From 6710627f55e7d09b14e1ef1bfdd50f522d893baa Mon Sep 17 00:00:00 2001 From: skypank Date: Thu, 9 Jul 2026 20:18:11 +0530 Subject: [PATCH 1/6] feat(db): add User model + resource-selection storage + login upsert (#586) Persist accounts on OIDC login and store a per-user resource (standard) selection. Additive to the existing session auth: the callback upserts the User row and sets session['user_id'] only when CRE_ENABLE_LOGIN is on; the existing session keys and 401 behaviour are unchanged. - User(google_sub UNIQUE, email, display_name, created_at, last_seen_at) - UserResourceSelection(user_id FK CASCADE, standard_name, UNIQUE(user_id, name)) - Node_collection: upsert_user / get_user_by_sub / get_user_resource_selection / set_user_resource_selection - Alembic: dedicated merge of the two open heads + create tables (up/down verified on Postgres; guardrail green) --- application/database/db.py | 113 ++++++++++++ application/tests/user_model_test.py | 164 ++++++++++++++++++ application/tests/web_main_test.py | 62 +++++++ application/web/web_main.py | 13 ++ ...d4e5f6_add_users_and_resource_selection.py | 50 ++++++ .../f0e1d2c3b4a5_merge_heads_before_users.py | 27 +++ 6 files changed, 429 insertions(+) create mode 100644 application/tests/user_model_test.py create mode 100644 migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py create mode 100644 migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py diff --git a/application/database/db.py b/application/database/db.py index 6600c3920..760e874ef 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -254,6 +254,49 @@ class StagedChangeSet(BaseModel): # type: ignore created_at = sqla.Column(sqla.DateTime, nullable=False) +class User(BaseModel): # type: ignore + """Persisted account identity for OIDC login (issue #586, RFC #876 TODO 1). + + ``google_sub`` is the immutable OIDC ``sub`` claim (the same value stored in + ``session['google_id']``); email can change, so identity is anchored on the sub. + """ + + __tablename__ = "users" + id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid) + google_sub = sqla.Column(sqla.String, nullable=False, unique=True) + email = sqla.Column(sqla.String, nullable=False) + display_name = sqla.Column(sqla.String, nullable=True) + created_at = sqla.Column(sqla.DateTime, nullable=False) + last_seen_at = sqla.Column(sqla.DateTime, nullable=False) + + resource_selection = sqla.relationship( + "UserResourceSelection", + cascade="all, delete-orphan", + ) + + __table_args__ = (sqla.UniqueConstraint(google_sub, name="uq_users_google_sub"),) + + +class UserResourceSelection(BaseModel): # type: ignore + """A single standard name a user has chosen to keep in view (issue #586).""" + + __tablename__ = "user_resource_selection" + id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid) + user_id = sqla.Column( + sqla.String, + sqla.ForeignKey("users.id", onupdate="CASCADE", ondelete="CASCADE"), + nullable=False, + ) + standard_name = sqla.Column(sqla.String, nullable=False) + created_at = sqla.Column(sqla.DateTime, nullable=False) + + __table_args__ = ( + sqla.UniqueConstraint( + user_id, standard_name, name="uq_user_resource_selection" + ), + ) + + def create_import_run(source: str, version: Optional[str] = None) -> ImportRun: """Create and persist an import run record. Returns the new ImportRun.""" from datetime import datetime, timezone @@ -992,6 +1035,76 @@ def with_graph(self) -> "Node_collection": return self + def upsert_user( + self, google_sub: str, email: str, display_name: Optional[str] = None + ) -> User: + """Create or refresh the User row for an OIDC ``sub`` (issue #586). + + Idempotent on ``google_sub``: a repeat login updates the existing row's + profile fields and ``last_seen_at`` instead of inserting a duplicate. + """ + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + user = self.session.query(User).filter(User.google_sub == google_sub).first() + if user is None: + user = User( + id=generate_uuid(), + google_sub=google_sub, + email=email, + display_name=display_name, + created_at=now, + last_seen_at=now, + ) + self.session.add(user) + else: + if email: + user.email = email + if display_name is not None: + user.display_name = display_name + user.last_seen_at = now + self.session.commit() + return user + + def get_user_by_sub(self, google_sub: str) -> Optional[User]: + return self.session.query(User).filter(User.google_sub == google_sub).first() + + def get_user_resource_selection(self, user_id: str) -> List[str]: + """Return the standard names a user has selected, ordered by name.""" + rows = ( + self.session.query(UserResourceSelection) + .filter(UserResourceSelection.user_id == user_id) + .order_by(UserResourceSelection.standard_name) + .all() + ) + return [row.standard_name for row in rows] + + def set_user_resource_selection( + self, user_id: str, standard_names: List[str] + ) -> List[str]: + """Replace a user's resource selection with ``standard_names`` (deduped).""" + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + deduped: List[str] = [] + for name in standard_names: + if name not in deduped: + deduped.append(name) + self.session.query(UserResourceSelection).filter( + UserResourceSelection.user_id == user_id + ).delete() + for name in deduped: + self.session.add( + UserResourceSelection( + id=generate_uuid(), + user_id=user_id, + standard_name=name, + created_at=now, + ) + ) + self.session.commit() + return self.get_user_resource_selection(user_id) + def __get_external_links(self) -> List[Tuple[CRE, Node, str]]: external_links: List[Tuple[CRE, Node, str]] = [] diff --git a/application/tests/user_model_test.py b/application/tests/user_model_test.py new file mode 100644 index 000000000..bf241de03 --- /dev/null +++ b/application/tests/user_model_test.py @@ -0,0 +1,164 @@ +"""Tests for user persistence and per-user resource selection (issue #586, RFC #876 TODO 1/2).""" + +import os +import unittest + +from sqlalchemy.exc import IntegrityError + +from application import create_app, sqla +from application.database import db + + +class TestUserModel(unittest.TestCase): + def setUp(self) -> None: + # These tests exercise only the SQL layer; skip the Neo4j graph load. + os.environ["NO_LOAD_GRAPH_DB"] = "1" + self.app = create_app(mode="test") + self.app_context = self.app.app_context() + self.app_context.push() + sqla.create_all() + self.collection = db.Node_collection() + + def tearDown(self) -> None: + sqla.session.remove() + sqla.drop_all() + self.app_context.pop() + os.environ.pop("NO_LOAD_GRAPH_DB", None) + + def test_upsert_user_creates_single_row(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + self.assertIsNotNone(user.id) + self.assertEqual(user.google_sub, "sub-123") + self.assertEqual(user.email, "a@example.com") + self.assertEqual(user.display_name, "Alice") + self.assertIsNotNone(user.created_at) + self.assertIsNotNone(user.last_seen_at) + self.assertEqual(sqla.session.query(db.User).count(), 1) + + def test_upsert_user_is_idempotent_and_updates_in_place(self) -> None: + first = self.collection.upsert_user( + google_sub="sub-123", email="old@example.com", display_name="Alice" + ) + first_id = first.id + created_at = first.created_at + + second = self.collection.upsert_user( + google_sub="sub-123", email="new@example.com", display_name="Alice B" + ) + + # No duplicate row, same identity, refreshed profile fields. + self.assertEqual(sqla.session.query(db.User).count(), 1) + self.assertEqual(second.id, first_id) + self.assertEqual(second.email, "new@example.com") + self.assertEqual(second.display_name, "Alice B") + self.assertEqual(second.created_at, created_at) + self.assertGreaterEqual(second.last_seen_at, created_at) + + def test_upsert_user_tolerates_missing_email_and_name(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-noemail", email="", display_name=None + ) + self.assertEqual(user.email, "") + self.assertIsNone(user.display_name) + self.assertEqual(sqla.session.query(db.User).count(), 1) + + def test_get_user_by_sub(self) -> None: + self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + found = self.collection.get_user_by_sub("sub-123") + self.assertIsNotNone(found) + self.assertEqual(found.google_sub, "sub-123") + self.assertIsNone(self.collection.get_user_by_sub("does-not-exist")) + + def test_resource_selection_round_trips(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + # Empty by default. + self.assertEqual(self.collection.get_user_resource_selection(user.id), []) + + stored = self.collection.set_user_resource_selection( + user.id, ["ASVS", "CWE", "SAMM"] + ) + self.assertEqual(sorted(stored), ["ASVS", "CWE", "SAMM"]) + self.assertEqual( + self.collection.get_user_resource_selection(user.id), + ["ASVS", "CWE", "SAMM"], + ) + + def test_set_resource_selection_replaces_previous(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) + self.collection.set_user_resource_selection(user.id, ["SAMM"]) + self.assertEqual(self.collection.get_user_resource_selection(user.id), ["SAMM"]) + self.assertEqual( + sqla.session.query(db.UserResourceSelection) + .filter(db.UserResourceSelection.user_id == user.id) + .count(), + 1, + ) + + def test_set_resource_selection_dedupes_input(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + self.collection.set_user_resource_selection(user.id, ["ASVS", "ASVS", "CWE"]) + self.assertEqual( + self.collection.get_user_resource_selection(user.id), ["ASVS", "CWE"] + ) + + def test_resource_selection_is_per_user(self) -> None: + alice = self.collection.upsert_user( + google_sub="sub-a", email="a@example.com", display_name="Alice" + ) + bob = self.collection.upsert_user( + google_sub="sub-b", email="b@example.com", display_name="Bob" + ) + self.collection.set_user_resource_selection(alice.id, ["ASVS"]) + self.collection.set_user_resource_selection(bob.id, ["CWE"]) + self.assertEqual( + self.collection.get_user_resource_selection(alice.id), ["ASVS"] + ) + self.assertEqual(self.collection.get_user_resource_selection(bob.id), ["CWE"]) + + def test_resource_selection_unique_constraint_enforced(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + from datetime import datetime, timezone + + sqla.session.add( + db.UserResourceSelection( + user_id=user.id, + standard_name="ASVS", + created_at=datetime.now(timezone.utc), + ) + ) + sqla.session.add( + db.UserResourceSelection( + user_id=user.id, + standard_name="ASVS", + created_at=datetime.now(timezone.utc), + ) + ) + with self.assertRaises(IntegrityError): + sqla.session.commit() + sqla.session.rollback() + + def test_deleting_user_cascades_to_selection(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-123", email="a@example.com", display_name="Alice" + ) + self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) + sqla.session.delete(user) + sqla.session.commit() + self.assertEqual(sqla.session.query(db.UserResourceSelection).count(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 0fab4fc74..5f3c2225f 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -1151,6 +1151,68 @@ def test_gap_analysis_weak_links_response(self, db_mock) -> None: self.assertEqual(200, response.status_code) self.assertEqual(expected, json.loads(response.data)) + @patch("application.web.web_main.id_token") + @patch("application.web.web_main.CREFlow") + def test_callback_upserts_user_and_sets_session( + self, cre_flow_mock, id_token_mock + ) -> None: + id_token_mock.verify_oauth2_token.return_value = { + "sub": "sub-xyz", + "name": "Test User", + "email": "test@example.com", + } + flow_instance = cre_flow_mock.instance.return_value + flow_instance.flow.credentials._id_token = "tok" + self.app.secret_key = "test-secret" + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "LOGIN_ALLOWED_DOMAINS": "*", + "NO_LOAD_GRAPH_DB": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + with client.session_transaction() as sess: + sess["state"] = "xyz" + client.get("/rest/v1/callback?state=xyz") + with client.session_transaction() as sess: + self.assertIn("user_id", sess) + + users = sqla.session.query(db.User).all() + self.assertEqual(len(users), 1) + self.assertEqual(users[0].google_sub, "sub-xyz") + self.assertEqual(users[0].email, "test@example.com") + self.assertEqual(users[0].display_name, "Test User") + + @patch("application.web.web_main.id_token") + @patch("application.web.web_main.CREFlow") + def test_callback_does_not_persist_when_login_flag_off( + self, cre_flow_mock, id_token_mock + ) -> None: + id_token_mock.verify_oauth2_token.return_value = { + "sub": "sub-xyz", + "name": "Test User", + "email": "test@example.com", + } + flow_instance = cre_flow_mock.instance.return_value + flow_instance.flow.credentials._id_token = "tok" + self.app.secret_key = "test-secret" + env = { + "LOGIN_ALLOWED_DOMAINS": "*", + "NO_LOAD_GRAPH_DB": "1", + "INSECURE_REQUESTS": "1", + } + with patch.dict(os.environ, env): + os.environ.pop("CRE_ENABLE_LOGIN", None) + with self.app.test_client() as client: + with client.session_transaction() as sess: + sess["state"] = "xyz" + client.get("/rest/v1/callback?state=xyz") + + self.assertEqual(sqla.session.query(db.User).count(), 0) + def test_deeplink(self) -> None: self.maxDiff = None collection = db.Node_collection().with_graph() diff --git a/application/web/web_main.py b/application/web/web_main.py index 012438476..fddd46773 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -1132,6 +1132,19 @@ def callback(): 401, description=f"You need an account with one of the following providers to access this functionality {allowed_domains}", ) + + # Persist the account when login is enabled; the session keeps working + # unchanged if this no-ops (flag off) or fails. + if is_login_enabled(): + try: + user = db.Node_collection().upsert_user( + google_sub=id_info.get("sub"), + email=id_info.get("email") or "", + display_name=id_info.get("name"), + ) + session["user_id"] = user.id + except Exception as e: + logger.error(f"failed to persist user on login: {e}") return redirect("/chatbot") diff --git a/migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py b/migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py new file mode 100644 index 000000000..e333e6890 --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_add_users_and_resource_selection.py @@ -0,0 +1,50 @@ +"""add users and user_resource_selection tables (issue #586) + +Revision ID: a1b2c3d4e5f6 +Revises: f0e1d2c3b4a5 +Create Date: 2026-07-08 + +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "a1b2c3d4e5f6" +down_revision = "f0e1d2c3b4a5" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "users", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("google_sub", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("display_name", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("last_seen_at", sa.DateTime(), nullable=False), + sa.UniqueConstraint("google_sub", name="uq_users_google_sub"), + ) + op.create_table( + "user_resource_selection", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("standard_name", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + onupdate="CASCADE", + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "user_id", "standard_name", name="uq_user_resource_selection" + ), + ) + + +def downgrade(): + op.drop_table("user_resource_selection") + op.drop_table("users") diff --git a/migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py b/migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py new file mode 100644 index 000000000..3f33cf2c3 --- /dev/null +++ b/migrations/versions/f0e1d2c3b4a5_merge_heads_before_users.py @@ -0,0 +1,27 @@ +"""merge embeddings heads (ab12cd34ef56, 9b1c2d3e4f50) before users + +No schema changes; collapses the two open heads into a single lineage so the +users migration has a single parent and ``flask db downgrade`` is unambiguous. + +Revision ID: f0e1d2c3b4a5 +Revises: ab12cd34ef56, 9b1c2d3e4f50 +Create Date: 2026-07-08 + +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "f0e1d2c3b4a5" +down_revision = ("ab12cd34ef56", "9b1c2d3e4f50") +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass From 2c0cd6e532eaa0c8b4f1efaf07e381a73ef499de Mon Sep 17 00:00:00 2001 From: skypank Date: Thu, 9 Jul 2026 21:00:28 +0530 Subject: [PATCH 2/6] fix(db): concurrency-safe upsert_user + drop redundant unique constraint (#586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: - upsert_user now mirrors add_node: catch IntegrityError, rollback, re-query by google_sub, update profile — safe under concurrent logins. - Drop column-level unique=True on User.google_sub; keep only the named constraint so create_all matches the Alembic migration (no schema drift). - Login callback skips persistence when the OIDC 'sub' claim is missing. - Callback test asserts session['user_id'] equals the persisted User.id. --- application/database/db.py | 53 ++++++++++++++++++++---------- application/tests/web_main_test.py | 4 +++ application/web/web_main.py | 22 ++++++++----- 3 files changed, 54 insertions(+), 25 deletions(-) diff --git a/application/database/db.py b/application/database/db.py index 56d3ea336..054dadf4e 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -263,7 +263,7 @@ class User(BaseModel): # type: ignore __tablename__ = "users" id = sqla.Column(sqla.String, primary_key=True, default=generate_uuid) - google_sub = sqla.Column(sqla.String, nullable=False, unique=True) + google_sub = sqla.Column(sqla.String, nullable=False) email = sqla.Column(sqla.String, nullable=False) display_name = sqla.Column(sqla.String, nullable=True) created_at = sqla.Column(sqla.DateTime, nullable=False) @@ -1046,24 +1046,43 @@ def upsert_user( from datetime import datetime, timezone now = datetime.now(timezone.utc) - user = self.session.query(User).filter(User.google_sub == google_sub).first() - if user is None: - user = User( - id=generate_uuid(), - google_sub=google_sub, - email=email, - display_name=display_name, - created_at=now, - last_seen_at=now, - ) - self.session.add(user) - else: + + def _apply_profile(u: User) -> None: if email: - user.email = email + u.email = email if display_name is not None: - user.display_name = display_name - user.last_seen_at = now - self.session.commit() + u.display_name = display_name + u.last_seen_at = now + + user = self.session.query(User).filter(User.google_sub == google_sub).first() + if user is not None: + _apply_profile(user) + self.session.commit() + return user + + user = User( + id=generate_uuid(), + google_sub=google_sub, + email=email, + display_name=display_name, + created_at=now, + last_seen_at=now, + ) + self.session.add(user) + try: + self.session.commit() + except IntegrityError: + # A concurrent login inserted the same google_sub first; recover by + # rolling back and updating the now-existing row (mirrors add_node). + self.session.rollback() + existing = ( + self.session.query(User).filter(User.google_sub == google_sub).first() + ) + if existing is None: + raise + _apply_profile(existing) + self.session.commit() + return existing return user def get_user_by_sub(self, google_sub: str) -> Optional[User]: diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 5f3c2225f..6b33c26d7 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -1173,18 +1173,22 @@ def test_callback_upserts_user_and_sets_session( "INSECURE_REQUESTS": "1", }, ): + session_user_id = None with self.app.test_client() as client: with client.session_transaction() as sess: sess["state"] = "xyz" client.get("/rest/v1/callback?state=xyz") with client.session_transaction() as sess: self.assertIn("user_id", sess) + session_user_id = sess["user_id"] users = sqla.session.query(db.User).all() self.assertEqual(len(users), 1) self.assertEqual(users[0].google_sub, "sub-xyz") self.assertEqual(users[0].email, "test@example.com") self.assertEqual(users[0].display_name, "Test User") + # Session id must match the persisted row (guards PR2's /user wiring). + self.assertEqual(session_user_id, users[0].id) @patch("application.web.web_main.id_token") @patch("application.web.web_main.CREFlow") diff --git a/application/web/web_main.py b/application/web/web_main.py index 546926532..e1c737538 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -1165,15 +1165,21 @@ def callback(): # Persist the account when login is enabled; the session keeps working # unchanged if this no-ops (flag off) or fails. if is_login_enabled(): - try: - user = db.Node_collection().upsert_user( - google_sub=id_info.get("sub"), - email=id_info.get("email") or "", - display_name=id_info.get("name"), + google_sub = id_info.get("sub") + if not google_sub: + logger.error( + "OIDC callback returned no 'sub' claim; skipping user persistence" ) - session["user_id"] = user.id - except Exception as e: - logger.error(f"failed to persist user on login: {e}") + else: + try: + user = db.Node_collection().upsert_user( + google_sub=google_sub, + email=id_info.get("email") or "", + display_name=id_info.get("name"), + ) + session["user_id"] = user.id + except Exception as e: + logger.error(f"failed to persist user on login: {e}") return redirect("/chatbot") From 1bb24249b336dee6d73f30d60988cd74589c31e8 Mon Sep 17 00:00:00 2001 From: skypank Date: Mon, 13 Jul 2026 17:50:32 +0530 Subject: [PATCH 3/6] fix(api): redact login-persistence error log + cover missing-sub skip (#586) Address PR review: - Log only the exception class on login-persistence failure; the raw message can carry SQL parameters (email, OIDC sub). - Add callback test: login enabled but token missing 'sub' creates no user row and leaves session['user_id'] unset. --- application/tests/web_main_test.py | 32 ++++++++++++++++++++++++++++++ application/web/web_main.py | 4 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/application/tests/web_main_test.py b/application/tests/web_main_test.py index 6b33c26d7..ded260242 100644 --- a/application/tests/web_main_test.py +++ b/application/tests/web_main_test.py @@ -1217,6 +1217,38 @@ def test_callback_does_not_persist_when_login_flag_off( self.assertEqual(sqla.session.query(db.User).count(), 0) + @patch("application.web.web_main.id_token") + @patch("application.web.web_main.CREFlow") + def test_callback_skips_persistence_when_sub_missing( + self, cre_flow_mock, id_token_mock + ) -> None: + # Login enabled, but the OIDC token has no 'sub' claim: must not create + # a user (google_sub is NOT NULL) and must not set session['user_id']. + id_token_mock.verify_oauth2_token.return_value = { + "name": "Test User", + "email": "test@example.com", + } + flow_instance = cre_flow_mock.instance.return_value + flow_instance.flow.credentials._id_token = "tok" + self.app.secret_key = "test-secret" + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "LOGIN_ALLOWED_DOMAINS": "*", + "NO_LOAD_GRAPH_DB": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + with client.session_transaction() as sess: + sess["state"] = "xyz" + client.get("/rest/v1/callback?state=xyz") + with client.session_transaction() as sess: + self.assertNotIn("user_id", sess) + + self.assertEqual(sqla.session.query(db.User).count(), 0) + def test_deeplink(self) -> None: self.maxDiff = None collection = db.Node_collection().with_graph() diff --git a/application/web/web_main.py b/application/web/web_main.py index e1c737538..5abf6982b 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -1179,7 +1179,9 @@ def callback(): ) session["user_id"] = user.id except Exception as e: - logger.error(f"failed to persist user on login: {e}") + # Avoid logging the raw exception: it can carry SQL parameters + # such as the user's email or OIDC subject. Log only the class. + logger.error("failed to persist user on login: %s", type(e).__name__) return redirect("/chatbot") From 5156c510efe97ce423bfb8baa602ecc45873bab1 Mon Sep 17 00:00:00 2001 From: skypank Date: Tue, 14 Jul 2026 02:13:27 +0530 Subject: [PATCH 4/6] feat(api): add GET/PUT /rest/v1/user/resources (#586) --- application/tests/user_resources_api_test.py | 262 +++++++++++++++++++ application/web/openapi_registry.py | 56 ++++ application/web/web_main.py | 78 ++++++ docs/api/openapi.yaml | 37 +++ 4 files changed, 433 insertions(+) create mode 100644 application/tests/user_resources_api_test.py diff --git a/application/tests/user_resources_api_test.py b/application/tests/user_resources_api_test.py new file mode 100644 index 000000000..1a49e0cdb --- /dev/null +++ b/application/tests/user_resources_api_test.py @@ -0,0 +1,262 @@ +"""Tests for the per-user resource-selection API (issue #586, PR2). + +GET/PUT /rest/v1/user/resources, gated by login_required + is_login_enabled. +Flag-off returns a safe default; authenticated users read/write their selection. +""" + +import json +import os +import unittest +from typing import Any +from unittest.mock import patch + +from application import create_app, sqla +from application.database import db + + +class TestUserResourcesApi(unittest.TestCase): + def setUp(self) -> None: + # SQL-only surface; skip the Neo4j graph load and allow http in tests. + os.environ["NO_LOAD_GRAPH_DB"] = "1" + self.app = create_app(mode="test") + self.app.secret_key = "test-secret" + self.app_context = self.app.app_context() + self.app_context.push() + sqla.create_all() + self.collection = db.Node_collection() + + def tearDown(self) -> None: + sqla.session.remove() + sqla.drop_all() + self.app_context.pop() + os.environ.pop("NO_LOAD_GRAPH_DB", None) + + def _login(self, client: Any, google_sub: str = "sub-1", name: str = "U") -> None: + with client.session_transaction() as sess: + sess["google_id"] = google_sub + sess["name"] = name + + # --- flag off -> safe default, no auth required, no writes --- + def test_get_returns_default_when_login_disabled(self) -> None: + with patch.dict(os.environ, {"INSECURE_REQUESTS": "1"}): + os.environ.pop("CRE_ENABLE_LOGIN", None) + with self.app.test_client() as client: + resp = client.get("/rest/v1/user/resources") + self.assertEqual(resp.status_code, 200) + self.assertEqual(json.loads(resp.data), {"selected": []}) + + def test_put_noops_when_login_disabled(self) -> None: + with patch.dict(os.environ, {"INSECURE_REQUESTS": "1"}): + os.environ.pop("CRE_ENABLE_LOGIN", None) + with self.app.test_client() as client: + resp = client.put( + "/rest/v1/user/resources", json={"selected": ["ASVS"]} + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(json.loads(resp.data), {"selected": []}) + self.assertEqual(sqla.session.query(db.UserResourceSelection).count(), 0) + + # --- flag on, anonymous -> 401 --- + def test_get_401_when_anonymous(self) -> None: + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + resp = client.get("/rest/v1/user/resources") + self.assertEqual(resp.status_code, 401) + + def test_put_401_when_anonymous(self) -> None: + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + resp = client.put( + "/rest/v1/user/resources", json={"selected": ["ASVS"]} + ) + self.assertEqual(resp.status_code, 401) + + # --- flag on, authenticated --- + def test_get_returns_saved_selection(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + resp = client.get("/rest/v1/user/resources") + self.assertEqual(resp.status_code, 200) + self.assertEqual(json.loads(resp.data), {"selected": ["ASVS", "CWE"]}) + + def test_get_returns_empty_for_new_user(self) -> None: + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-new", "U") + resp = client.get("/rest/v1/user/resources") + self.assertEqual(resp.status_code, 200) + self.assertEqual(json.loads(resp.data), {"selected": []}) + + def test_put_persists_and_returns_selection(self) -> None: + self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + resp = client.put( + "/rest/v1/user/resources", json={"selected": ["CWE", "ASVS"]} + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual( + sorted(json.loads(resp.data)["selected"]), ["ASVS", "CWE"] + ) + get = client.get("/rest/v1/user/resources") + self.assertEqual( + sorted(json.loads(get.data)["selected"]), ["ASVS", "CWE"] + ) + + def test_put_replaces_previous_selection(self) -> None: + user = self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + client.put("/rest/v1/user/resources", json={"selected": ["SAMM"]}) + get = client.get("/rest/v1/user/resources") + self.assertEqual(json.loads(get.data)["selected"], ["SAMM"]) + + def test_put_dedupes_input(self) -> None: + self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + resp = client.put( + "/rest/v1/user/resources", + json={"selected": ["ASVS", "ASVS", "CWE"]}, + ) + self.assertEqual( + sorted(json.loads(resp.data)["selected"]), ["ASVS", "CWE"] + ) + + def test_put_400_on_invalid_body(self) -> None: + self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + self.assertEqual( + client.put( + "/rest/v1/user/resources", json={"foo": "bar"} + ).status_code, + 400, + ) + self.assertEqual( + client.put( + "/rest/v1/user/resources", json={"selected": "ASVS"} + ).status_code, + 400, + ) + self.assertEqual( + client.put( + "/rest/v1/user/resources", json={"selected": [1, 2]} + ).status_code, + 400, + ) + + # --- login on but myopencre off -> safe default, no writes --- + def test_get_returns_default_when_myopencre_disabled(self) -> None: + # Seed a real, non-empty selection. With myopencre off the endpoint must + # return the safe default [] instead of it, proving the gate short-circuits + # BEFORE reading the DB (an empty-user default would pass for the wrong + # reason). If the gate were bypassed, this would return ["ASVS", "CWE"]. + user = self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + self.collection.set_user_resource_selection(user.id, ["ASVS", "CWE"]) + with patch.dict( + os.environ, {"CRE_ENABLE_LOGIN": "1", "INSECURE_REQUESTS": "1"} + ): + os.environ.pop("CRE_ENABLE_MYOPENCRE", None) + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + resp = client.get("/rest/v1/user/resources") + self.assertEqual(resp.status_code, 200) + self.assertEqual(json.loads(resp.data), {"selected": []}) + + def test_put_noops_when_myopencre_disabled(self) -> None: + self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + with patch.dict( + os.environ, {"CRE_ENABLE_LOGIN": "1", "INSECURE_REQUESTS": "1"} + ): + os.environ.pop("CRE_ENABLE_MYOPENCRE", None) + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + resp = client.put( + "/rest/v1/user/resources", json={"selected": ["ASVS"]} + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(json.loads(resp.data), {"selected": []}) + self.assertEqual(sqla.session.query(db.UserResourceSelection).count(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/application/web/openapi_registry.py b/application/web/openapi_registry.py index 34ec2cc7c..d171b2512 100644 --- a/application/web/openapi_registry.py +++ b/application/web/openapi_registry.py @@ -301,6 +301,62 @@ def __init__( response_schema=schemas.ConfigResponseSchema, not_found=False, ), + PathSpec( + "/rest/v1/user/resources", + "get_user_resources", + tags=["User"], + summary="Get the current user's selected standards", + description=( + "Requires login and the MyOpenCRE feature; returns an empty " + "selection when either is disabled." + ), + not_found=False, + response_override={ + "200": { + "description": "The user's selected standards", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "selected": { + "type": "array", + "items": {"type": "string"}, + } + }, + } + } + }, + } + }, + ), + PathSpec( + "/rest/v1/user/resources", + "put_user_resources", + method="put", + tags=["User"], + summary="Replace the current user's selected standards", + not_found=False, + extra_responses={"400": {"description": "Invalid selection body"}}, + response_override={ + "200": { + "description": "The stored selection", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "selected": { + "type": "array", + "items": {"type": "string"}, + } + }, + } + } + }, + } + }, + ), ] diff --git a/application/web/web_main.py b/application/web/web_main.py index 5abf6982b..4bc9ed611 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -843,6 +843,45 @@ def login_r(*args, **kwargs): return login_r +def feature_enabled_or_default(is_enabled: Any, default_factory: Any) -> Any: + """Gate a view on a feature predicate. + + When ``is_enabled()`` is false the wrapped view is skipped and + ``default_factory()`` is returned, so callers receive a safe default instead + of an auth error. When true the view runs (typically behind ``login_required``). + """ + + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not is_enabled(): + return default_factory() + return f(*args, **kwargs) + + return wrapper + + return decorator + + +def _resolve_current_user(database): + """Return the persisted User for the current session, creating it if absent. + + Anchored on the OIDC subject in ``session['google_id']`` (guaranteed present + by ``login_required``). Returns None when there is no authenticated subject. + """ + google_sub = session.get("google_id") + if not google_sub: + return None + user = database.get_user_by_sub(google_sub) + if user is None: + user = database.upsert_user( + google_sub=google_sub, + email=session.get("email") or "", + display_name=session.get("name"), + ) + return user + + def admin_imports_enabled_required(f): @wraps(f) def enabled_r(*args, **kwargs): @@ -1191,6 +1230,45 @@ def logout(): return redirect("/") +@openapi_documented("get_user_resources") +@app.route("/rest/v1/user/resources", methods=["GET"]) +@feature_enabled_or_default( + lambda: is_login_enabled() and is_myopencre_enabled(), + lambda: jsonify({"selected": []}), +) +@login_required +def get_user_resources() -> Any: + """Return the standard names the current user has selected.""" + database = db.Node_collection() + user = _resolve_current_user(database) + if user is None: + abort(401, description="Not authenticated") + return jsonify({"selected": database.get_user_resource_selection(user.id)}) + + +@openapi_documented("put_user_resources") +@app.route("/rest/v1/user/resources", methods=["PUT"]) +@feature_enabled_or_default( + lambda: is_login_enabled() and is_myopencre_enabled(), + lambda: jsonify({"selected": []}), +) +@login_required +def put_user_resources() -> Any: + """Replace the current user's selected standards.""" + body = request.get_json(silent=True) + if not isinstance(body, dict) or not isinstance(body.get("selected"), list): + abort(400, description="Body must be a JSON object with a 'selected' list") + selected = body["selected"] + if not all(isinstance(name, str) and name.strip() for name in selected): + abort(400, description="'selected' must be a list of non-empty strings") + database = db.Node_collection() + user = _resolve_current_user(database) + if user is None: + abort(401, description="Not authenticated") + stored = database.set_user_resource_selection(user.id, selected) + return jsonify({"selected": stored}) + + @openapi_documented("all_cres") @app.route("/rest/v1/all_cres", methods=["GET"]) def all_cres() -> Any: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 08931d824..ec8a7316a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -494,6 +494,43 @@ paths: description: Not found '400': description: Missing text parameter + /rest/v1/user/resources: + get: + tags: + - User + summary: Get the current user's selected standards + description: Requires login and the MyOpenCRE feature; returns an empty selection + when either is disabled. + responses: + '200': + description: The user's selected standards + content: + application/json: + schema: + type: object + properties: + selected: + type: array + items: + type: string + put: + tags: + - User + summary: Replace the current user's selected standards + responses: + '200': + description: The stored selection + content: + application/json: + schema: + type: object + properties: + selected: + type: array + items: + type: string + '400': + description: Invalid selection body /rest/v1/{ntype}/{name}: get: tags: From 95f914c9b6ce57e84566f7268384c2342286c148 Mon Sep 17 00:00:00 2001 From: skypank Date: Tue, 14 Jul 2026 05:44:30 +0530 Subject: [PATCH 5/6] =?UTF-8?q?fix(api):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?concurrency,=20request-body=20spec,=20value=20trimming=20(#586)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/database/db.py | 35 +++++++++++++------- application/tests/user_resources_api_test.py | 26 +++++++++++++++ application/web/openapi_registry.py | 27 +++++++++++++++ application/web/web_main.py | 7 ++-- docs/api/openapi.yaml | 16 +++++++++ 5 files changed, 97 insertions(+), 14 deletions(-) diff --git a/application/database/db.py b/application/database/db.py index 054dadf4e..d0776bc79 100644 --- a/application/database/db.py +++ b/application/database/db.py @@ -1109,19 +1109,30 @@ def set_user_resource_selection( for name in standard_names: if name not in deduped: deduped.append(name) - self.session.query(UserResourceSelection).filter( - UserResourceSelection.user_id == user_id - ).delete() - for name in deduped: - self.session.add( - UserResourceSelection( - id=generate_uuid(), - user_id=user_id, - standard_name=name, - created_at=now, + + def _replace() -> None: + self.session.query(UserResourceSelection).filter( + UserResourceSelection.user_id == user_id + ).delete() + for name in deduped: + self.session.add( + UserResourceSelection( + id=generate_uuid(), + user_id=user_id, + standard_name=name, + created_at=now, + ) ) - ) - self.session.commit() + self.session.commit() + + try: + _replace() + except IntegrityError: + # A concurrent PUT for the same user can collide on + # uq_user_resource_selection between our delete and our inserts; + # roll back and retry once (mirrors upsert_user). + self.session.rollback() + _replace() return self.get_user_resource_selection(user_id) def __get_external_links(self) -> List[Tuple[CRE, Node, str]]: diff --git a/application/tests/user_resources_api_test.py b/application/tests/user_resources_api_test.py index 1a49e0cdb..37b996f5e 100644 --- a/application/tests/user_resources_api_test.py +++ b/application/tests/user_resources_api_test.py @@ -187,6 +187,32 @@ def test_put_dedupes_input(self) -> None: sorted(json.loads(resp.data)["selected"]), ["ASVS", "CWE"] ) + def test_put_trims_and_dedupes_whitespace_variants(self) -> None: + # " ASVS " and "ASVS" must normalize to a single stored entry, otherwise + # they'd persist as distinct rows and defeat the dedupe. + self.collection.upsert_user( + google_sub="sub-1", email="a@x.com", display_name="U" + ) + with patch.dict( + os.environ, + { + "CRE_ENABLE_LOGIN": "1", + "CRE_ENABLE_MYOPENCRE": "1", + "INSECURE_REQUESTS": "1", + }, + ): + with self.app.test_client() as client: + self._login(client, "sub-1", "U") + resp = client.put( + "/rest/v1/user/resources", + json={"selected": [" ASVS ", "ASVS", "CWE "]}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual( + sorted(json.loads(resp.data)["selected"]), ["ASVS", "CWE"] + ) + self.assertEqual(sqla.session.query(db.UserResourceSelection).count(), 2) + def test_put_400_on_invalid_body(self) -> None: self.collection.upsert_user( google_sub="sub-1", email="a@x.com", display_name="U" diff --git a/application/web/openapi_registry.py b/application/web/openapi_registry.py index d171b2512..b5fa9a3d0 100644 --- a/application/web/openapi_registry.py +++ b/application/web/openapi_registry.py @@ -58,6 +58,7 @@ class PathSpec: "not_found", "extra_responses", "response_override", + "request_body", ) def __init__( @@ -74,6 +75,7 @@ def __init__( not_found: bool = True, extra_responses: Optional[Dict[str, Any]] = None, response_override: Optional[Dict[str, Any]] = None, + request_body: Optional[Dict[str, Any]] = None, ) -> None: self.path = path self.method = method.lower() @@ -86,6 +88,7 @@ def __init__( self.not_found = not_found self.extra_responses = extra_responses or {} self.response_override = response_override + self.request_body = request_body OPENAPI_PATHS: List[PathSpec] = [ @@ -338,6 +341,27 @@ def __init__( summary="Replace the current user's selected standards", not_found=False, extra_responses={"400": {"description": "Invalid selection body"}}, + request_body={ + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["selected"], + "properties": { + "selected": { + "type": "array", + "items": {"type": "string", "minLength": 1}, + "description": ( + "Standard names to select. Non-empty strings; " + "values are trimmed and deduplicated." + ), + } + }, + } + } + }, + }, response_override={ "200": { "description": "The stored selection", @@ -449,6 +473,9 @@ def _operation_from_path( if parameters: operation["parameters"] = parameters + if path_spec.request_body is not None: + operation["requestBody"] = path_spec.request_body + if path_spec.response_override is not None: responses = dict(path_spec.response_override) else: diff --git a/application/web/web_main.py b/application/web/web_main.py index 4bc9ed611..897ca78da 100644 --- a/application/web/web_main.py +++ b/application/web/web_main.py @@ -1258,9 +1258,12 @@ def put_user_resources() -> Any: body = request.get_json(silent=True) if not isinstance(body, dict) or not isinstance(body.get("selected"), list): abort(400, description="Body must be a JSON object with a 'selected' list") - selected = body["selected"] - if not all(isinstance(name, str) and name.strip() for name in selected): + raw_selected = body["selected"] + if not all(isinstance(name, str) and name.strip() for name in raw_selected): abort(400, description="'selected' must be a list of non-empty strings") + # Normalize before storing: otherwise " ASVS " and "ASVS" both validate but + # persist as distinct rows, defeating the dedupe. + selected = [name.strip() for name in raw_selected] database = db.Node_collection() user = _resolve_current_user(database) if user is None: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ec8a7316a..761b8f574 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -517,6 +517,22 @@ paths: tags: - User summary: Replace the current user's selected standards + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - selected + properties: + selected: + type: array + items: + type: string + minLength: 1 + description: Standard names to select. Non-empty strings; values + are trimmed and deduplicated. responses: '200': description: The stored selection From 7c4fe2ce6af1149a2e421c49648b99f32c5508b8 Mon Sep 17 00:00:00 2001 From: skypank Date: Tue, 14 Jul 2026 05:56:38 +0530 Subject: [PATCH 6/6] docs(api): document 401 and require 'selected' in resource-selection spec (#586) --- application/web/openapi_registry.py | 30 ++++++++++++++++++++++++++--- docs/api/openapi.yaml | 18 +++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/application/web/openapi_registry.py b/application/web/openapi_registry.py index b5fa9a3d0..438111f91 100644 --- a/application/web/openapi_registry.py +++ b/application/web/openapi_registry.py @@ -310,10 +310,19 @@ def __init__( tags=["User"], summary="Get the current user's selected standards", description=( - "Requires login and the MyOpenCRE feature; returns an empty " - "selection when either is disabled." + "Requires login (CRE_ENABLE_LOGIN) and the MyOpenCRE feature " + "(CRE_ENABLE_MYOPENCRE). When either flag is disabled the endpoint " + "does not authenticate and returns an empty selection. When both are " + "enabled, anonymous requests receive 401." ), not_found=False, + extra_responses={ + "401": { + "description": ( + "Not authenticated (both feature flags enabled and no active session)" + ) + } + }, response_override={ "200": { "description": "The user's selected standards", @@ -321,6 +330,7 @@ def __init__( "application/json": { "schema": { "type": "object", + "required": ["selected"], "properties": { "selected": { "type": "array", @@ -339,8 +349,21 @@ def __init__( method="put", tags=["User"], summary="Replace the current user's selected standards", + description=( + "Requires login (CRE_ENABLE_LOGIN) and the MyOpenCRE feature " + "(CRE_ENABLE_MYOPENCRE). When either flag is disabled the request is a " + "no-op and returns an empty selection. When both are enabled, anonymous " + "requests receive 401." + ), not_found=False, - extra_responses={"400": {"description": "Invalid selection body"}}, + extra_responses={ + "400": {"description": "Invalid selection body"}, + "401": { + "description": ( + "Not authenticated (both feature flags enabled and no active session)" + ) + }, + }, request_body={ "required": True, "content": { @@ -369,6 +392,7 @@ def __init__( "application/json": { "schema": { "type": "object", + "required": ["selected"], "properties": { "selected": { "type": "array", diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 761b8f574..4bf657765 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -499,8 +499,9 @@ paths: tags: - User summary: Get the current user's selected standards - description: Requires login and the MyOpenCRE feature; returns an empty selection - when either is disabled. + description: Requires login (CRE_ENABLE_LOGIN) and the MyOpenCRE feature (CRE_ENABLE_MYOPENCRE). + When either flag is disabled the endpoint does not authenticate and returns + an empty selection. When both are enabled, anonymous requests receive 401. responses: '200': description: The user's selected standards @@ -508,15 +509,23 @@ paths: application/json: schema: type: object + required: + - selected properties: selected: type: array items: type: string + '401': + description: Not authenticated (both feature flags enabled and no active + session) put: tags: - User summary: Replace the current user's selected standards + description: Requires login (CRE_ENABLE_LOGIN) and the MyOpenCRE feature (CRE_ENABLE_MYOPENCRE). + When either flag is disabled the request is a no-op and returns an empty selection. + When both are enabled, anonymous requests receive 401. requestBody: required: true content: @@ -540,6 +549,8 @@ paths: application/json: schema: type: object + required: + - selected properties: selected: type: array @@ -547,6 +558,9 @@ paths: type: string '400': description: Invalid selection body + '401': + description: Not authenticated (both feature flags enabled and no active + session) /rest/v1/{ntype}/{name}: get: tags: