From 3715eec0cda3fd9615963450ec433c7537d93bd8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 23:54:56 +0900 Subject: [PATCH 01/34] chore: stage bounded #1078 security repair --- ...air-summary-catalog-authorization-1078.yml | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 .github/workflows/repair-summary-catalog-authorization-1078.yml diff --git a/.github/workflows/repair-summary-catalog-authorization-1078.yml b/.github/workflows/repair-summary-catalog-authorization-1078.yml new file mode 100644 index 000000000..599ea698d --- /dev/null +++ b/.github/workflows/repair-summary-catalog-authorization-1078.yml @@ -0,0 +1,162 @@ +name: Repair summary catalog authorization 1078 + +on: + push: + branches: + - fix/summary-catalog-authorization-1078 + +permissions: + contents: write + +jobs: + repair: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/summary-catalog-authorization-1078 + fetch-depth: 0 + + - name: Confirm realistic RED on protected-main-derived source + shell: bash + run: | + set +e + python - <<'PY' + from pathlib import Path + + main = Path('backend/app/main.py').read_text() + ingestion = Path('backend/app/post_summary_ingestion.py').read_text() + vulnerabilities = [] + if 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' in main: + vulnerabilities.append('summary GET admits live hierarchy inference without checking post_admin') + if 'verification_client=_relation_verification_client(),' in main: + vulnerabilities.append('summary GET admits live relation verification without checking post_admin') + if 'cataloged_team_id = await upsert_team(' in ingestion: + vulnerabilities.append('summary persistence unconditionally upserts shared cataloged_team') + if vulnerabilities: + raise AssertionError('RED: ' + '; '.join(vulnerabilities)) + PY + red_status=$? + set -e + if [ "$red_status" -eq 0 ]; then + echo 'Expected #1078 RED did not reproduce; refusing to patch a moved source.' >&2 + exit 1 + fi + echo 'Expected #1078 RED reproduced.' + + - name: Apply minimal causal authorization repair + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + file_path = Path(path) + text = file_path.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected exactly one replacement target, found {count}') + file_path.write_text(text.replace(old, new, 1)) + + main_path = Path('backend/app/main.py') + main_text = main_path.read_text() + endpoint_marker = '@app.get("/api/posts/{post_id}/summary")\nasync def read_post_summary(' + if main_text.count(endpoint_marker) != 1: + raise SystemExit('summary endpoint marker moved') + helper = '''async def _persist_post_summary_for_account(\n conn: asyncpg.Connection,\n post_id: str,\n summary: Any,\n *,\n post_body: str,\n account: CurrentAccount,\n) -> dict[str, Any]:\n """Persist a summary without granting read-only callers shared-catalog writes.\n\n The summary projection itself is post-owned and may be materialized for a\n ``post_read`` caller. Shared identity enrichment is a separate write\n capability: only ``post_admin`` may admit hierarchy/search clients or\n create catalog identities. Existing identities remain readable in either\n mode.\n """\n allow_catalog_enrichment = account.has_permission(_POST_ADMIN)\n hierarchy_inference_client = (\n _corporate_hierarchy_inference_client()\n if allow_catalog_enrichment\n else NullCorporateHierarchyInferenceClient()\n )\n verification_client = (\n _relation_verification_client()\n if allow_catalog_enrichment\n else NullRelationVerificationClient()\n )\n return await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=post_body,\n hierarchy_inference_client=hierarchy_inference_client,\n verification_client=verification_client,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )\n\n\n''' + main_text = main_text.replace(endpoint_marker, helper + endpoint_marker, 1) + old_call = ''' payload = await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n hierarchy_inference_client=_corporate_hierarchy_inference_client(),\n verification_client=_relation_verification_client(),\n )''' + new_call = ''' payload = await _persist_post_summary_for_account(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n account=account,\n )''' + if main_text.count(old_call) != 1: + raise SystemExit('summary persistence call moved') + main_path.write_text(main_text.replace(old_call, new_call, 1)) + + replace_once( + 'backend/app/post_summary_ingestion.py', + ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n) -> dict[str, Any]:''', + ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n allow_catalog_enrichment: bool = True,\n) -> dict[str, Any]:''', + ) + replace_once( + 'backend/app/post_summary_ingestion.py', + ''' hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n''', + ''' if allow_catalog_enrichment:\n hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n else:\n # Authorization is enforced at the application boundary. Even if a\n # caller accidentally supplies live clients, a read-only capability\n # cannot cross into shared corporate-catalog creation.\n hierarchy_inference_client = NullCorporateHierarchyInferenceClient()\n verification_client = NullRelationVerificationClient()\n''', + ) + replace_once( + 'backend/app/post_summary_ingestion.py', + ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n )''', + ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''', + ) + ingestion_path = Path('backend/app/post_summary_ingestion.py') + ingestion_text = ingestion_path.read_text() + replace_marker = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n) -> None:''' + new_signature = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n *,\n allow_catalog_enrichment: bool,\n) -> None:''' + if ingestion_text.count(replace_marker) != 1: + raise SystemExit('summary projection signature moved') + ingestion_text = ingestion_text.replace(replace_marker, new_signature, 1) + team_helper_marker = 'async def _replace_summary_projection(' + team_helper = '''async def _resolve_summary_team_id(\n conn: asyncpg.Connection,\n team_name: str,\n affiliated_organization_name: str | None,\n candidates: list[Any],\n *,\n allow_catalog_enrichment: bool,\n) -> str | None:\n """Resolve a team without creating shared identity state for readers."""\n if allow_catalog_enrichment:\n return await upsert_team(\n conn, team_name, affiliated_organization_name, candidates\n )\n row = await conn.fetchrow(\n "select team_id from cataloged_team "\n "where team_name = $1 "\n "and affiliated_organization_name is not distinct from $2",\n team_name,\n affiliated_organization_name,\n )\n return None if row is None else str(row["team_id"])\n\n\n''' + if ingestion_text.count(team_helper_marker) != 1: + raise SystemExit('team helper insertion marker moved') + ingestion_text = ingestion_text.replace(team_helper_marker, team_helper + team_helper_marker, 1) + old_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await upsert_team(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n )''' + new_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await _resolve_summary_team_id(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''' + if ingestion_text.count(old_team) != 1: + raise SystemExit('team upsert block moved') + ingestion_path.write_text(ingestion_text.replace(old_team, new_team, 1)) + + test_path = Path('tests/test_summary_catalog_authorization.py') + if test_path.exists(): + raise SystemExit('test path already exists; refusing competing writer') + test_path.write_text('''"""Authorization contracts for summary-driven shared-catalog enrichment."""\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom types import SimpleNamespace\n\nfrom backend.app import main\nfrom backend.app.auth import CurrentAccount\nfrom backend.app import post_summary_ingestion as summary_ingestion\n\n\ndef _account(*permissions: str) -> CurrentAccount:\n return CurrentAccount(\n user_account_id="synthetic-account",\n external_subject_id="synthetic-subject",\n display_name="Synthetic Reader",\n preferred_locale="ko",\n corporate_entity_ids=frozenset({"synthetic-corp"}),\n process_unit_ids=frozenset({"synthetic-pu"}),\n permission_codes=frozenset(permissions),\n )\n\n\ndef test_post_read_summary_persistence_never_admits_shared_catalog_clients(monkeypatch) -> None:\n captured: dict[str, object] = {}\n\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n\n def forbidden_live_client():\n raise AssertionError("post_read must not construct a catalog-mutation client")\n\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", forbidden_live_client)\n monkeypatch.setattr(main, "_relation_verification_client", forbidden_live_client)\n\n payload = asyncio.run(\n main._persist_post_summary_for_account(\n object(),\n "synthetic-post",\n object(),\n post_body="synthetic evidence",\n account=_account("post_read"),\n )\n )\n\n assert payload == {"post_id": "synthetic-post"}\n assert captured["allow_catalog_enrichment"] is False\n assert captured["hierarchy_inference_client"].available is False\n assert captured["verification_client"].available is False\n\n\ndef test_post_admin_summary_persistence_retains_explicit_catalog_capability(monkeypatch) -> None:\n captured: dict[str, object] = {}\n hierarchy = SimpleNamespace(available=True)\n verification = SimpleNamespace(available=True)\n\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", lambda: hierarchy)\n monkeypatch.setattr(main, "_relation_verification_client", lambda: verification)\n\n asyncio.run(\n main._persist_post_summary_for_account(\n object(),\n "synthetic-post",\n object(),\n post_body="synthetic evidence",\n account=_account("post_read", "post_admin"),\n )\n )\n\n assert captured["allow_catalog_enrichment"] is True\n assert captured["hierarchy_inference_client"] is hierarchy\n assert captured["verification_client"] is verification\n\n\ndef test_post_read_team_resolution_is_lookup_only(monkeypatch) -> None:\n class Connection:\n async def fetchrow(self, query, *args):\n assert query.startswith("select team_id from cataloged_team")\n assert args == ("Synthetic Team", "Synthetic Org")\n return {"team_id": "known-team"}\n\n async def forbidden_upsert(*_args, **_kwargs):\n raise AssertionError("post_read must not upsert cataloged_team")\n\n monkeypatch.setattr(summary_ingestion, "upsert_team", forbidden_upsert)\n team_id = asyncio.run(\n summary_ingestion._resolve_summary_team_id(\n Connection(),\n "Synthetic Team",\n "Synthetic Org",\n [],\n allow_catalog_enrichment=False,\n )\n )\n assert team_id == "known-team"\n\n\ndef test_post_admin_team_resolution_retains_upsert(monkeypatch) -> None:\n async def fake_upsert(_conn, team_name, affiliation, candidates):\n assert team_name == "Synthetic Team"\n assert affiliation == "Synthetic Org"\n assert candidates == []\n return "created-team"\n\n monkeypatch.setattr(summary_ingestion, "upsert_team", fake_upsert)\n team_id = asyncio.run(\n summary_ingestion._resolve_summary_team_id(\n object(),\n "Synthetic Team",\n "Synthetic Org",\n [],\n allow_catalog_enrichment=True,\n )\n )\n assert team_id == "created-team"\n''') + + adr = Path('docs/adr/0375-summary-read-catalog-authorization.md') + if adr.exists(): + raise SystemExit('ADR 0375 already exists; refuse number collision') + adr.write_text('''# ADR 0375 — summary reads cannot grant shared-catalog write authority\n\n**Decision status:** Proposed\n**Date:** 2026-09-13\n\n## Problem\n\n`GET /api/posts/{post_id}/summary` is authorized by the normal `post_read` + ABAC visibility boundary. When no current summary exists, the application derives and persists the post-owned summary projection. Before this decision, that persistence path also admitted live corporate-hierarchy inference and relation-verification clients and unconditionally upserted R&R teams. A read-only caller could therefore create or update shared `corporate_entity` / `cataloged_team` identity state as a side effect of GET. Strix run `34746057545` on #1055 exposed the corporate-entity case as CWE-862; protected `main@83eba56149eb802cd63642c507c324c9976ec78e` independently reproduces the path. Fresh code review found the same root cause in `cataloged_team` upsert.\n\n## Constraints\n\n- Preserve `post_read` access to visible summaries and the ability to materialize the post-owned summary projection.\n- Preserve read-only binding to already-known corporate, team, and person identities.\n- Keep ADR 0010's verified corporate-hierarchy creation available where explicit write authority exists.\n- Do not duplicate corporate matching, provider routing, or catalog ownership in the API layer.\n- Do not turn every summary GET into `post_admin`; reading and catalog enrichment are separate operations.\n\n## Decision\n\nThe FastAPI application boundary converts the authenticated account's `post_admin` permission into an explicit `allow_catalog_enrichment` capability passed to summary persistence. Without that capability, hierarchy inference and relation-verification are replaced by unavailable Null clients even if live clients were accidentally supplied downstream. Corporate actors may still bind to an existing unique identity, but cannot enter the verified creation fallback. Team actors use a read-only exact lookup and never call `upsert_team`. Person actors already use an existing-row lookup and require no change. With `post_admin`, the existing ADR 0010 corporate creation and team upsert behavior is preserved.\n\nThe summary projection remains writable under `post_read` because it is post-scoped derived evidence for the requested object; shared identity registries are a distinct write authority. This follows the ABAC principle that authorization evaluates subject, object, requested operation, and policy rather than treating endpoint access as authority for unrelated side effects. It also follows OWASP's requirement to authorize modification of sensitive object state rather than assuming permission to read an API object implies permission to mutate adjacent state.\n\n## Alternatives rejected\n\n- Require `post_admin` for the whole GET: rejected because it breaks the buyer read contract and conflates summary access with catalog administration.\n- Disable summary persistence for readers: rejected because it removes deterministic/idempotent materialization without addressing the capability boundary.\n- Clone a reader-local corporate/team catalog: rejected because shared identity has one canonical owner and duplication would create divergent truth.\n- Rely only on Null hierarchy/search clients: rejected because `cataloged_team` was still mutated through unconditional `upsert_team`.\n\n## Risks and verification\n\nThe main regression risk is losing catalog binding for readers. Tests therefore require low-privilege readers to retain existing-identity lookup while proving live mutation clients and team upsert are not admitted; admin tests prove the enrichment path remains. The real PostgreSQL + Keycloak + Valkey authenticated topology remains #1057's owner and must prove the same invariant before #1055 promotion. Strix must be rerun on the repaired exact source head.\n\n## Related\n\n- ADR 0010 — verified corporate hierarchy auto-creation\n- ADR 0028 — Keyverse/OIDC identity boundary\n- ADR 0041 — source context versus authorization scope\n- Issues #1078 (security owner), #1077 (separate DB connection-lease invariant), #1057 (real authenticated topology)\n\n## References — APA 7th\n\nHu, V. C., Ferraiolo, D. F., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. (2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST Special Publication 800-162, updated August 2, 2019). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-162\n\nOWASP Foundation. (2023). *OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization*. https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/\n''') + + architecture = Path('ARCHITECTURE.md') + architecture_text = architecture.read_text() + architecture_heading = '## Summary shared-catalog authorization boundary (ADR 0375)' + if architecture_heading not in architecture_text: + architecture.write_text(architecture_text.rstrip() + '''\n\n## Summary shared-catalog authorization boundary (ADR 0375)\n\nA visible summary is a `post_read` projection, but shared actor catalogs are not. The summary application service derives one explicit catalog-enrichment capability from `post_admin`. Reader materialization may bind already-known identities but cannot admit hierarchy/search creation clients or upsert `cataloged_team`; administrators retain the verified ADR 0010 enrichment path. This keeps post-owned summary persistence separate from shared master-data mutation and prevents a GET permission from becoming implicit catalog-write authority.\n''') + + changelog = Path('CHANGELOG.d/2.28.1-summary-catalog-authorization.md') + if changelog.exists(): + raise SystemExit('changelog fragment already exists') + changelog.write_text('''### Security\n\n- Summary GET materialization now separates `post_read` projection writes from shared identity-catalog enrichment. Read-only callers can reuse existing corporate/team/person identities but cannot create corporate hierarchy rows, admit mutation-capable hierarchy/search providers, or upsert `cataloged_team`; `post_admin` retains the explicit enrichment capability (ADR 0375, #1078).\n''') + PY + + - name: Verify repaired source contract and syntax + shell: bash + run: | + python -m compileall -q backend/app/main.py backend/app/post_summary_ingestion.py tests/test_summary_catalog_authorization.py + python - <<'PY' + from pathlib import Path + main = Path('backend/app/main.py').read_text() + ingestion = Path('backend/app/post_summary_ingestion.py').read_text() + tests = Path('tests/test_summary_catalog_authorization.py').read_text() + assert '_persist_post_summary_for_account(' in main + assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main + assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in main + assert 'verification_client=_relation_verification_client(),' not in main + assert 'if allow_catalog_enrichment:' in ingestion + assert '_resolve_summary_team_id(' in ingestion + assert 'allow_catalog_enrichment=False' in tests + assert 'forbidden_upsert' in tests + print('GREEN: reader summary path no longer owns shared-catalog mutation capability') + PY + + - name: Remove purpose-complete repair workflow + shell: bash + run: rm .github/workflows/repair-summary-catalog-authorization-1078.yml + + - name: Commit repaired source + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git diff --cached --check + git commit -m 'fix(security): gate summary catalog enrichment (#1078)' + git push origin HEAD:fix/summary-catalog-authorization-1078 From ba79eb1fdf09a1257d2138f6ec2b49f9442c6a3f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 00:51:39 +0900 Subject: [PATCH 02/34] ci: repair scoped #1078 verification harness --- ...ary-catalog-authorization-1078-harness.yml | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/repair-summary-catalog-authorization-1078-harness.yml diff --git a/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml b/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml new file mode 100644 index 000000000..f66086701 --- /dev/null +++ b/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml @@ -0,0 +1,54 @@ +name: Repair summary catalog authorization 1078 harness + +on: + push: + branches: + - fix/summary-catalog-authorization-1078 + +permissions: + contents: write + +jobs: + repair-harness: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/summary-catalog-authorization-1078 + fetch-depth: 0 + + - name: Scope verification to the summary endpoint + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/repair-summary-catalog-authorization-1078.yml') + text = path.read_text() + old = """ assert '_persist_post_summary_for_account(' in main + assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main + assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in main + assert 'verification_client=_relation_verification_client(),' not in main +""" + new = """ assert '_persist_post_summary_for_account(' in main + assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main + summary_endpoint = main.split('@app.get(\"/api/posts/{post_id}/summary\")', 1)[1].split('\\n@app.', 1)[0] + assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in summary_endpoint + assert 'verification_client=_relation_verification_client(),' not in summary_endpoint +""" + if text.count(old) != 1: + raise SystemExit(f'expected exactly one stale verification block, found {text.count(old)}') + path.write_text(text.replace(old, new, 1)) + PY + + - name: Remove purpose-complete harness and commit + shell: bash + run: | + rm .github/workflows/repair-summary-catalog-authorization-1078-harness.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + git diff --cached --check + git commit -m 'ci: scope #1078 repair verification to summary endpoint' + git push origin HEAD:fix/summary-catalog-authorization-1078 From 611f3886972a27ba4c6f2f064d33b37cfdf62479 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 00:52:15 +0900 Subject: [PATCH 03/34] ci: fix #1078 harness workflow syntax --- ...ary-catalog-authorization-1078-harness.yml | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml b/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml index f66086701..eaa57b1b4 100644 --- a/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml +++ b/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml @@ -23,22 +23,24 @@ jobs: run: | python - <<'PY' from pathlib import Path - path = Path('.github/workflows/repair-summary-catalog-authorization-1078.yml') text = path.read_text() - old = """ assert '_persist_post_summary_for_account(' in main - assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main - assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in main - assert 'verification_client=_relation_verification_client(),' not in main -""" - new = """ assert '_persist_post_summary_for_account(' in main - assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main - summary_endpoint = main.split('@app.get(\"/api/posts/{post_id}/summary\")', 1)[1].split('\\n@app.', 1)[0] - assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in summary_endpoint - assert 'verification_client=_relation_verification_client(),' not in summary_endpoint -""" - if text.count(old) != 1: - raise SystemExit(f'expected exactly one stale verification block, found {text.count(old)}') + old = ( + " assert '_persist_post_summary_for_account(' in main\n" + " assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main\n" + " assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in main\n" + " assert 'verification_client=_relation_verification_client(),' not in main\n" + ) + new = ( + " assert '_persist_post_summary_for_account(' in main\n" + " assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main\n" + " summary_endpoint = main.split('@app.get(\\\"/api/posts/{post_id}/summary\\\")', 1)[1].split('\\\\n@app.', 1)[0]\n" + " assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in summary_endpoint\n" + " assert 'verification_client=_relation_verification_client(),' not in summary_endpoint\n" + ) + count = text.count(old) + if count != 1: + raise SystemExit(f'expected exactly one stale verification block, found {count}') path.write_text(text.replace(old, new, 1)) PY From 4194d3b14d1d2c5a005ca160d33c3dbb1bb9d037 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:49:34 +0900 Subject: [PATCH 04/34] ci: remove completed #1078 harness writer --- ...ary-catalog-authorization-1078-harness.yml | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 .github/workflows/repair-summary-catalog-authorization-1078-harness.yml diff --git a/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml b/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml deleted file mode 100644 index eaa57b1b4..000000000 --- a/.github/workflows/repair-summary-catalog-authorization-1078-harness.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Repair summary catalog authorization 1078 harness - -on: - push: - branches: - - fix/summary-catalog-authorization-1078 - -permissions: - contents: write - -jobs: - repair-harness: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - with: - ref: fix/summary-catalog-authorization-1078 - fetch-depth: 0 - - - name: Scope verification to the summary endpoint - shell: bash - run: | - python - <<'PY' - from pathlib import Path - path = Path('.github/workflows/repair-summary-catalog-authorization-1078.yml') - text = path.read_text() - old = ( - " assert '_persist_post_summary_for_account(' in main\n" - " assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main\n" - " assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in main\n" - " assert 'verification_client=_relation_verification_client(),' not in main\n" - ) - new = ( - " assert '_persist_post_summary_for_account(' in main\n" - " assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main\n" - " summary_endpoint = main.split('@app.get(\\\"/api/posts/{post_id}/summary\\\")', 1)[1].split('\\\\n@app.', 1)[0]\n" - " assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in summary_endpoint\n" - " assert 'verification_client=_relation_verification_client(),' not in summary_endpoint\n" - ) - count = text.count(old) - if count != 1: - raise SystemExit(f'expected exactly one stale verification block, found {count}') - path.write_text(text.replace(old, new, 1)) - PY - - - name: Remove purpose-complete harness and commit - shell: bash - run: | - rm .github/workflows/repair-summary-catalog-authorization-1078-harness.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git diff --cached --check - git commit -m 'ci: scope #1078 repair verification to summary endpoint' - git push origin HEAD:fix/summary-catalog-authorization-1078 From a111d193f344aa89b2bdf58acacbf31c6767749a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:49:44 +0900 Subject: [PATCH 05/34] ci: remove purpose-complete #1078 source writer --- ...air-summary-catalog-authorization-1078.yml | 162 ------------------ 1 file changed, 162 deletions(-) delete mode 100644 .github/workflows/repair-summary-catalog-authorization-1078.yml diff --git a/.github/workflows/repair-summary-catalog-authorization-1078.yml b/.github/workflows/repair-summary-catalog-authorization-1078.yml deleted file mode 100644 index 599ea698d..000000000 --- a/.github/workflows/repair-summary-catalog-authorization-1078.yml +++ /dev/null @@ -1,162 +0,0 @@ -name: Repair summary catalog authorization 1078 - -on: - push: - branches: - - fix/summary-catalog-authorization-1078 - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: fix/summary-catalog-authorization-1078 - fetch-depth: 0 - - - name: Confirm realistic RED on protected-main-derived source - shell: bash - run: | - set +e - python - <<'PY' - from pathlib import Path - - main = Path('backend/app/main.py').read_text() - ingestion = Path('backend/app/post_summary_ingestion.py').read_text() - vulnerabilities = [] - if 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' in main: - vulnerabilities.append('summary GET admits live hierarchy inference without checking post_admin') - if 'verification_client=_relation_verification_client(),' in main: - vulnerabilities.append('summary GET admits live relation verification without checking post_admin') - if 'cataloged_team_id = await upsert_team(' in ingestion: - vulnerabilities.append('summary persistence unconditionally upserts shared cataloged_team') - if vulnerabilities: - raise AssertionError('RED: ' + '; '.join(vulnerabilities)) - PY - red_status=$? - set -e - if [ "$red_status" -eq 0 ]; then - echo 'Expected #1078 RED did not reproduce; refusing to patch a moved source.' >&2 - exit 1 - fi - echo 'Expected #1078 RED reproduced.' - - - name: Apply minimal causal authorization repair - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file_path = Path(path) - text = file_path.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected exactly one replacement target, found {count}') - file_path.write_text(text.replace(old, new, 1)) - - main_path = Path('backend/app/main.py') - main_text = main_path.read_text() - endpoint_marker = '@app.get("/api/posts/{post_id}/summary")\nasync def read_post_summary(' - if main_text.count(endpoint_marker) != 1: - raise SystemExit('summary endpoint marker moved') - helper = '''async def _persist_post_summary_for_account(\n conn: asyncpg.Connection,\n post_id: str,\n summary: Any,\n *,\n post_body: str,\n account: CurrentAccount,\n) -> dict[str, Any]:\n """Persist a summary without granting read-only callers shared-catalog writes.\n\n The summary projection itself is post-owned and may be materialized for a\n ``post_read`` caller. Shared identity enrichment is a separate write\n capability: only ``post_admin`` may admit hierarchy/search clients or\n create catalog identities. Existing identities remain readable in either\n mode.\n """\n allow_catalog_enrichment = account.has_permission(_POST_ADMIN)\n hierarchy_inference_client = (\n _corporate_hierarchy_inference_client()\n if allow_catalog_enrichment\n else NullCorporateHierarchyInferenceClient()\n )\n verification_client = (\n _relation_verification_client()\n if allow_catalog_enrichment\n else NullRelationVerificationClient()\n )\n return await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=post_body,\n hierarchy_inference_client=hierarchy_inference_client,\n verification_client=verification_client,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )\n\n\n''' - main_text = main_text.replace(endpoint_marker, helper + endpoint_marker, 1) - old_call = ''' payload = await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n hierarchy_inference_client=_corporate_hierarchy_inference_client(),\n verification_client=_relation_verification_client(),\n )''' - new_call = ''' payload = await _persist_post_summary_for_account(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n account=account,\n )''' - if main_text.count(old_call) != 1: - raise SystemExit('summary persistence call moved') - main_path.write_text(main_text.replace(old_call, new_call, 1)) - - replace_once( - 'backend/app/post_summary_ingestion.py', - ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n) -> dict[str, Any]:''', - ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n allow_catalog_enrichment: bool = True,\n) -> dict[str, Any]:''', - ) - replace_once( - 'backend/app/post_summary_ingestion.py', - ''' hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n''', - ''' if allow_catalog_enrichment:\n hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n else:\n # Authorization is enforced at the application boundary. Even if a\n # caller accidentally supplies live clients, a read-only capability\n # cannot cross into shared corporate-catalog creation.\n hierarchy_inference_client = NullCorporateHierarchyInferenceClient()\n verification_client = NullRelationVerificationClient()\n''', - ) - replace_once( - 'backend/app/post_summary_ingestion.py', - ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n )''', - ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''', - ) - ingestion_path = Path('backend/app/post_summary_ingestion.py') - ingestion_text = ingestion_path.read_text() - replace_marker = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n) -> None:''' - new_signature = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n *,\n allow_catalog_enrichment: bool,\n) -> None:''' - if ingestion_text.count(replace_marker) != 1: - raise SystemExit('summary projection signature moved') - ingestion_text = ingestion_text.replace(replace_marker, new_signature, 1) - team_helper_marker = 'async def _replace_summary_projection(' - team_helper = '''async def _resolve_summary_team_id(\n conn: asyncpg.Connection,\n team_name: str,\n affiliated_organization_name: str | None,\n candidates: list[Any],\n *,\n allow_catalog_enrichment: bool,\n) -> str | None:\n """Resolve a team without creating shared identity state for readers."""\n if allow_catalog_enrichment:\n return await upsert_team(\n conn, team_name, affiliated_organization_name, candidates\n )\n row = await conn.fetchrow(\n "select team_id from cataloged_team "\n "where team_name = $1 "\n "and affiliated_organization_name is not distinct from $2",\n team_name,\n affiliated_organization_name,\n )\n return None if row is None else str(row["team_id"])\n\n\n''' - if ingestion_text.count(team_helper_marker) != 1: - raise SystemExit('team helper insertion marker moved') - ingestion_text = ingestion_text.replace(team_helper_marker, team_helper + team_helper_marker, 1) - old_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await upsert_team(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n )''' - new_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await _resolve_summary_team_id(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''' - if ingestion_text.count(old_team) != 1: - raise SystemExit('team upsert block moved') - ingestion_path.write_text(ingestion_text.replace(old_team, new_team, 1)) - - test_path = Path('tests/test_summary_catalog_authorization.py') - if test_path.exists(): - raise SystemExit('test path already exists; refusing competing writer') - test_path.write_text('''"""Authorization contracts for summary-driven shared-catalog enrichment."""\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom types import SimpleNamespace\n\nfrom backend.app import main\nfrom backend.app.auth import CurrentAccount\nfrom backend.app import post_summary_ingestion as summary_ingestion\n\n\ndef _account(*permissions: str) -> CurrentAccount:\n return CurrentAccount(\n user_account_id="synthetic-account",\n external_subject_id="synthetic-subject",\n display_name="Synthetic Reader",\n preferred_locale="ko",\n corporate_entity_ids=frozenset({"synthetic-corp"}),\n process_unit_ids=frozenset({"synthetic-pu"}),\n permission_codes=frozenset(permissions),\n )\n\n\ndef test_post_read_summary_persistence_never_admits_shared_catalog_clients(monkeypatch) -> None:\n captured: dict[str, object] = {}\n\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n\n def forbidden_live_client():\n raise AssertionError("post_read must not construct a catalog-mutation client")\n\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", forbidden_live_client)\n monkeypatch.setattr(main, "_relation_verification_client", forbidden_live_client)\n\n payload = asyncio.run(\n main._persist_post_summary_for_account(\n object(),\n "synthetic-post",\n object(),\n post_body="synthetic evidence",\n account=_account("post_read"),\n )\n )\n\n assert payload == {"post_id": "synthetic-post"}\n assert captured["allow_catalog_enrichment"] is False\n assert captured["hierarchy_inference_client"].available is False\n assert captured["verification_client"].available is False\n\n\ndef test_post_admin_summary_persistence_retains_explicit_catalog_capability(monkeypatch) -> None:\n captured: dict[str, object] = {}\n hierarchy = SimpleNamespace(available=True)\n verification = SimpleNamespace(available=True)\n\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", lambda: hierarchy)\n monkeypatch.setattr(main, "_relation_verification_client", lambda: verification)\n\n asyncio.run(\n main._persist_post_summary_for_account(\n object(),\n "synthetic-post",\n object(),\n post_body="synthetic evidence",\n account=_account("post_read", "post_admin"),\n )\n )\n\n assert captured["allow_catalog_enrichment"] is True\n assert captured["hierarchy_inference_client"] is hierarchy\n assert captured["verification_client"] is verification\n\n\ndef test_post_read_team_resolution_is_lookup_only(monkeypatch) -> None:\n class Connection:\n async def fetchrow(self, query, *args):\n assert query.startswith("select team_id from cataloged_team")\n assert args == ("Synthetic Team", "Synthetic Org")\n return {"team_id": "known-team"}\n\n async def forbidden_upsert(*_args, **_kwargs):\n raise AssertionError("post_read must not upsert cataloged_team")\n\n monkeypatch.setattr(summary_ingestion, "upsert_team", forbidden_upsert)\n team_id = asyncio.run(\n summary_ingestion._resolve_summary_team_id(\n Connection(),\n "Synthetic Team",\n "Synthetic Org",\n [],\n allow_catalog_enrichment=False,\n )\n )\n assert team_id == "known-team"\n\n\ndef test_post_admin_team_resolution_retains_upsert(monkeypatch) -> None:\n async def fake_upsert(_conn, team_name, affiliation, candidates):\n assert team_name == "Synthetic Team"\n assert affiliation == "Synthetic Org"\n assert candidates == []\n return "created-team"\n\n monkeypatch.setattr(summary_ingestion, "upsert_team", fake_upsert)\n team_id = asyncio.run(\n summary_ingestion._resolve_summary_team_id(\n object(),\n "Synthetic Team",\n "Synthetic Org",\n [],\n allow_catalog_enrichment=True,\n )\n )\n assert team_id == "created-team"\n''') - - adr = Path('docs/adr/0375-summary-read-catalog-authorization.md') - if adr.exists(): - raise SystemExit('ADR 0375 already exists; refuse number collision') - adr.write_text('''# ADR 0375 — summary reads cannot grant shared-catalog write authority\n\n**Decision status:** Proposed\n**Date:** 2026-09-13\n\n## Problem\n\n`GET /api/posts/{post_id}/summary` is authorized by the normal `post_read` + ABAC visibility boundary. When no current summary exists, the application derives and persists the post-owned summary projection. Before this decision, that persistence path also admitted live corporate-hierarchy inference and relation-verification clients and unconditionally upserted R&R teams. A read-only caller could therefore create or update shared `corporate_entity` / `cataloged_team` identity state as a side effect of GET. Strix run `34746057545` on #1055 exposed the corporate-entity case as CWE-862; protected `main@83eba56149eb802cd63642c507c324c9976ec78e` independently reproduces the path. Fresh code review found the same root cause in `cataloged_team` upsert.\n\n## Constraints\n\n- Preserve `post_read` access to visible summaries and the ability to materialize the post-owned summary projection.\n- Preserve read-only binding to already-known corporate, team, and person identities.\n- Keep ADR 0010's verified corporate-hierarchy creation available where explicit write authority exists.\n- Do not duplicate corporate matching, provider routing, or catalog ownership in the API layer.\n- Do not turn every summary GET into `post_admin`; reading and catalog enrichment are separate operations.\n\n## Decision\n\nThe FastAPI application boundary converts the authenticated account's `post_admin` permission into an explicit `allow_catalog_enrichment` capability passed to summary persistence. Without that capability, hierarchy inference and relation-verification are replaced by unavailable Null clients even if live clients were accidentally supplied downstream. Corporate actors may still bind to an existing unique identity, but cannot enter the verified creation fallback. Team actors use a read-only exact lookup and never call `upsert_team`. Person actors already use an existing-row lookup and require no change. With `post_admin`, the existing ADR 0010 corporate creation and team upsert behavior is preserved.\n\nThe summary projection remains writable under `post_read` because it is post-scoped derived evidence for the requested object; shared identity registries are a distinct write authority. This follows the ABAC principle that authorization evaluates subject, object, requested operation, and policy rather than treating endpoint access as authority for unrelated side effects. It also follows OWASP's requirement to authorize modification of sensitive object state rather than assuming permission to read an API object implies permission to mutate adjacent state.\n\n## Alternatives rejected\n\n- Require `post_admin` for the whole GET: rejected because it breaks the buyer read contract and conflates summary access with catalog administration.\n- Disable summary persistence for readers: rejected because it removes deterministic/idempotent materialization without addressing the capability boundary.\n- Clone a reader-local corporate/team catalog: rejected because shared identity has one canonical owner and duplication would create divergent truth.\n- Rely only on Null hierarchy/search clients: rejected because `cataloged_team` was still mutated through unconditional `upsert_team`.\n\n## Risks and verification\n\nThe main regression risk is losing catalog binding for readers. Tests therefore require low-privilege readers to retain existing-identity lookup while proving live mutation clients and team upsert are not admitted; admin tests prove the enrichment path remains. The real PostgreSQL + Keycloak + Valkey authenticated topology remains #1057's owner and must prove the same invariant before #1055 promotion. Strix must be rerun on the repaired exact source head.\n\n## Related\n\n- ADR 0010 — verified corporate hierarchy auto-creation\n- ADR 0028 — Keyverse/OIDC identity boundary\n- ADR 0041 — source context versus authorization scope\n- Issues #1078 (security owner), #1077 (separate DB connection-lease invariant), #1057 (real authenticated topology)\n\n## References — APA 7th\n\nHu, V. C., Ferraiolo, D. F., Kuhn, D. R., Schnitzer, A., Sandlin, K., Miller, R., & Scarfone, K. (2019). *Guide to attribute based access control (ABAC) definition and considerations* (NIST Special Publication 800-162, updated August 2, 2019). National Institute of Standards and Technology. https://doi.org/10.6028/NIST.SP.800-162\n\nOWASP Foundation. (2023). *OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization*. https://api-security.owasp.org/editions/2023/en/0xa1-broken-object-level-authorization/\n''') - - architecture = Path('ARCHITECTURE.md') - architecture_text = architecture.read_text() - architecture_heading = '## Summary shared-catalog authorization boundary (ADR 0375)' - if architecture_heading not in architecture_text: - architecture.write_text(architecture_text.rstrip() + '''\n\n## Summary shared-catalog authorization boundary (ADR 0375)\n\nA visible summary is a `post_read` projection, but shared actor catalogs are not. The summary application service derives one explicit catalog-enrichment capability from `post_admin`. Reader materialization may bind already-known identities but cannot admit hierarchy/search creation clients or upsert `cataloged_team`; administrators retain the verified ADR 0010 enrichment path. This keeps post-owned summary persistence separate from shared master-data mutation and prevents a GET permission from becoming implicit catalog-write authority.\n''') - - changelog = Path('CHANGELOG.d/2.28.1-summary-catalog-authorization.md') - if changelog.exists(): - raise SystemExit('changelog fragment already exists') - changelog.write_text('''### Security\n\n- Summary GET materialization now separates `post_read` projection writes from shared identity-catalog enrichment. Read-only callers can reuse existing corporate/team/person identities but cannot create corporate hierarchy rows, admit mutation-capable hierarchy/search providers, or upsert `cataloged_team`; `post_admin` retains the explicit enrichment capability (ADR 0375, #1078).\n''') - PY - - - name: Verify repaired source contract and syntax - shell: bash - run: | - python -m compileall -q backend/app/main.py backend/app/post_summary_ingestion.py tests/test_summary_catalog_authorization.py - python - <<'PY' - from pathlib import Path - main = Path('backend/app/main.py').read_text() - ingestion = Path('backend/app/post_summary_ingestion.py').read_text() - tests = Path('tests/test_summary_catalog_authorization.py').read_text() - assert '_persist_post_summary_for_account(' in main - assert 'allow_catalog_enrichment=allow_catalog_enrichment' in main - assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in main - assert 'verification_client=_relation_verification_client(),' not in main - assert 'if allow_catalog_enrichment:' in ingestion - assert '_resolve_summary_team_id(' in ingestion - assert 'allow_catalog_enrichment=False' in tests - assert 'forbidden_upsert' in tests - print('GREEN: reader summary path no longer owns shared-catalog mutation capability') - PY - - - name: Remove purpose-complete repair workflow - shell: bash - run: rm .github/workflows/repair-summary-catalog-authorization-1078.yml - - - name: Commit repaired source - shell: bash - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git diff --cached --check - git commit -m 'fix(security): gate summary catalog enrichment (#1078)' - git push origin HEAD:fix/summary-catalog-authorization-1078 From 1aacc1c78b0c5fdcb9b7e391a87a4baf898ebac3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:51:24 +0900 Subject: [PATCH 06/34] ci: execute bounded #1078 authorization repair --- ...ute-summary-catalog-authorization-1078.yml | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .github/workflows/execute-summary-catalog-authorization-1078.yml diff --git a/.github/workflows/execute-summary-catalog-authorization-1078.yml b/.github/workflows/execute-summary-catalog-authorization-1078.yml new file mode 100644 index 000000000..56f9cd245 --- /dev/null +++ b/.github/workflows/execute-summary-catalog-authorization-1078.yml @@ -0,0 +1,154 @@ +name: Execute summary catalog authorization 1078 + +on: + push: + branches: + - fix/summary-catalog-authorization-1078 + +permissions: + contents: write + +jobs: + repair: + if: ${{ !contains(github.event.head_commit.message, 'fix(security): gate summary catalog enrichment') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/summary-catalog-authorization-1078 + fetch-depth: 0 + + - name: Reproduce protected-main authorization RED + shell: bash + run: | + python - <<'PY' + from pathlib import Path + main = Path('backend/app/main.py').read_text() + ingestion = Path('backend/app/post_summary_ingestion.py').read_text() + endpoint = main.split('@app.get("/api/posts/{post_id}/summary")', 1)[1].split('\n@app.', 1)[0] + assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' in endpoint + assert 'verification_client=_relation_verification_client(),' in endpoint + assert 'cataloged_team_id = await upsert_team(' in ingestion + print('RED: post_read summary materialization can mutate shared catalogs') + PY + + - name: Apply minimal fail-closed capability repair + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + target = Path(path) + text = target.read_text() + count = text.count(old) + if count != 1: + raise SystemExit(f'{path}: expected one target, found {count}') + target.write_text(text.replace(old, new, 1)) + + main_path = Path('backend/app/main.py') + main = main_path.read_text() + marker = '@app.get("/api/posts/{post_id}/summary")\nasync def read_post_summary(' + if main.count(marker) != 1: + raise SystemExit('summary endpoint marker moved') + helper = '''async def _persist_post_summary_for_account(\n conn: asyncpg.Connection,\n post_id: str,\n summary: Any,\n *,\n post_body: str,\n account: CurrentAccount,\n) -> dict[str, Any]:\n """Persist post-owned summary evidence without granting catalog-write authority."""\n allow_catalog_enrichment = account.has_permission(_POST_ADMIN)\n hierarchy_inference_client = (\n _corporate_hierarchy_inference_client()\n if allow_catalog_enrichment\n else NullCorporateHierarchyInferenceClient()\n )\n verification_client = (\n _relation_verification_client()\n if allow_catalog_enrichment\n else NullRelationVerificationClient()\n )\n return await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=post_body,\n hierarchy_inference_client=hierarchy_inference_client,\n verification_client=verification_client,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )\n\n\n''' + main = main.replace(marker, helper + marker, 1) + old_call = ''' payload = await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n hierarchy_inference_client=_corporate_hierarchy_inference_client(),\n verification_client=_relation_verification_client(),\n )''' + new_call = ''' payload = await _persist_post_summary_for_account(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n account=account,\n )''' + if main.count(old_call) != 1: + raise SystemExit('summary persistence call moved') + main_path.write_text(main.replace(old_call, new_call, 1)) + + replace_once( + 'backend/app/post_summary_ingestion.py', + ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n) -> dict[str, Any]:''', + ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n allow_catalog_enrichment: bool = False,\n) -> dict[str, Any]:''', + ) + replace_once( + 'backend/app/post_summary_ingestion.py', + ''' hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n''', + ''' if allow_catalog_enrichment:\n hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n else:\n hierarchy_inference_client = NullCorporateHierarchyInferenceClient()\n verification_client = NullRelationVerificationClient()\n''', + ) + replace_once( + 'backend/app/post_summary_ingestion.py', + ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n )''', + ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''', + ) + + ingestion_path = Path('backend/app/post_summary_ingestion.py') + ingestion = ingestion_path.read_text() + old_signature = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n) -> None:''' + new_signature = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n *,\n allow_catalog_enrichment: bool,\n) -> None:''' + if ingestion.count(old_signature) != 1: + raise SystemExit('summary projection signature moved') + ingestion = ingestion.replace(old_signature, new_signature, 1) + helper_marker = 'async def _replace_summary_projection(' + team_helper = '''async def _resolve_summary_team_id(\n conn: asyncpg.Connection,\n team_name: str,\n affiliated_organization_name: str | None,\n candidates: list[Any],\n *,\n allow_catalog_enrichment: bool,\n) -> str | None:\n """Reuse an existing team for readers; only explicit enrichment may upsert."""\n if allow_catalog_enrichment:\n return await upsert_team(conn, team_name, affiliated_organization_name, candidates)\n row = await conn.fetchrow(\n "select team_id from cataloged_team "\n "where team_name = $1 "\n "and affiliated_organization_name is not distinct from $2",\n team_name,\n affiliated_organization_name,\n )\n return None if row is None else str(row["team_id"])\n\n\n''' + if ingestion.count(helper_marker) != 1: + raise SystemExit('team helper insertion marker moved') + ingestion = ingestion.replace(helper_marker, team_helper + helper_marker, 1) + old_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await upsert_team(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n )''' + new_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await _resolve_summary_team_id(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''' + if ingestion.count(old_team) != 1: + raise SystemExit('team upsert block moved') + ingestion_path.write_text(ingestion.replace(old_team, new_team, 1)) + + backfill = Path('scripts/backfill_post_summaries.py') + backfill_text = backfill.read_text() + old_backfill = ''' hierarchy_inference_client=NullCorporateHierarchyInferenceClient(),\n verification_client=NullRelationVerificationClient(),\n )''' + new_backfill = ''' hierarchy_inference_client=NullCorporateHierarchyInferenceClient(),\n verification_client=NullRelationVerificationClient(),\n allow_catalog_enrichment=False,\n )''' + if backfill_text.count(old_backfill) != 1: + raise SystemExit('backfill summary call moved') + backfill.write_text(backfill_text.replace(old_backfill, new_backfill, 1)) + + test_path = Path('tests/test_summary_catalog_authorization.py') + if test_path.exists(): + raise SystemExit('authorization test already exists') + test_path.write_text('''"""Authorization contracts for summary-driven shared-catalog enrichment."""\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom types import SimpleNamespace\n\nfrom backend.app import main\nfrom backend.app.auth import CurrentAccount\nfrom backend.app import post_summary_ingestion as summary_ingestion\n\n\ndef _account(*permissions: str) -> CurrentAccount:\n return CurrentAccount(\n user_account_id="synthetic-account",\n external_subject_id="synthetic-subject",\n display_name="Synthetic Reader",\n preferred_locale="ko",\n corporate_entity_ids=frozenset({"synthetic-corp"}),\n process_unit_ids=frozenset({"synthetic-pu"}),\n permission_codes=frozenset(permissions),\n )\n\n\ndef test_reader_never_constructs_shared_catalog_clients(monkeypatch) -> None:\n captured: dict[str, object] = {}\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n def forbidden():\n raise AssertionError("post_read must not construct mutation-capable clients")\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", forbidden)\n monkeypatch.setattr(main, "_relation_verification_client", forbidden)\n asyncio.run(main._persist_post_summary_for_account(\n object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read")\n ))\n assert captured["allow_catalog_enrichment"] is False\n assert captured["hierarchy_inference_client"].available is False\n assert captured["verification_client"].available is False\n\n\ndef test_admin_retains_explicit_enrichment_capability(monkeypatch) -> None:\n captured: dict[str, object] = {}\n hierarchy = SimpleNamespace(available=True)\n verification = SimpleNamespace(available=True)\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", lambda: hierarchy)\n monkeypatch.setattr(main, "_relation_verification_client", lambda: verification)\n asyncio.run(main._persist_post_summary_for_account(\n object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read", "post_admin")\n ))\n assert captured["allow_catalog_enrichment"] is True\n assert captured["hierarchy_inference_client"] is hierarchy\n assert captured["verification_client"] is verification\n\n\ndef test_reader_team_resolution_is_lookup_only(monkeypatch) -> None:\n class Connection:\n async def fetchrow(self, query, *args):\n assert query.startswith("select team_id from cataloged_team")\n assert args == ("Synthetic Team", "Synthetic Org")\n return {"team_id": "known-team"}\n async def forbidden_upsert(*_args, **_kwargs):\n raise AssertionError("post_read must not upsert cataloged_team")\n monkeypatch.setattr(summary_ingestion, "upsert_team", forbidden_upsert)\n team_id = asyncio.run(summary_ingestion._resolve_summary_team_id(\n Connection(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=False\n ))\n assert team_id == "known-team"\n\n\ndef test_admin_team_resolution_may_upsert(monkeypatch) -> None:\n async def fake_upsert(_conn, team_name, affiliation, candidates):\n assert (team_name, affiliation, candidates) == ("Synthetic Team", "Synthetic Org", [])\n return "created-team"\n monkeypatch.setattr(summary_ingestion, "upsert_team", fake_upsert)\n team_id = asyncio.run(summary_ingestion._resolve_summary_team_id(\n object(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=True\n ))\n assert team_id == "created-team"\n''') + + adr = Path('docs/adr/0375-summary-read-catalog-authorization.md') + if adr.exists(): + raise SystemExit('ADR 0375 collision') + adr.write_text('''# ADR 0375 — Summary reads cannot grant shared-catalog write authority\n\n**Decision status:** Proposed\n**Date:** 2026-09-14\n\n## Problem\n\nA visible summary is readable under `post_read`, but the missing-summary materialization path also admitted hierarchy/relation clients and team upserts that can mutate shared `corporate_entity` and `cataloged_team` state. Strix exact-head run `34746057545` exposed the corporate path as CWE-862; code review found the same authority leak through team upsert.\n\n## Decision\n\nThe application boundary derives an explicit catalog-enrichment capability from `post_admin`. Without it, summary persistence forcibly substitutes Null hierarchy/relation clients and resolves teams with a parameterized read-only lookup. `allow_catalog_enrichment` defaults to `False`, so omitted capability never grants catalog writes. The operator backfill declares `False` explicitly because it already uses Null clients. `post_admin` keeps the existing verified enrichment behavior. Summary projection persistence itself remains available to `post_read`.\n\n## Alternatives rejected\n\nRequiring `post_admin` for the entire GET would break the read contract. Disabling summary persistence would remove useful idempotent materialization without fixing authority. A reader-local duplicate catalog would split canonical identity truth. Null clients alone are insufficient because the team upsert path would still mutate shared state.\n\n## Evidence and follow-up\n\nProtected `main@83eba56149eb802cd63642c507c324c9976ec78e` is the RED baseline. Focused domain tests cover reader/admin client admission and team lookup/upsert. The authenticated PostgreSQL + Keycloak + Valkey API regression remains required before acceptance, and Strix must be rerun on the repaired exact source head. #1077 remains a separate connection-lease/performance invariant.\n''') + + architecture = Path('ARCHITECTURE.md') + text = architecture.read_text() + heading = '## Summary shared-catalog authorization boundary (ADR 0375)' + if heading not in text: + architecture.write_text(text.rstrip() + '''\n\n## Summary shared-catalog authorization boundary (ADR 0375)\n\nSummary evidence is post-owned and readable under `post_read`; shared identity catalogs are not. The summary application service converts `post_admin` into an explicit enrichment capability. Reader materialization may bind known identities but cannot create corporate hierarchy state, admit mutation-capable hierarchy/relation clients, or upsert `cataloged_team`.\n''') + + changelog = Path('CHANGELOG.d/2.28.1-summary-catalog-authorization.md') + if changelog.exists(): + raise SystemExit('changelog collision') + changelog.write_text('''### Security\n\n- Prevent `post_read` summary materialization from mutating shared corporate/team catalogs; only explicit `post_admin` enrichment may create or update shared identity state (ADR 0375, #1078).\n''') + PY + + - name: Verify focused source contract + shell: bash + run: | + python -m compileall -q backend/app/main.py backend/app/post_summary_ingestion.py scripts/backfill_post_summaries.py tests/test_summary_catalog_authorization.py + python - <<'PY' + from pathlib import Path + main = Path('backend/app/main.py').read_text() + ingestion = Path('backend/app/post_summary_ingestion.py').read_text() + tests = Path('tests/test_summary_catalog_authorization.py').read_text() + endpoint = main.split('@app.get("/api/posts/{post_id}/summary")', 1)[1].split('\n@app.', 1)[0] + assert '_persist_post_summary_for_account(' in endpoint + assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in endpoint + assert 'verification_client=_relation_verification_client(),' not in endpoint + assert 'allow_catalog_enrichment: bool = False' in ingestion + assert '_resolve_summary_team_id(' in ingestion + assert 'allow_catalog_enrichment=False' in tests + print('GREEN: low-privilege summary path is fail-closed for shared-catalog mutation') + PY + git diff --check + + - name: Commit causal repair + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add backend/app/main.py backend/app/post_summary_ingestion.py scripts/backfill_post_summaries.py tests/test_summary_catalog_authorization.py docs/adr/0375-summary-read-catalog-authorization.md ARCHITECTURE.md CHANGELOG.d/2.28.1-summary-catalog-authorization.md + git commit -m 'fix(security): gate summary catalog enrichment (#1078)' + git push origin HEAD:fix/summary-catalog-authorization-1078 From 7530a576eaa22ef937b1453fa9c803430a9e2f42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 01:52:27 +0900 Subject: [PATCH 07/34] ci: fix #1078 executor workflow expression --- .../workflows/execute-summary-catalog-authorization-1078.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/execute-summary-catalog-authorization-1078.yml b/.github/workflows/execute-summary-catalog-authorization-1078.yml index 56f9cd245..0c7663b00 100644 --- a/.github/workflows/execute-summary-catalog-authorization-1078.yml +++ b/.github/workflows/execute-summary-catalog-authorization-1078.yml @@ -10,7 +10,7 @@ permissions: jobs: repair: - if: ${{ !contains(github.event.head_commit.message, 'fix(security): gate summary catalog enrichment') }} + if: ${{ !contains(github.event.head_commit.message, 'fix(security)') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: From fe4e5f999702a2f9fbb085e91ec7821f7eab1415 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:15:56 +0000 Subject: [PATCH 08/34] fix(security): gate summary catalog enrichment (#1078) --- ARCHITECTURE.md | 4 + .../2.28.1-summary-catalog-authorization.md | 3 + backend/app/main.py | 36 +++++++- backend/app/post_summary_ingestion.py | 40 +++++++-- ...0375-summary-read-catalog-authorization.md | 20 +++++ scripts/backfill_post_summaries.py | 1 + tests/test_summary_catalog_authorization.py | 84 +++++++++++++++++++ 7 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.d/2.28.1-summary-catalog-authorization.md create mode 100644 docs/adr/0375-summary-read-catalog-authorization.md create mode 100644 tests/test_summary_catalog_authorization.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 05f98e69e..846cc4596 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1057,3 +1057,7 @@ so it also covers the multi-entity opposite-order case a per-name lock would still deadlock on. Every already-cataloged entity still resolves through the unchanged, lock-free similarity-matching fast path; only the rare creation branch serializes. + +## Summary shared-catalog authorization boundary (ADR 0375) + +Summary evidence is post-owned and readable under `post_read`; shared identity catalogs are not. The summary application service converts `post_admin` into an explicit enrichment capability. Reader materialization may bind known identities but cannot create corporate hierarchy state, admit mutation-capable hierarchy/relation clients, or upsert `cataloged_team`. diff --git a/CHANGELOG.d/2.28.1-summary-catalog-authorization.md b/CHANGELOG.d/2.28.1-summary-catalog-authorization.md new file mode 100644 index 000000000..c91c36454 --- /dev/null +++ b/CHANGELOG.d/2.28.1-summary-catalog-authorization.md @@ -0,0 +1,3 @@ +### Security + +- Prevent `post_read` summary materialization from mutating shared corporate/team catalogs; only explicit `post_admin` enrichment may create or update shared identity state (ADR 0375, #1078). diff --git a/backend/app/main.py b/backend/app/main.py index 122165990..7ef539279 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3190,6 +3190,37 @@ async def rebuild_period_report_endpoint( } +async def _persist_post_summary_for_account( + conn: asyncpg.Connection, + post_id: str, + summary: Any, + *, + post_body: str, + account: CurrentAccount, +) -> dict[str, Any]: + """Persist post-owned summary evidence without granting catalog-write authority.""" + allow_catalog_enrichment = account.has_permission(_POST_ADMIN) + hierarchy_inference_client = ( + _corporate_hierarchy_inference_client() + if allow_catalog_enrichment + else NullCorporateHierarchyInferenceClient() + ) + verification_client = ( + _relation_verification_client() + if allow_catalog_enrichment + else NullRelationVerificationClient() + ) + return await persist_post_summary( + conn, + post_id, + summary, + post_body=post_body, + hierarchy_inference_client=hierarchy_inference_client, + verification_client=verification_client, + allow_catalog_enrichment=allow_catalog_enrichment, + ) + + @app.get("/api/posts/{post_id}/summary") async def read_post_summary( post_id: str, @@ -3271,13 +3302,12 @@ def stale_fallback( "Post summary is unavailable: contextual-orchestrator returned no complete evidence object", ) from exc try: - payload = await persist_post_summary( + payload = await _persist_post_summary_for_account( conn, post_id, summary, post_body=normalized_body, - hierarchy_inference_client=_corporate_hierarchy_inference_client(), - verification_client=_relation_verification_client(), + account=account, ) except Exception as exc: # noqa: BLE001 - provider boundary is fail-closed. if stale is not None: diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 022d372c2..c87b423ca 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -227,6 +227,7 @@ async def persist_post_summary( post_body: str | None = None, hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None, verification_client: RelationVerificationClient | None = None, + allow_catalog_enrichment: bool = False, ) -> dict[str, Any]: """Replace the stored summary for ``post_id`` and return the public payload. @@ -246,10 +247,14 @@ async def persist_post_summary( if post_body is not None: require_summary_source_body(post_body) - hierarchy_inference_client = ( - hierarchy_inference_client or NullCorporateHierarchyInferenceClient() - ) - verification_client = verification_client or NullRelationVerificationClient() + if allow_catalog_enrichment: + hierarchy_inference_client = ( + hierarchy_inference_client or NullCorporateHierarchyInferenceClient() + ) + verification_client = verification_client or NullRelationVerificationClient() + else: + hierarchy_inference_client = NullCorporateHierarchyInferenceClient() + verification_client = NullRelationVerificationClient() context_text = post_body if post_body is not None else summary.korean_summary aliases = ( @@ -285,6 +290,7 @@ async def persist_post_summary( summary, candidates, resolved_organization_ids, + allow_catalog_enrichment=allow_catalog_enrichment, ) payload = await fetch_persisted_summary(conn, post_id) @@ -313,12 +319,35 @@ async def _resolve_existing_cataloged_person_id( return str(person_row["person_id"]) +async def _resolve_summary_team_id( + conn: asyncpg.Connection, + team_name: str, + affiliated_organization_name: str | None, + candidates: list[Any], + *, + allow_catalog_enrichment: bool, +) -> str | None: + """Reuse an existing team for readers; only explicit enrichment may upsert.""" + if allow_catalog_enrichment: + return await upsert_team(conn, team_name, affiliated_organization_name, candidates) + row = await conn.fetchrow( + "select team_id from cataloged_team " + "where team_name = $1 " + "and affiliated_organization_name is not distinct from $2", + team_name, + affiliated_organization_name, + ) + return None if row is None else str(row["team_id"]) + + async def _replace_summary_projection( conn: asyncpg.Connection, post_id: str, summary: PostSummary, candidates: list[Any], resolved_organization_ids: dict[int, str], + *, + allow_catalog_enrichment: bool, ) -> None: """Write one atomic replacement using pre-resolved shared identities.""" # Summary replacement owns only R&R projections. Keyman mentions remain @@ -407,11 +436,12 @@ async def _replace_summary_projection( cataloged_corporate_entity_id = None cataloged_person_id = None if role.actor_type_code == ACTOR_TYPE_TEAM: - cataloged_team_id = await upsert_team( + cataloged_team_id = await _resolve_summary_team_id( conn, role.actor_name, role.affiliated_organization_name, candidates, + allow_catalog_enrichment=allow_catalog_enrichment, ) elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: cataloged_corporate_entity_id = resolved_organization_ids.get( diff --git a/docs/adr/0375-summary-read-catalog-authorization.md b/docs/adr/0375-summary-read-catalog-authorization.md new file mode 100644 index 000000000..00cc8f4fd --- /dev/null +++ b/docs/adr/0375-summary-read-catalog-authorization.md @@ -0,0 +1,20 @@ +# ADR 0375 — Summary reads cannot grant shared-catalog write authority + +**Decision status:** Proposed +**Date:** 2026-09-14 + +## Problem + +A visible summary is readable under `post_read`, but the missing-summary materialization path also admitted hierarchy/relation clients and team upserts that can mutate shared `corporate_entity` and `cataloged_team` state. Strix exact-head run `34746057545` exposed the corporate path as CWE-862; code review found the same authority leak through team upsert. + +## Decision + +The application boundary derives an explicit catalog-enrichment capability from `post_admin`. Without it, summary persistence forcibly substitutes Null hierarchy/relation clients and resolves teams with a parameterized read-only lookup. `allow_catalog_enrichment` defaults to `False`, so omitted capability never grants catalog writes. The operator backfill declares `False` explicitly because it already uses Null clients. `post_admin` keeps the existing verified enrichment behavior. Summary projection persistence itself remains available to `post_read`. + +## Alternatives rejected + +Requiring `post_admin` for the entire GET would break the read contract. Disabling summary persistence would remove useful idempotent materialization without fixing authority. A reader-local duplicate catalog would split canonical identity truth. Null clients alone are insufficient because the team upsert path would still mutate shared state. + +## Evidence and follow-up + +Protected `main@83eba56149eb802cd63642c507c324c9976ec78e` is the RED baseline. Focused domain tests cover reader/admin client admission and team lookup/upsert. The authenticated PostgreSQL + Keycloak + Valkey API regression remains required before acceptance, and Strix must be rerun on the repaired exact source head. #1077 remains a separate connection-lease/performance invariant. diff --git a/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py index 2c008fea0..17e352e0b 100644 --- a/scripts/backfill_post_summaries.py +++ b/scripts/backfill_post_summaries.py @@ -268,6 +268,7 @@ async def backfill_post_summaries( post_body=normalized.text, hierarchy_inference_client=NullCorporateHierarchyInferenceClient(), verification_client=NullRelationVerificationClient(), + allow_catalog_enrichment=False, ) result["processed_posts"] = int(result["processed_posts"]) + 1 result["project_mentions"] = int(result["project_mentions"]) + len(summary.project_mentions) diff --git a/tests/test_summary_catalog_authorization.py b/tests/test_summary_catalog_authorization.py new file mode 100644 index 000000000..c74a2764d --- /dev/null +++ b/tests/test_summary_catalog_authorization.py @@ -0,0 +1,84 @@ +"""Authorization contracts for summary-driven shared-catalog enrichment.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from backend.app import main +from backend.app.auth import CurrentAccount +from backend.app import post_summary_ingestion as summary_ingestion + + +def _account(*permissions: str) -> CurrentAccount: + return CurrentAccount( + user_account_id="synthetic-account", + external_subject_id="synthetic-subject", + display_name="Synthetic Reader", + preferred_locale="ko", + corporate_entity_ids=frozenset({"synthetic-corp"}), + process_unit_ids=frozenset({"synthetic-pu"}), + permission_codes=frozenset(permissions), + ) + + +def test_reader_never_constructs_shared_catalog_clients(monkeypatch) -> None: + captured: dict[str, object] = {} + async def fake_persist(*_args, **kwargs): + captured.update(kwargs) + return {"post_id": "synthetic-post"} + def forbidden(): + raise AssertionError("post_read must not construct mutation-capable clients") + monkeypatch.setattr(main, "persist_post_summary", fake_persist) + monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", forbidden) + monkeypatch.setattr(main, "_relation_verification_client", forbidden) + asyncio.run(main._persist_post_summary_for_account( + object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read") + )) + assert captured["allow_catalog_enrichment"] is False + assert captured["hierarchy_inference_client"].available is False + assert captured["verification_client"].available is False + + +def test_admin_retains_explicit_enrichment_capability(monkeypatch) -> None: + captured: dict[str, object] = {} + hierarchy = SimpleNamespace(available=True) + verification = SimpleNamespace(available=True) + async def fake_persist(*_args, **kwargs): + captured.update(kwargs) + return {"post_id": "synthetic-post"} + monkeypatch.setattr(main, "persist_post_summary", fake_persist) + monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", lambda: hierarchy) + monkeypatch.setattr(main, "_relation_verification_client", lambda: verification) + asyncio.run(main._persist_post_summary_for_account( + object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read", "post_admin") + )) + assert captured["allow_catalog_enrichment"] is True + assert captured["hierarchy_inference_client"] is hierarchy + assert captured["verification_client"] is verification + + +def test_reader_team_resolution_is_lookup_only(monkeypatch) -> None: + class Connection: + async def fetchrow(self, query, *args): + assert query.startswith("select team_id from cataloged_team") + assert args == ("Synthetic Team", "Synthetic Org") + return {"team_id": "known-team"} + async def forbidden_upsert(*_args, **_kwargs): + raise AssertionError("post_read must not upsert cataloged_team") + monkeypatch.setattr(summary_ingestion, "upsert_team", forbidden_upsert) + team_id = asyncio.run(summary_ingestion._resolve_summary_team_id( + Connection(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=False + )) + assert team_id == "known-team" + + +def test_admin_team_resolution_may_upsert(monkeypatch) -> None: + async def fake_upsert(_conn, team_name, affiliation, candidates): + assert (team_name, affiliation, candidates) == ("Synthetic Team", "Synthetic Org", []) + return "created-team" + monkeypatch.setattr(summary_ingestion, "upsert_team", fake_upsert) + team_id = asyncio.run(summary_ingestion._resolve_summary_team_id( + object(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=True + )) + assert team_id == "created-team" From 789c3846a321fd8554bf2f8cb84cbacda2083109 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:47:04 +0900 Subject: [PATCH 09/34] ci: remove purpose-complete #1078 executor --- ...ute-summary-catalog-authorization-1078.yml | 154 ------------------ 1 file changed, 154 deletions(-) delete mode 100644 .github/workflows/execute-summary-catalog-authorization-1078.yml diff --git a/.github/workflows/execute-summary-catalog-authorization-1078.yml b/.github/workflows/execute-summary-catalog-authorization-1078.yml deleted file mode 100644 index 0c7663b00..000000000 --- a/.github/workflows/execute-summary-catalog-authorization-1078.yml +++ /dev/null @@ -1,154 +0,0 @@ -name: Execute summary catalog authorization 1078 - -on: - push: - branches: - - fix/summary-catalog-authorization-1078 - -permissions: - contents: write - -jobs: - repair: - if: ${{ !contains(github.event.head_commit.message, 'fix(security)') }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: fix/summary-catalog-authorization-1078 - fetch-depth: 0 - - - name: Reproduce protected-main authorization RED - shell: bash - run: | - python - <<'PY' - from pathlib import Path - main = Path('backend/app/main.py').read_text() - ingestion = Path('backend/app/post_summary_ingestion.py').read_text() - endpoint = main.split('@app.get("/api/posts/{post_id}/summary")', 1)[1].split('\n@app.', 1)[0] - assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' in endpoint - assert 'verification_client=_relation_verification_client(),' in endpoint - assert 'cataloged_team_id = await upsert_team(' in ingestion - print('RED: post_read summary materialization can mutate shared catalogs') - PY - - - name: Apply minimal fail-closed capability repair - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - target = Path(path) - text = target.read_text() - count = text.count(old) - if count != 1: - raise SystemExit(f'{path}: expected one target, found {count}') - target.write_text(text.replace(old, new, 1)) - - main_path = Path('backend/app/main.py') - main = main_path.read_text() - marker = '@app.get("/api/posts/{post_id}/summary")\nasync def read_post_summary(' - if main.count(marker) != 1: - raise SystemExit('summary endpoint marker moved') - helper = '''async def _persist_post_summary_for_account(\n conn: asyncpg.Connection,\n post_id: str,\n summary: Any,\n *,\n post_body: str,\n account: CurrentAccount,\n) -> dict[str, Any]:\n """Persist post-owned summary evidence without granting catalog-write authority."""\n allow_catalog_enrichment = account.has_permission(_POST_ADMIN)\n hierarchy_inference_client = (\n _corporate_hierarchy_inference_client()\n if allow_catalog_enrichment\n else NullCorporateHierarchyInferenceClient()\n )\n verification_client = (\n _relation_verification_client()\n if allow_catalog_enrichment\n else NullRelationVerificationClient()\n )\n return await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=post_body,\n hierarchy_inference_client=hierarchy_inference_client,\n verification_client=verification_client,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )\n\n\n''' - main = main.replace(marker, helper + marker, 1) - old_call = ''' payload = await persist_post_summary(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n hierarchy_inference_client=_corporate_hierarchy_inference_client(),\n verification_client=_relation_verification_client(),\n )''' - new_call = ''' payload = await _persist_post_summary_for_account(\n conn,\n post_id,\n summary,\n post_body=normalized_body,\n account=account,\n )''' - if main.count(old_call) != 1: - raise SystemExit('summary persistence call moved') - main_path.write_text(main.replace(old_call, new_call, 1)) - - replace_once( - 'backend/app/post_summary_ingestion.py', - ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n) -> dict[str, Any]:''', - ''' hierarchy_inference_client: CorporateHierarchyInferenceClient | None = None,\n verification_client: RelationVerificationClient | None = None,\n allow_catalog_enrichment: bool = False,\n) -> dict[str, Any]:''', - ) - replace_once( - 'backend/app/post_summary_ingestion.py', - ''' hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n''', - ''' if allow_catalog_enrichment:\n hierarchy_inference_client = (\n hierarchy_inference_client or NullCorporateHierarchyInferenceClient()\n )\n verification_client = verification_client or NullRelationVerificationClient()\n else:\n hierarchy_inference_client = NullCorporateHierarchyInferenceClient()\n verification_client = NullRelationVerificationClient()\n''', - ) - replace_once( - 'backend/app/post_summary_ingestion.py', - ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n )''', - ''' await _replace_summary_projection(\n conn,\n post_id,\n summary,\n candidates,\n resolved_organization_ids,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''', - ) - - ingestion_path = Path('backend/app/post_summary_ingestion.py') - ingestion = ingestion_path.read_text() - old_signature = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n) -> None:''' - new_signature = '''async def _replace_summary_projection(\n conn: asyncpg.Connection,\n post_id: str,\n summary: PostSummary,\n candidates: list[Any],\n resolved_organization_ids: dict[int, str],\n *,\n allow_catalog_enrichment: bool,\n) -> None:''' - if ingestion.count(old_signature) != 1: - raise SystemExit('summary projection signature moved') - ingestion = ingestion.replace(old_signature, new_signature, 1) - helper_marker = 'async def _replace_summary_projection(' - team_helper = '''async def _resolve_summary_team_id(\n conn: asyncpg.Connection,\n team_name: str,\n affiliated_organization_name: str | None,\n candidates: list[Any],\n *,\n allow_catalog_enrichment: bool,\n) -> str | None:\n """Reuse an existing team for readers; only explicit enrichment may upsert."""\n if allow_catalog_enrichment:\n return await upsert_team(conn, team_name, affiliated_organization_name, candidates)\n row = await conn.fetchrow(\n "select team_id from cataloged_team "\n "where team_name = $1 "\n "and affiliated_organization_name is not distinct from $2",\n team_name,\n affiliated_organization_name,\n )\n return None if row is None else str(row["team_id"])\n\n\n''' - if ingestion.count(helper_marker) != 1: - raise SystemExit('team helper insertion marker moved') - ingestion = ingestion.replace(helper_marker, team_helper + helper_marker, 1) - old_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await upsert_team(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n )''' - new_team = ''' if role.actor_type_code == ACTOR_TYPE_TEAM:\n cataloged_team_id = await _resolve_summary_team_id(\n conn,\n role.actor_name,\n role.affiliated_organization_name,\n candidates,\n allow_catalog_enrichment=allow_catalog_enrichment,\n )''' - if ingestion.count(old_team) != 1: - raise SystemExit('team upsert block moved') - ingestion_path.write_text(ingestion.replace(old_team, new_team, 1)) - - backfill = Path('scripts/backfill_post_summaries.py') - backfill_text = backfill.read_text() - old_backfill = ''' hierarchy_inference_client=NullCorporateHierarchyInferenceClient(),\n verification_client=NullRelationVerificationClient(),\n )''' - new_backfill = ''' hierarchy_inference_client=NullCorporateHierarchyInferenceClient(),\n verification_client=NullRelationVerificationClient(),\n allow_catalog_enrichment=False,\n )''' - if backfill_text.count(old_backfill) != 1: - raise SystemExit('backfill summary call moved') - backfill.write_text(backfill_text.replace(old_backfill, new_backfill, 1)) - - test_path = Path('tests/test_summary_catalog_authorization.py') - if test_path.exists(): - raise SystemExit('authorization test already exists') - test_path.write_text('''"""Authorization contracts for summary-driven shared-catalog enrichment."""\n\nfrom __future__ import annotations\n\nimport asyncio\nfrom types import SimpleNamespace\n\nfrom backend.app import main\nfrom backend.app.auth import CurrentAccount\nfrom backend.app import post_summary_ingestion as summary_ingestion\n\n\ndef _account(*permissions: str) -> CurrentAccount:\n return CurrentAccount(\n user_account_id="synthetic-account",\n external_subject_id="synthetic-subject",\n display_name="Synthetic Reader",\n preferred_locale="ko",\n corporate_entity_ids=frozenset({"synthetic-corp"}),\n process_unit_ids=frozenset({"synthetic-pu"}),\n permission_codes=frozenset(permissions),\n )\n\n\ndef test_reader_never_constructs_shared_catalog_clients(monkeypatch) -> None:\n captured: dict[str, object] = {}\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n def forbidden():\n raise AssertionError("post_read must not construct mutation-capable clients")\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", forbidden)\n monkeypatch.setattr(main, "_relation_verification_client", forbidden)\n asyncio.run(main._persist_post_summary_for_account(\n object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read")\n ))\n assert captured["allow_catalog_enrichment"] is False\n assert captured["hierarchy_inference_client"].available is False\n assert captured["verification_client"].available is False\n\n\ndef test_admin_retains_explicit_enrichment_capability(monkeypatch) -> None:\n captured: dict[str, object] = {}\n hierarchy = SimpleNamespace(available=True)\n verification = SimpleNamespace(available=True)\n async def fake_persist(*_args, **kwargs):\n captured.update(kwargs)\n return {"post_id": "synthetic-post"}\n monkeypatch.setattr(main, "persist_post_summary", fake_persist)\n monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", lambda: hierarchy)\n monkeypatch.setattr(main, "_relation_verification_client", lambda: verification)\n asyncio.run(main._persist_post_summary_for_account(\n object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read", "post_admin")\n ))\n assert captured["allow_catalog_enrichment"] is True\n assert captured["hierarchy_inference_client"] is hierarchy\n assert captured["verification_client"] is verification\n\n\ndef test_reader_team_resolution_is_lookup_only(monkeypatch) -> None:\n class Connection:\n async def fetchrow(self, query, *args):\n assert query.startswith("select team_id from cataloged_team")\n assert args == ("Synthetic Team", "Synthetic Org")\n return {"team_id": "known-team"}\n async def forbidden_upsert(*_args, **_kwargs):\n raise AssertionError("post_read must not upsert cataloged_team")\n monkeypatch.setattr(summary_ingestion, "upsert_team", forbidden_upsert)\n team_id = asyncio.run(summary_ingestion._resolve_summary_team_id(\n Connection(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=False\n ))\n assert team_id == "known-team"\n\n\ndef test_admin_team_resolution_may_upsert(monkeypatch) -> None:\n async def fake_upsert(_conn, team_name, affiliation, candidates):\n assert (team_name, affiliation, candidates) == ("Synthetic Team", "Synthetic Org", [])\n return "created-team"\n monkeypatch.setattr(summary_ingestion, "upsert_team", fake_upsert)\n team_id = asyncio.run(summary_ingestion._resolve_summary_team_id(\n object(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=True\n ))\n assert team_id == "created-team"\n''') - - adr = Path('docs/adr/0375-summary-read-catalog-authorization.md') - if adr.exists(): - raise SystemExit('ADR 0375 collision') - adr.write_text('''# ADR 0375 — Summary reads cannot grant shared-catalog write authority\n\n**Decision status:** Proposed\n**Date:** 2026-09-14\n\n## Problem\n\nA visible summary is readable under `post_read`, but the missing-summary materialization path also admitted hierarchy/relation clients and team upserts that can mutate shared `corporate_entity` and `cataloged_team` state. Strix exact-head run `34746057545` exposed the corporate path as CWE-862; code review found the same authority leak through team upsert.\n\n## Decision\n\nThe application boundary derives an explicit catalog-enrichment capability from `post_admin`. Without it, summary persistence forcibly substitutes Null hierarchy/relation clients and resolves teams with a parameterized read-only lookup. `allow_catalog_enrichment` defaults to `False`, so omitted capability never grants catalog writes. The operator backfill declares `False` explicitly because it already uses Null clients. `post_admin` keeps the existing verified enrichment behavior. Summary projection persistence itself remains available to `post_read`.\n\n## Alternatives rejected\n\nRequiring `post_admin` for the entire GET would break the read contract. Disabling summary persistence would remove useful idempotent materialization without fixing authority. A reader-local duplicate catalog would split canonical identity truth. Null clients alone are insufficient because the team upsert path would still mutate shared state.\n\n## Evidence and follow-up\n\nProtected `main@83eba56149eb802cd63642c507c324c9976ec78e` is the RED baseline. Focused domain tests cover reader/admin client admission and team lookup/upsert. The authenticated PostgreSQL + Keycloak + Valkey API regression remains required before acceptance, and Strix must be rerun on the repaired exact source head. #1077 remains a separate connection-lease/performance invariant.\n''') - - architecture = Path('ARCHITECTURE.md') - text = architecture.read_text() - heading = '## Summary shared-catalog authorization boundary (ADR 0375)' - if heading not in text: - architecture.write_text(text.rstrip() + '''\n\n## Summary shared-catalog authorization boundary (ADR 0375)\n\nSummary evidence is post-owned and readable under `post_read`; shared identity catalogs are not. The summary application service converts `post_admin` into an explicit enrichment capability. Reader materialization may bind known identities but cannot create corporate hierarchy state, admit mutation-capable hierarchy/relation clients, or upsert `cataloged_team`.\n''') - - changelog = Path('CHANGELOG.d/2.28.1-summary-catalog-authorization.md') - if changelog.exists(): - raise SystemExit('changelog collision') - changelog.write_text('''### Security\n\n- Prevent `post_read` summary materialization from mutating shared corporate/team catalogs; only explicit `post_admin` enrichment may create or update shared identity state (ADR 0375, #1078).\n''') - PY - - - name: Verify focused source contract - shell: bash - run: | - python -m compileall -q backend/app/main.py backend/app/post_summary_ingestion.py scripts/backfill_post_summaries.py tests/test_summary_catalog_authorization.py - python - <<'PY' - from pathlib import Path - main = Path('backend/app/main.py').read_text() - ingestion = Path('backend/app/post_summary_ingestion.py').read_text() - tests = Path('tests/test_summary_catalog_authorization.py').read_text() - endpoint = main.split('@app.get("/api/posts/{post_id}/summary")', 1)[1].split('\n@app.', 1)[0] - assert '_persist_post_summary_for_account(' in endpoint - assert 'hierarchy_inference_client=_corporate_hierarchy_inference_client(),' not in endpoint - assert 'verification_client=_relation_verification_client(),' not in endpoint - assert 'allow_catalog_enrichment: bool = False' in ingestion - assert '_resolve_summary_team_id(' in ingestion - assert 'allow_catalog_enrichment=False' in tests - print('GREEN: low-privilege summary path is fail-closed for shared-catalog mutation') - PY - git diff --check - - - name: Commit causal repair - shell: bash - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add backend/app/main.py backend/app/post_summary_ingestion.py scripts/backfill_post_summaries.py tests/test_summary_catalog_authorization.py docs/adr/0375-summary-read-catalog-authorization.md ARCHITECTURE.md CHANGELOG.d/2.28.1-summary-catalog-authorization.md - git commit -m 'fix(security): gate summary catalog enrichment (#1078)' - git push origin HEAD:fix/summary-catalog-authorization-1078 From ffa9c39d3166bdb795fe9defd2e656d053fdac84 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 02:51:14 +0900 Subject: [PATCH 10/34] ci: stage bounded #1078 API regression patch --- .../patch-summary-auth-integration-1078.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/patch-summary-auth-integration-1078.yml diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml new file mode 100644 index 000000000..93f430314 --- /dev/null +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -0,0 +1,60 @@ +name: Patch summary authorization integration regression 1078 + +on: + push: + branches: + - fix/summary-catalog-authorization-1078 + +permissions: + contents: write + +jobs: + patch: + if: ${{ !contains(github.event.head_commit.message, 'test(security)') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/summary-catalog-authorization-1078 + fetch-depth: 0 + + - name: Add authenticated reader/admin catalog invariance regression + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('backend/tests/test_api.py') + text = path.read_text() + + target = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n class _FakeSummaryClient:\n''' + replacement = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n _grant_post_admin(seeded_db["dsn"])\n\n class _FakeSummaryClient:\n''' + if text.count(target) != 1: + raise SystemExit(f'expected one same-team insertion point, found {text.count(target)}') + text = text.replace(target, replacement, 1) + + marker = '''\n\ndef test_organization_mention_only_posts_appear_in_entity_related(\n''' + if text.count(marker) != 1: + raise SystemExit(f'expected one integration insertion marker, found {text.count(marker)}') + + regression = r'''\n\ndef test_post_read_summary_materialization_cannot_mutate_shared_catalogs(\n client, demo_analyst_token, seeded_db, monkeypatch\n) -> None:\n \"\"\"Real OIDC/API/PostgreSQL proof of the summary catalog-write boundary.\"\"\"\n from lineageweave.corporate_hierarchy_inference import HierarchyProposal\n from lineageweave.post_summary import (\n ACTOR_TYPE_ORGANIZATION,\n ACTOR_TYPE_TEAM,\n PostSummary,\n RoleResponsibility,\n )\n from lineageweave.relation_verification import (\n STATUS_CORROBORATED,\n RelationVerificationResult,\n )\n\n reader_org = \"Reader Summary Never Create Corp\"\n reader_team = \"Reader Summary Never Create Team\"\n admin_org = \"Admin Summary Create Corp\"\n admin_team = \"Admin Summary Create Team\"\n\n class _FakeSummaryClient:\n available = True\n\n def summarize(self, post_title: str, post_body: str) -> PostSummary:\n if post_title.startswith(\"Reader summary auth\"):\n organization_name, team_name = reader_org, reader_team\n else:\n organization_name, team_name = admin_org, admin_team\n return PostSummary(\n korean_summary=\"권한 경계를 검증하는 합성 요약이다.\",\n roles_and_responsibilities=(\n RoleResponsibility(\n actor_name=organization_name,\n responsibility=\"조직 역할\",\n actor_type_code=ACTOR_TYPE_ORGANIZATION,\n ),\n RoleResponsibility(\n actor_name=team_name,\n responsibility=\"팀 역할\",\n actor_type_code=ACTOR_TYPE_TEAM,\n affiliated_organization_name=organization_name,\n ),\n ),\n )\n\n hierarchy_factory_calls = 0\n verification_factory_calls = 0\n\n class _FakeHierarchyInferenceClient:\n available = True\n\n def infer(self, organization_name: str, context_text: str) -> HierarchyProposal:\n return HierarchyProposal(level_code=\"company\", parent_name=None)\n\n class _FakeVerificationClient:\n available = True\n\n def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:\n return RelationVerificationResult(\n status_code=STATUS_CORROBORATED,\n evidence_url=f\"https://example.org/{organization_name.replace(' ', '-')}\",\n )\n\n def hierarchy_factory():\n nonlocal hierarchy_factory_calls\n hierarchy_factory_calls += 1\n return _FakeHierarchyInferenceClient()\n\n def verification_factory():\n nonlocal verification_factory_calls\n verification_factory_calls += 1\n return _FakeVerificationClient()\n\n monkeypatch.setattr(\"backend.app.main._post_summary_client\", lambda: _FakeSummaryClient())\n monkeypatch.setattr(\"backend.app.main._corporate_hierarchy_inference_client\", hierarchy_factory)\n monkeypatch.setattr(\"backend.app.main._relation_verification_client\", verification_factory)\n\n admin_conn = psycopg2.connect(seeded_db[\"dsn\"])\n admin_conn.autocommit = True\n try:\n with admin_conn.cursor() as cur:\n post_ids = []\n for title in (\"Reader summary auth\", \"Admin summary auth\"):\n cur.execute(\n \"insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) \"\n \"select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' \"\n \"from source_post where post_id = %s returning post_id\",\n (title, \"authorization evidence body\", seeded_db[\"own_private_post_id\"]),\n )\n post_ids.append(str(cur.fetchone()[0]))\n cur.execute(\n \"select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code \"\n \"from corporate_entity order by corporate_entity_id\"\n )\n corporate_before = cur.fetchall()\n cur.execute(\n \"select team_id::text, team_name, affiliated_organization_name \"\n \"from cataloged_team order by team_id\"\n )\n teams_before = cur.fetchall()\n finally:\n admin_conn.close()\n\n headers = {\"Authorization\": f\"Bearer {demo_analyst_token}\"}\n reader = client.get(f\"/api/posts/{post_ids[0]}/summary\", headers=headers)\n assert reader.status_code == 200, reader.text\n assert hierarchy_factory_calls == 0\n assert verification_factory_calls == 0\n\n admin_conn = psycopg2.connect(seeded_db[\"dsn\"])\n admin_conn.autocommit = True\n try:\n with admin_conn.cursor() as cur:\n cur.execute(\n \"select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code \"\n \"from corporate_entity order by corporate_entity_id\"\n )\n assert cur.fetchall() == corporate_before\n cur.execute(\n \"select team_id::text, team_name, affiliated_organization_name \"\n \"from cataloged_team order by team_id\"\n )\n assert cur.fetchall() == teams_before\n cur.execute(\n \"select cataloged_corporate_entity_id, cataloged_team_id from post_summary_role \"\n \"where post_id = %s order by role_ordinal\",\n (post_ids[0],),\n )\n reader_bindings = cur.fetchall()\n finally:\n admin_conn.close()\n assert reader_bindings == [(None, None), (None, None)]\n\n _grant_post_admin(seeded_db[\"dsn\"])\n admin = client.get(f\"/api/posts/{post_ids[1]}/summary\", headers=headers)\n assert admin.status_code == 200, admin.text\n assert hierarchy_factory_calls > 0\n assert verification_factory_calls > 0\n\n admin_conn = psycopg2.connect(seeded_db[\"dsn\"])\n try:\n with admin_conn.cursor() as cur:\n cur.execute(\n \"select count(*) from corporate_entity where entity_name = %s\",\n (admin_org,),\n )\n assert cur.fetchone()[0] == 1\n cur.execute(\n \"select count(*) from cataloged_team where team_name = %s and affiliated_organization_name = %s\",\n (admin_team, admin_org),\n )\n assert cur.fetchone()[0] == 1\n finally:\n admin_conn.close()\n''' + text = text.replace(marker, regression + marker, 1) + path.write_text(text) + PY + + - name: Verify test source + shell: bash + run: | + python -m compileall -q backend/tests/test_api.py + git diff --check + grep -n "test_post_read_summary_materialization_cannot_mutate_shared_catalogs" backend/tests/test_api.py + + - name: Commit regression + shell: bash + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add backend/tests/test_api.py + git commit -m 'test(security): cover summary catalog authorization (#1078)' + git push origin HEAD:fix/summary-catalog-authorization-1078 From a06894d044a6c99db5d1104f33914a98dba521c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:50:34 +0900 Subject: [PATCH 11/34] ci: repair #1078 patcher after raw-string RCA --- .../repair-summary-auth-patcher-1078.yml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/repair-summary-auth-patcher-1078.yml diff --git a/.github/workflows/repair-summary-auth-patcher-1078.yml b/.github/workflows/repair-summary-auth-patcher-1078.yml new file mode 100644 index 000000000..f4081bd98 --- /dev/null +++ b/.github/workflows/repair-summary-auth-patcher-1078.yml @@ -0,0 +1,51 @@ +name: Repair summary authorization patcher 1078 + +on: + push: + branches: + - fix/summary-catalog-authorization-1078 + +permissions: + contents: write + +jobs: + repair: + if: ${{ !contains(github.event.head_commit.message, 'ci: repair #1078 patcher') }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: fix/summary-catalog-authorization-1078 + fetch-depth: 0 + + - name: Repair failed patcher and make it purpose-complete + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + path = Path('.github/workflows/patch-summary-auth-integration-1078.yml') + text = path.read_text() + old = " regression = r'''\\n\\ndef test_post_read_summary_materialization_cannot_mutate_shared_catalogs(" + new = " regression = '''\\n\\ndef test_post_read_summary_materialization_cannot_mutate_shared_catalogs(" + if text.count(old) != 1: + raise SystemExit(f'expected one raw-regression marker, found {text.count(old)}') + text = text.replace(old, new, 1) + old_commit = " git add backend/tests/test_api.py\n git commit -m 'test(security): cover summary catalog authorization (#1078)'" + new_commit = " rm .github/workflows/patch-summary-auth-integration-1078.yml\n git add backend/tests/test_api.py .github/workflows/patch-summary-auth-integration-1078.yml\n git commit -m 'test(security): cover summary catalog authorization (#1078)'" + if text.count(old_commit) != 1: + raise SystemExit(f'expected one commit block, found {text.count(old_commit)}') + path.write_text(text.replace(old_commit, new_commit, 1)) + PY + git diff --check + + - name: Commit repaired patcher + shell: bash + run: | + rm .github/workflows/repair-summary-auth-patcher-1078.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add .github/workflows/patch-summary-auth-integration-1078.yml .github/workflows/repair-summary-auth-patcher-1078.yml + git commit -m 'ci: repair #1078 patcher after raw-string RCA' + git push origin HEAD:fix/summary-catalog-authorization-1078 From 142fbc0fa21aa11d6695d4e458c1332cf625096a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:52:14 +0900 Subject: [PATCH 12/34] ci: make #1078 patch repair purpose-complete --- .../repair-summary-auth-patcher-1078.yml | 245 ++++++++++++++++-- 1 file changed, 228 insertions(+), 17 deletions(-) diff --git a/.github/workflows/repair-summary-auth-patcher-1078.yml b/.github/workflows/repair-summary-auth-patcher-1078.yml index f4081bd98..d44a588a1 100644 --- a/.github/workflows/repair-summary-auth-patcher-1078.yml +++ b/.github/workflows/repair-summary-auth-patcher-1078.yml @@ -10,7 +10,6 @@ permissions: jobs: repair: - if: ${{ !contains(github.event.head_commit.message, 'ci: repair #1078 patcher') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -19,33 +18,245 @@ jobs: ref: fix/summary-catalog-authorization-1078 fetch-depth: 0 - - name: Repair failed patcher and make it purpose-complete + - name: Apply authenticated authorization regressions shell: bash run: | python - <<'PY' from pathlib import Path - path = Path('.github/workflows/patch-summary-auth-integration-1078.yml') - text = path.read_text() - old = " regression = r'''\\n\\ndef test_post_read_summary_materialization_cannot_mutate_shared_catalogs(" - new = " regression = '''\\n\\ndef test_post_read_summary_materialization_cannot_mutate_shared_catalogs(" - if text.count(old) != 1: - raise SystemExit(f'expected one raw-regression marker, found {text.count(old)}') - text = text.replace(old, new, 1) - old_commit = " git add backend/tests/test_api.py\n git commit -m 'test(security): cover summary catalog authorization (#1078)'" - new_commit = " rm .github/workflows/patch-summary-auth-integration-1078.yml\n git add backend/tests/test_api.py .github/workflows/patch-summary-auth-integration-1078.yml\n git commit -m 'test(security): cover summary catalog authorization (#1078)'" - if text.count(old_commit) != 1: - raise SystemExit(f'expected one commit block, found {text.count(old_commit)}') - path.write_text(text.replace(old_commit, new_commit, 1)) + api_path = Path('backend/tests/test_api.py') + api = api_path.read_text() + + target = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility + + class _FakeSummaryClient: +''' + replacement = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility + + _grant_post_admin(seeded_db["dsn"]) + + class _FakeSummaryClient: +''' + if api.count(target) != 1: + raise SystemExit(f'expected one same-team admin insertion point, found {api.count(target)}') + api = api.replace(target, replacement, 1) + + marker = ''' + +def test_organization_mention_only_posts_appear_in_entity_related( +''' + if api.count(marker) != 1: + raise SystemExit(f'expected one API regression insertion marker, found {api.count(marker)}') + + regression = ''' + +def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Real OIDC/API/PostgreSQL proof of the summary catalog-write boundary.""" + from lineageweave.corporate_hierarchy_inference import HierarchyProposal + from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_TEAM, + PostSummary, + RoleResponsibility, + ) + from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + RelationVerificationResult, + ) + + reader_org = "Reader Summary Never Create Corp" + reader_team = "Reader Summary Never Create Team" + admin_org = "Admin Summary Create Corp" + admin_team = "Admin Summary Create Team" + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + if post_title.startswith("Reader summary auth"): + organization_name, team_name = reader_org, reader_team + else: + organization_name, team_name = admin_org, admin_team + return PostSummary( + korean_summary="권한 경계를 검증하는 합성 요약이다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name=organization_name, + responsibility="조직 역할", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + RoleResponsibility( + actor_name=team_name, + responsibility="팀 역할", + actor_type_code=ACTOR_TYPE_TEAM, + affiliated_organization_name=organization_name, + ), + ), + ) + + hierarchy_factory_calls = 0 + verification_factory_calls = 0 + + class _FakeHierarchyInferenceClient: + available = True + + def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: + return HierarchyProposal(level_code="company", parent_name=None) + + class _FakeVerificationClient: + available = True + + def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + return RelationVerificationResult( + status_code=STATUS_CORROBORATED, + evidence_url=f"https://example.org/{organization_name.replace(' ', '-')}", + ) + + def hierarchy_factory(): + nonlocal hierarchy_factory_calls + hierarchy_factory_calls += 1 + return _FakeHierarchyInferenceClient() + + def verification_factory(): + nonlocal verification_factory_calls + verification_factory_calls += 1 + return _FakeVerificationClient() + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + monkeypatch.setattr("backend.app.main._corporate_hierarchy_inference_client", hierarchy_factory) + monkeypatch.setattr("backend.app.main._relation_verification_client", verification_factory) + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + post_ids = [] + for title in ("Reader summary auth", "Admin summary auth"): + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + (title, "authorization evidence body", seeded_db["own_private_post_id"]), + ) + post_ids.append(str(cur.fetchone()[0])) + cur.execute( + "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " + "from corporate_entity order by corporate_entity_id" + ) + corporate_before = cur.fetchall() + cur.execute( + "select team_id::text, team_name, affiliated_organization_name " + "from cataloged_team order by team_id" + ) + teams_before = cur.fetchall() + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + reader = client.get(f"/api/posts/{post_ids[0]}/summary", headers=headers) + assert reader.status_code == 200, reader.text + assert hierarchy_factory_calls == 0 + assert verification_factory_calls == 0 + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " + "from corporate_entity order by corporate_entity_id" + ) + assert cur.fetchall() == corporate_before + cur.execute( + "select team_id::text, team_name, affiliated_organization_name " + "from cataloged_team order by team_id" + ) + assert cur.fetchall() == teams_before + cur.execute( + "select cataloged_corporate_entity_id, cataloged_team_id from post_summary_role " + "where post_id = %s order by role_ordinal", + (post_ids[0],), + ) + reader_bindings = cur.fetchall() + finally: + admin_conn.close() + assert reader_bindings == [(None, None), (None, None)] + + _grant_post_admin(seeded_db["dsn"]) + admin = client.get(f"/api/posts/{post_ids[1]}/summary", headers=headers) + assert admin.status_code == 200, admin.text + assert hierarchy_factory_calls > 0 + assert verification_factory_calls > 0 + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute( + "select count(*) from corporate_entity where entity_name = %s", + (admin_org,), + ) + assert cur.fetchone()[0] == 1 + cur.execute( + "select count(*) from cataloged_team where team_name = %s and affiliated_organization_name = %s", + (admin_team, admin_org), + ) + assert cur.fetchone()[0] == 1 + finally: + admin_conn.close() +''' + api_path.write_text(api.replace(marker, regression + marker, 1)) + + contract_path = Path('tests/test_ingestion_transaction_contracts.py') + contracts = contract_path.read_text() + + def add_explicit_enrichment(function_name: str, source: str) -> str: + start = source.index(f'def {function_name}') + next_def = source.find('\ndef ', start + 4) + end = len(source) if next_def == -1 else next_def + section = source[start:end] + needle = ''' str(uuid.uuid4()), + summary, + )''' + if section.count(needle) != 1: + raise SystemExit( + f'expected one persist call in {function_name}, found {section.count(needle)}' + ) + section = section.replace( + needle, + ''' str(uuid.uuid4()), + summary, + allow_catalog_enrichment=True, + )''', + 1, + ) + return source[:start] + section + source[end:] + + contracts = add_explicit_enrichment( + 'test_post_summary_replacement_mentions_and_edges_share_one_transaction', contracts + ) + contracts = add_explicit_enrichment( + 'test_organization_enrichment_finishes_before_summary_transaction', contracts + ) + contract_path.write_text(contracts) PY + + - name: Verify repaired test source + shell: bash + run: | + python -m compileall -q backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py git diff --check + grep -n "test_post_read_summary_materialization_cannot_mutate_shared_catalogs" backend/tests/test_api.py + grep -n "allow_catalog_enrichment=True" tests/test_ingestion_transaction_contracts.py - - name: Commit repaired patcher + - name: Commit product regression and remove temporary machinery shell: bash run: | + rm .github/workflows/patch-summary-auth-integration-1078.yml rm .github/workflows/repair-summary-auth-patcher-1078.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add .github/workflows/patch-summary-auth-integration-1078.yml .github/workflows/repair-summary-auth-patcher-1078.yml - git commit -m 'ci: repair #1078 patcher after raw-string RCA' + git add backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py .github/workflows/patch-summary-auth-integration-1078.yml .github/workflows/repair-summary-auth-patcher-1078.yml + git commit -m 'test(security): prove summary catalog authorization (#1078)' git push origin HEAD:fix/summary-catalog-authorization-1078 From 16fbed6cd532d996c47db19c2cab542d85a56992 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:52:49 +0900 Subject: [PATCH 13/34] ci: retire inert #1078 repair helper --- .../repair-summary-auth-patcher-1078.yml | 262 ------------------ 1 file changed, 262 deletions(-) delete mode 100644 .github/workflows/repair-summary-auth-patcher-1078.yml diff --git a/.github/workflows/repair-summary-auth-patcher-1078.yml b/.github/workflows/repair-summary-auth-patcher-1078.yml deleted file mode 100644 index d44a588a1..000000000 --- a/.github/workflows/repair-summary-auth-patcher-1078.yml +++ /dev/null @@ -1,262 +0,0 @@ -name: Repair summary authorization patcher 1078 - -on: - push: - branches: - - fix/summary-catalog-authorization-1078 - -permissions: - contents: write - -jobs: - repair: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - with: - ref: fix/summary-catalog-authorization-1078 - fetch-depth: 0 - - - name: Apply authenticated authorization regressions - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - api_path = Path('backend/tests/test_api.py') - api = api_path.read_text() - - target = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - - class _FakeSummaryClient: -''' - replacement = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - - _grant_post_admin(seeded_db["dsn"]) - - class _FakeSummaryClient: -''' - if api.count(target) != 1: - raise SystemExit(f'expected one same-team admin insertion point, found {api.count(target)}') - api = api.replace(target, replacement, 1) - - marker = ''' - -def test_organization_mention_only_posts_appear_in_entity_related( -''' - if api.count(marker) != 1: - raise SystemExit(f'expected one API regression insertion marker, found {api.count(marker)}') - - regression = ''' - -def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( - client, demo_analyst_token, seeded_db, monkeypatch -) -> None: - """Real OIDC/API/PostgreSQL proof of the summary catalog-write boundary.""" - from lineageweave.corporate_hierarchy_inference import HierarchyProposal - from lineageweave.post_summary import ( - ACTOR_TYPE_ORGANIZATION, - ACTOR_TYPE_TEAM, - PostSummary, - RoleResponsibility, - ) - from lineageweave.relation_verification import ( - STATUS_CORROBORATED, - RelationVerificationResult, - ) - - reader_org = "Reader Summary Never Create Corp" - reader_team = "Reader Summary Never Create Team" - admin_org = "Admin Summary Create Corp" - admin_team = "Admin Summary Create Team" - - class _FakeSummaryClient: - available = True - - def summarize(self, post_title: str, post_body: str) -> PostSummary: - if post_title.startswith("Reader summary auth"): - organization_name, team_name = reader_org, reader_team - else: - organization_name, team_name = admin_org, admin_team - return PostSummary( - korean_summary="권한 경계를 검증하는 합성 요약이다.", - roles_and_responsibilities=( - RoleResponsibility( - actor_name=organization_name, - responsibility="조직 역할", - actor_type_code=ACTOR_TYPE_ORGANIZATION, - ), - RoleResponsibility( - actor_name=team_name, - responsibility="팀 역할", - actor_type_code=ACTOR_TYPE_TEAM, - affiliated_organization_name=organization_name, - ), - ), - ) - - hierarchy_factory_calls = 0 - verification_factory_calls = 0 - - class _FakeHierarchyInferenceClient: - available = True - - def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: - return HierarchyProposal(level_code="company", parent_name=None) - - class _FakeVerificationClient: - available = True - - def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: - return RelationVerificationResult( - status_code=STATUS_CORROBORATED, - evidence_url=f"https://example.org/{organization_name.replace(' ', '-')}", - ) - - def hierarchy_factory(): - nonlocal hierarchy_factory_calls - hierarchy_factory_calls += 1 - return _FakeHierarchyInferenceClient() - - def verification_factory(): - nonlocal verification_factory_calls - verification_factory_calls += 1 - return _FakeVerificationClient() - - monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) - monkeypatch.setattr("backend.app.main._corporate_hierarchy_inference_client", hierarchy_factory) - monkeypatch.setattr("backend.app.main._relation_verification_client", verification_factory) - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - admin_conn.autocommit = True - try: - with admin_conn.cursor() as cur: - post_ids = [] - for title in ("Reader summary auth", "Admin summary auth"): - cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " - "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " - "from source_post where post_id = %s returning post_id", - (title, "authorization evidence body", seeded_db["own_private_post_id"]), - ) - post_ids.append(str(cur.fetchone()[0])) - cur.execute( - "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " - "from corporate_entity order by corporate_entity_id" - ) - corporate_before = cur.fetchall() - cur.execute( - "select team_id::text, team_name, affiliated_organization_name " - "from cataloged_team order by team_id" - ) - teams_before = cur.fetchall() - finally: - admin_conn.close() - - headers = {"Authorization": f"Bearer {demo_analyst_token}"} - reader = client.get(f"/api/posts/{post_ids[0]}/summary", headers=headers) - assert reader.status_code == 200, reader.text - assert hierarchy_factory_calls == 0 - assert verification_factory_calls == 0 - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - admin_conn.autocommit = True - try: - with admin_conn.cursor() as cur: - cur.execute( - "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " - "from corporate_entity order by corporate_entity_id" - ) - assert cur.fetchall() == corporate_before - cur.execute( - "select team_id::text, team_name, affiliated_organization_name " - "from cataloged_team order by team_id" - ) - assert cur.fetchall() == teams_before - cur.execute( - "select cataloged_corporate_entity_id, cataloged_team_id from post_summary_role " - "where post_id = %s order by role_ordinal", - (post_ids[0],), - ) - reader_bindings = cur.fetchall() - finally: - admin_conn.close() - assert reader_bindings == [(None, None), (None, None)] - - _grant_post_admin(seeded_db["dsn"]) - admin = client.get(f"/api/posts/{post_ids[1]}/summary", headers=headers) - assert admin.status_code == 200, admin.text - assert hierarchy_factory_calls > 0 - assert verification_factory_calls > 0 - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - try: - with admin_conn.cursor() as cur: - cur.execute( - "select count(*) from corporate_entity where entity_name = %s", - (admin_org,), - ) - assert cur.fetchone()[0] == 1 - cur.execute( - "select count(*) from cataloged_team where team_name = %s and affiliated_organization_name = %s", - (admin_team, admin_org), - ) - assert cur.fetchone()[0] == 1 - finally: - admin_conn.close() -''' - api_path.write_text(api.replace(marker, regression + marker, 1)) - - contract_path = Path('tests/test_ingestion_transaction_contracts.py') - contracts = contract_path.read_text() - - def add_explicit_enrichment(function_name: str, source: str) -> str: - start = source.index(f'def {function_name}') - next_def = source.find('\ndef ', start + 4) - end = len(source) if next_def == -1 else next_def - section = source[start:end] - needle = ''' str(uuid.uuid4()), - summary, - )''' - if section.count(needle) != 1: - raise SystemExit( - f'expected one persist call in {function_name}, found {section.count(needle)}' - ) - section = section.replace( - needle, - ''' str(uuid.uuid4()), - summary, - allow_catalog_enrichment=True, - )''', - 1, - ) - return source[:start] + section + source[end:] - - contracts = add_explicit_enrichment( - 'test_post_summary_replacement_mentions_and_edges_share_one_transaction', contracts - ) - contracts = add_explicit_enrichment( - 'test_organization_enrichment_finishes_before_summary_transaction', contracts - ) - contract_path.write_text(contracts) - PY - - - name: Verify repaired test source - shell: bash - run: | - python -m compileall -q backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py - git diff --check - grep -n "test_post_read_summary_materialization_cannot_mutate_shared_catalogs" backend/tests/test_api.py - grep -n "allow_catalog_enrichment=True" tests/test_ingestion_transaction_contracts.py - - - name: Commit product regression and remove temporary machinery - shell: bash - run: | - rm .github/workflows/patch-summary-auth-integration-1078.yml - rm .github/workflows/repair-summary-auth-patcher-1078.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py .github/workflows/patch-summary-auth-integration-1078.yml .github/workflows/repair-summary-auth-patcher-1078.yml - git commit -m 'test(security): prove summary catalog authorization (#1078)' - git push origin HEAD:fix/summary-catalog-authorization-1078 From 13fabba2d7ae323d73374d584e69d4e2b9bf4d49 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:53:13 +0900 Subject: [PATCH 14/34] ci: repair #1078 patch workflow --- .../patch-summary-auth-integration-1078.yml | 242 ++++++++++++++++-- 1 file changed, 222 insertions(+), 20 deletions(-) diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml index 93f430314..9d7af25c4 100644 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -10,7 +10,7 @@ permissions: jobs: patch: - if: ${{ !contains(github.event.head_commit.message, 'test(security)') }} + if: ${{ contains(github.event.head_commit.message, 'ci: repair #1078 patch workflow') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -19,42 +19,244 @@ jobs: ref: fix/summary-catalog-authorization-1078 fetch-depth: 0 - - name: Add authenticated reader/admin catalog invariance regression + - name: Add authenticated reader/admin catalog regressions shell: bash run: | python - <<'PY' from pathlib import Path - path = Path('backend/tests/test_api.py') - text = path.read_text() + api_path = Path('backend/tests/test_api.py') + api = api_path.read_text() - target = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n class _FakeSummaryClient:\n''' - replacement = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n _grant_post_admin(seeded_db["dsn"])\n\n class _FakeSummaryClient:\n''' - if text.count(target) != 1: - raise SystemExit(f'expected one same-team insertion point, found {text.count(target)}') - text = text.replace(target, replacement, 1) + target = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - marker = '''\n\ndef test_organization_mention_only_posts_appear_in_entity_related(\n''' - if text.count(marker) != 1: - raise SystemExit(f'expected one integration insertion marker, found {text.count(marker)}') + class _FakeSummaryClient: +''' + replacement = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - regression = r'''\n\ndef test_post_read_summary_materialization_cannot_mutate_shared_catalogs(\n client, demo_analyst_token, seeded_db, monkeypatch\n) -> None:\n \"\"\"Real OIDC/API/PostgreSQL proof of the summary catalog-write boundary.\"\"\"\n from lineageweave.corporate_hierarchy_inference import HierarchyProposal\n from lineageweave.post_summary import (\n ACTOR_TYPE_ORGANIZATION,\n ACTOR_TYPE_TEAM,\n PostSummary,\n RoleResponsibility,\n )\n from lineageweave.relation_verification import (\n STATUS_CORROBORATED,\n RelationVerificationResult,\n )\n\n reader_org = \"Reader Summary Never Create Corp\"\n reader_team = \"Reader Summary Never Create Team\"\n admin_org = \"Admin Summary Create Corp\"\n admin_team = \"Admin Summary Create Team\"\n\n class _FakeSummaryClient:\n available = True\n\n def summarize(self, post_title: str, post_body: str) -> PostSummary:\n if post_title.startswith(\"Reader summary auth\"):\n organization_name, team_name = reader_org, reader_team\n else:\n organization_name, team_name = admin_org, admin_team\n return PostSummary(\n korean_summary=\"권한 경계를 검증하는 합성 요약이다.\",\n roles_and_responsibilities=(\n RoleResponsibility(\n actor_name=organization_name,\n responsibility=\"조직 역할\",\n actor_type_code=ACTOR_TYPE_ORGANIZATION,\n ),\n RoleResponsibility(\n actor_name=team_name,\n responsibility=\"팀 역할\",\n actor_type_code=ACTOR_TYPE_TEAM,\n affiliated_organization_name=organization_name,\n ),\n ),\n )\n\n hierarchy_factory_calls = 0\n verification_factory_calls = 0\n\n class _FakeHierarchyInferenceClient:\n available = True\n\n def infer(self, organization_name: str, context_text: str) -> HierarchyProposal:\n return HierarchyProposal(level_code=\"company\", parent_name=None)\n\n class _FakeVerificationClient:\n available = True\n\n def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult:\n return RelationVerificationResult(\n status_code=STATUS_CORROBORATED,\n evidence_url=f\"https://example.org/{organization_name.replace(' ', '-')}\",\n )\n\n def hierarchy_factory():\n nonlocal hierarchy_factory_calls\n hierarchy_factory_calls += 1\n return _FakeHierarchyInferenceClient()\n\n def verification_factory():\n nonlocal verification_factory_calls\n verification_factory_calls += 1\n return _FakeVerificationClient()\n\n monkeypatch.setattr(\"backend.app.main._post_summary_client\", lambda: _FakeSummaryClient())\n monkeypatch.setattr(\"backend.app.main._corporate_hierarchy_inference_client\", hierarchy_factory)\n monkeypatch.setattr(\"backend.app.main._relation_verification_client\", verification_factory)\n\n admin_conn = psycopg2.connect(seeded_db[\"dsn\"])\n admin_conn.autocommit = True\n try:\n with admin_conn.cursor() as cur:\n post_ids = []\n for title in (\"Reader summary auth\", \"Admin summary auth\"):\n cur.execute(\n \"insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) \"\n \"select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' \"\n \"from source_post where post_id = %s returning post_id\",\n (title, \"authorization evidence body\", seeded_db[\"own_private_post_id\"]),\n )\n post_ids.append(str(cur.fetchone()[0]))\n cur.execute(\n \"select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code \"\n \"from corporate_entity order by corporate_entity_id\"\n )\n corporate_before = cur.fetchall()\n cur.execute(\n \"select team_id::text, team_name, affiliated_organization_name \"\n \"from cataloged_team order by team_id\"\n )\n teams_before = cur.fetchall()\n finally:\n admin_conn.close()\n\n headers = {\"Authorization\": f\"Bearer {demo_analyst_token}\"}\n reader = client.get(f\"/api/posts/{post_ids[0]}/summary\", headers=headers)\n assert reader.status_code == 200, reader.text\n assert hierarchy_factory_calls == 0\n assert verification_factory_calls == 0\n\n admin_conn = psycopg2.connect(seeded_db[\"dsn\"])\n admin_conn.autocommit = True\n try:\n with admin_conn.cursor() as cur:\n cur.execute(\n \"select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code \"\n \"from corporate_entity order by corporate_entity_id\"\n )\n assert cur.fetchall() == corporate_before\n cur.execute(\n \"select team_id::text, team_name, affiliated_organization_name \"\n \"from cataloged_team order by team_id\"\n )\n assert cur.fetchall() == teams_before\n cur.execute(\n \"select cataloged_corporate_entity_id, cataloged_team_id from post_summary_role \"\n \"where post_id = %s order by role_ordinal\",\n (post_ids[0],),\n )\n reader_bindings = cur.fetchall()\n finally:\n admin_conn.close()\n assert reader_bindings == [(None, None), (None, None)]\n\n _grant_post_admin(seeded_db[\"dsn\"])\n admin = client.get(f\"/api/posts/{post_ids[1]}/summary\", headers=headers)\n assert admin.status_code == 200, admin.text\n assert hierarchy_factory_calls > 0\n assert verification_factory_calls > 0\n\n admin_conn = psycopg2.connect(seeded_db[\"dsn\"])\n try:\n with admin_conn.cursor() as cur:\n cur.execute(\n \"select count(*) from corporate_entity where entity_name = %s\",\n (admin_org,),\n )\n assert cur.fetchone()[0] == 1\n cur.execute(\n \"select count(*) from cataloged_team where team_name = %s and affiliated_organization_name = %s\",\n (admin_team, admin_org),\n )\n assert cur.fetchone()[0] == 1\n finally:\n admin_conn.close()\n''' - text = text.replace(marker, regression + marker, 1) - path.write_text(text) + _grant_post_admin(seeded_db["dsn"]) + + class _FakeSummaryClient: +''' + if api.count(target) != 1: + raise SystemExit(f'expected one same-team admin insertion point, found {api.count(target)}') + api = api.replace(target, replacement, 1) + + marker = ''' + +def test_organization_mention_only_posts_appear_in_entity_related( +''' + if api.count(marker) != 1: + raise SystemExit(f'expected one API regression insertion marker, found {api.count(marker)}') + + regression = ''' + +def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """Real OIDC/API/PostgreSQL proof of the summary catalog-write boundary.""" + from lineageweave.corporate_hierarchy_inference import HierarchyProposal + from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_TEAM, + PostSummary, + RoleResponsibility, + ) + from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + RelationVerificationResult, + ) + + reader_org = "Reader Summary Never Create Corp" + reader_team = "Reader Summary Never Create Team" + admin_org = "Admin Summary Create Corp" + admin_team = "Admin Summary Create Team" + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + if post_title.startswith("Reader summary auth"): + organization_name, team_name = reader_org, reader_team + else: + organization_name, team_name = admin_org, admin_team + return PostSummary( + korean_summary="권한 경계를 검증하는 합성 요약이다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name=organization_name, + responsibility="조직 역할", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + RoleResponsibility( + actor_name=team_name, + responsibility="팀 역할", + actor_type_code=ACTOR_TYPE_TEAM, + affiliated_organization_name=organization_name, + ), + ), + ) + + hierarchy_factory_calls = 0 + verification_factory_calls = 0 + + class _FakeHierarchyInferenceClient: + available = True + + def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: + return HierarchyProposal(level_code="company", parent_name=None) + + class _FakeVerificationClient: + available = True + + def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: + return RelationVerificationResult( + status_code=STATUS_CORROBORATED, + evidence_url=f"https://example.org/{organization_name.replace(' ', '-')}", + ) + + def hierarchy_factory(): + nonlocal hierarchy_factory_calls + hierarchy_factory_calls += 1 + return _FakeHierarchyInferenceClient() + + def verification_factory(): + nonlocal verification_factory_calls + verification_factory_calls += 1 + return _FakeVerificationClient() + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + monkeypatch.setattr("backend.app.main._corporate_hierarchy_inference_client", hierarchy_factory) + monkeypatch.setattr("backend.app.main._relation_verification_client", verification_factory) + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + post_ids = [] + for title in ("Reader summary auth", "Admin summary auth"): + cur.execute( + "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + (title, "authorization evidence body", seeded_db["own_private_post_id"]), + ) + post_ids.append(str(cur.fetchone()[0])) + cur.execute( + "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " + "from corporate_entity order by corporate_entity_id" + ) + corporate_before = cur.fetchall() + cur.execute( + "select team_id::text, team_name, affiliated_organization_name " + "from cataloged_team order by team_id" + ) + teams_before = cur.fetchall() + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + reader = client.get(f"/api/posts/{post_ids[0]}/summary", headers=headers) + assert reader.status_code == 200, reader.text + assert hierarchy_factory_calls == 0 + assert verification_factory_calls == 0 + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " + "from corporate_entity order by corporate_entity_id" + ) + assert cur.fetchall() == corporate_before + cur.execute( + "select team_id::text, team_name, affiliated_organization_name " + "from cataloged_team order by team_id" + ) + assert cur.fetchall() == teams_before + cur.execute( + "select cataloged_corporate_entity_id, cataloged_team_id from post_summary_role " + "where post_id = %s order by role_ordinal", + (post_ids[0],), + ) + reader_bindings = cur.fetchall() + finally: + admin_conn.close() + assert reader_bindings == [(None, None), (None, None)] + + _grant_post_admin(seeded_db["dsn"]) + admin = client.get(f"/api/posts/{post_ids[1]}/summary", headers=headers) + assert admin.status_code == 200, admin.text + assert hierarchy_factory_calls > 0 + assert verification_factory_calls > 0 + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute( + "select count(*) from corporate_entity where entity_name = %s", + (admin_org,), + ) + assert cur.fetchone()[0] == 1 + cur.execute( + "select count(*) from cataloged_team where team_name = %s and affiliated_organization_name = %s", + (admin_team, admin_org), + ) + assert cur.fetchone()[0] == 1 + finally: + admin_conn.close() +''' + api_path.write_text(api.replace(marker, regression + marker, 1)) + + contract_path = Path('tests/test_ingestion_transaction_contracts.py') + contracts = contract_path.read_text() + + def add_explicit_enrichment(function_name: str, source: str) -> str: + start = source.index(f'def {function_name}') + next_def = source.find('\ndef ', start + 4) + end = len(source) if next_def == -1 else next_def + section = source[start:end] + needle = ''' str(uuid.uuid4()), + summary, + )''' + if section.count(needle) != 1: + raise SystemExit( + f'expected one persist call in {function_name}, found {section.count(needle)}' + ) + section = section.replace( + needle, + ''' str(uuid.uuid4()), + summary, + allow_catalog_enrichment=True, + )''', + 1, + ) + return source[:start] + section + source[end:] + + contracts = add_explicit_enrichment( + 'test_post_summary_replacement_mentions_and_edges_share_one_transaction', contracts + ) + contracts = add_explicit_enrichment( + 'test_organization_enrichment_finishes_before_summary_transaction', contracts + ) + contract_path.write_text(contracts) PY - - name: Verify test source + - name: Verify repaired test source shell: bash run: | - python -m compileall -q backend/tests/test_api.py + python -m compileall -q backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py git diff --check grep -n "test_post_read_summary_materialization_cannot_mutate_shared_catalogs" backend/tests/test_api.py + grep -n "allow_catalog_enrichment=True" tests/test_ingestion_transaction_contracts.py - - name: Commit regression + - name: Commit regressions and remove temporary patcher shell: bash run: | + rm .github/workflows/patch-summary-auth-integration-1078.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add backend/tests/test_api.py - git commit -m 'test(security): cover summary catalog authorization (#1078)' + git add backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py .github/workflows/patch-summary-auth-integration-1078.yml + git commit -m 'test(security): prove summary catalog authorization (#1078)' git push origin HEAD:fix/summary-catalog-authorization-1078 From e774a889e259cd5d5367b66e5a7c89fdde7dc549 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:53:48 +0900 Subject: [PATCH 15/34] ci: fix #1078 workflow expression syntax --- .github/workflows/patch-summary-auth-integration-1078.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml index 9d7af25c4..90fd42ccd 100644 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -10,7 +10,6 @@ permissions: jobs: patch: - if: ${{ contains(github.event.head_commit.message, 'ci: repair #1078 patch workflow') }} runs-on: ubuntu-latest timeout-minutes: 10 steps: From b87c4742543998c808345dc0bb9752e1e97d3cf8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 03:56:02 +0900 Subject: [PATCH 16/34] ci: encode #1078 bounded patch safely --- .../patch-summary-auth-integration-1078.yml | 221 +----------------- 1 file changed, 3 insertions(+), 218 deletions(-) diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml index 90fd42ccd..ec357cf0b 100644 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -22,224 +22,9 @@ jobs: shell: bash run: | python - <<'PY' - from pathlib import Path - - api_path = Path('backend/tests/test_api.py') - api = api_path.read_text() - - target = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - - class _FakeSummaryClient: -''' - replacement = ''' from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - - _grant_post_admin(seeded_db["dsn"]) - - class _FakeSummaryClient: -''' - if api.count(target) != 1: - raise SystemExit(f'expected one same-team admin insertion point, found {api.count(target)}') - api = api.replace(target, replacement, 1) - - marker = ''' - -def test_organization_mention_only_posts_appear_in_entity_related( -''' - if api.count(marker) != 1: - raise SystemExit(f'expected one API regression insertion marker, found {api.count(marker)}') - - regression = ''' - -def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( - client, demo_analyst_token, seeded_db, monkeypatch -) -> None: - """Real OIDC/API/PostgreSQL proof of the summary catalog-write boundary.""" - from lineageweave.corporate_hierarchy_inference import HierarchyProposal - from lineageweave.post_summary import ( - ACTOR_TYPE_ORGANIZATION, - ACTOR_TYPE_TEAM, - PostSummary, - RoleResponsibility, - ) - from lineageweave.relation_verification import ( - STATUS_CORROBORATED, - RelationVerificationResult, - ) - - reader_org = "Reader Summary Never Create Corp" - reader_team = "Reader Summary Never Create Team" - admin_org = "Admin Summary Create Corp" - admin_team = "Admin Summary Create Team" - - class _FakeSummaryClient: - available = True - - def summarize(self, post_title: str, post_body: str) -> PostSummary: - if post_title.startswith("Reader summary auth"): - organization_name, team_name = reader_org, reader_team - else: - organization_name, team_name = admin_org, admin_team - return PostSummary( - korean_summary="권한 경계를 검증하는 합성 요약이다.", - roles_and_responsibilities=( - RoleResponsibility( - actor_name=organization_name, - responsibility="조직 역할", - actor_type_code=ACTOR_TYPE_ORGANIZATION, - ), - RoleResponsibility( - actor_name=team_name, - responsibility="팀 역할", - actor_type_code=ACTOR_TYPE_TEAM, - affiliated_organization_name=organization_name, - ), - ), - ) - - hierarchy_factory_calls = 0 - verification_factory_calls = 0 - - class _FakeHierarchyInferenceClient: - available = True - - def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: - return HierarchyProposal(level_code="company", parent_name=None) - - class _FakeVerificationClient: - available = True - - def verify(self, organization_name: str, relationship_label: str) -> RelationVerificationResult: - return RelationVerificationResult( - status_code=STATUS_CORROBORATED, - evidence_url=f"https://example.org/{organization_name.replace(' ', '-')}", - ) - - def hierarchy_factory(): - nonlocal hierarchy_factory_calls - hierarchy_factory_calls += 1 - return _FakeHierarchyInferenceClient() - - def verification_factory(): - nonlocal verification_factory_calls - verification_factory_calls += 1 - return _FakeVerificationClient() - - monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) - monkeypatch.setattr("backend.app.main._corporate_hierarchy_inference_client", hierarchy_factory) - monkeypatch.setattr("backend.app.main._relation_verification_client", verification_factory) - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - admin_conn.autocommit = True - try: - with admin_conn.cursor() as cur: - post_ids = [] - for title in ("Reader summary auth", "Admin summary auth"): - cur.execute( - "insert into source_post (author_account_id, corporate_entity_id, post_title, post_body, voc_type_code, visibility_code) " - "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " - "from source_post where post_id = %s returning post_id", - (title, "authorization evidence body", seeded_db["own_private_post_id"]), - ) - post_ids.append(str(cur.fetchone()[0])) - cur.execute( - "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " - "from corporate_entity order by corporate_entity_id" - ) - corporate_before = cur.fetchall() - cur.execute( - "select team_id::text, team_name, affiliated_organization_name " - "from cataloged_team order by team_id" - ) - teams_before = cur.fetchall() - finally: - admin_conn.close() - - headers = {"Authorization": f"Bearer {demo_analyst_token}"} - reader = client.get(f"/api/posts/{post_ids[0]}/summary", headers=headers) - assert reader.status_code == 200, reader.text - assert hierarchy_factory_calls == 0 - assert verification_factory_calls == 0 - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - admin_conn.autocommit = True - try: - with admin_conn.cursor() as cur: - cur.execute( - "select corporate_entity_id::text, parent_entity_id::text, corporate_entity_code, entity_name, entity_level_code " - "from corporate_entity order by corporate_entity_id" - ) - assert cur.fetchall() == corporate_before - cur.execute( - "select team_id::text, team_name, affiliated_organization_name " - "from cataloged_team order by team_id" - ) - assert cur.fetchall() == teams_before - cur.execute( - "select cataloged_corporate_entity_id, cataloged_team_id from post_summary_role " - "where post_id = %s order by role_ordinal", - (post_ids[0],), - ) - reader_bindings = cur.fetchall() - finally: - admin_conn.close() - assert reader_bindings == [(None, None), (None, None)] - - _grant_post_admin(seeded_db["dsn"]) - admin = client.get(f"/api/posts/{post_ids[1]}/summary", headers=headers) - assert admin.status_code == 200, admin.text - assert hierarchy_factory_calls > 0 - assert verification_factory_calls > 0 - - admin_conn = psycopg2.connect(seeded_db["dsn"]) - try: - with admin_conn.cursor() as cur: - cur.execute( - "select count(*) from corporate_entity where entity_name = %s", - (admin_org,), - ) - assert cur.fetchone()[0] == 1 - cur.execute( - "select count(*) from cataloged_team where team_name = %s and affiliated_organization_name = %s", - (admin_team, admin_org), - ) - assert cur.fetchone()[0] == 1 - finally: - admin_conn.close() -''' - api_path.write_text(api.replace(marker, regression + marker, 1)) - - contract_path = Path('tests/test_ingestion_transaction_contracts.py') - contracts = contract_path.read_text() - - def add_explicit_enrichment(function_name: str, source: str) -> str: - start = source.index(f'def {function_name}') - next_def = source.find('\ndef ', start + 4) - end = len(source) if next_def == -1 else next_def - section = source[start:end] - needle = ''' str(uuid.uuid4()), - summary, - )''' - if section.count(needle) != 1: - raise SystemExit( - f'expected one persist call in {function_name}, found {section.count(needle)}' - ) - section = section.replace( - needle, - ''' str(uuid.uuid4()), - summary, - allow_catalog_enrichment=True, - )''', - 1, - ) - return source[:start] + section + source[end:] - - contracts = add_explicit_enrichment( - 'test_post_summary_replacement_mentions_and_edges_share_one_transaction', contracts - ) - contracts = add_explicit_enrichment( - 'test_organization_enrichment_finishes_before_summary_transaction', contracts - ) - contract_path.write_text(contracts) + import base64 + payload = "CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKYXBpX3BhdGggPSBQYXRoKCJiYWNrZW5kL3Rlc3RzL3Rlc3RfYXBpLnB5IikKYXBpID0gYXBpX3BhdGgucmVhZF90ZXh0KCkKdGFyZ2V0ID0gJyAgICBmcm9tIGxpbmVhZ2V3ZWF2ZS5wb3N0X3N1bW1hcnkgaW1wb3J0IEFDVE9SX1RZUEVfVEVBTSwgUG9zdFN1bW1hcnksIFJvbGVSZXNwb25zaWJpbGl0eVxuXG4gICAgY2xhc3MgX0Zha2VTdW1tYXJ5Q2xpZW50OlxuJwpyZXBsYWNlbWVudCA9ICcgICAgZnJvbSBsaW5lYWdld2VhdmUucG9zdF9zdW1tYXJ5IGltcG9ydCBBQ1RPUl9UWVBFX1RFQU0sIFBvc3RTdW1tYXJ5LCBSb2xlUmVzcG9uc2liaWxpdHlcblxuICAgIF9ncmFudF9wb3N0X2FkbWluKHNlZWRlZF9kYlsiZHNuIl0pXG5cbiAgICBjbGFzcyBfRmFrZVN1bW1hcnlDbGllbnQ6XG4nCmlmIGFwaS5jb3VudCh0YXJnZXQpICE9IDE6CiAgICByYWlzZSBTeXN0ZW1FeGl0KGYiZXhwZWN0ZWQgb25lIHNhbWUtdGVhbSBhZG1pbiBpbnNlcnRpb24gcG9pbnQsIGZvdW5kIHthcGkuY291bnQodGFyZ2V0KX0iKQphcGkgPSBhcGkucmVwbGFjZSh0YXJnZXQsIHJlcGxhY2VtZW50LCAxKQptYXJrZXIgPSAnXG5cbmRlZiB0ZXN0X29yZ2FuaXphdGlvbl9tZW50aW9uX29ubHlfcG9zdHNfYXBwZWFyX2luX2VudGl0eV9yZWxhdGVkKFxuJwppZiBhcGkuY291bnQobWFya2VyKSAhPSAxOgogICAgcmFpc2UgU3lzdGVtRXhpdChmImV4cGVjdGVkIG9uZSBBUEkgcmVncmVzc2lvbiBpbnNlcnRpb24gbWFya2VyLCBmb3VuZCB7YXBpLmNvdW50KG1hcmtlcil9IikKcmVncmVzc2lvbiA9ICdcblxuZGVmIHRlc3RfcG9zdF9yZWFkX3N1bW1hcnlfbWF0ZXJpYWxpemF0aW9uX2Nhbm5vdF9tdXRhdGVfc2hhcmVkX2NhdGFsb2dzKFxuICAgIGNsaWVudCwgZGVtb19hbmFseXN0X3Rva2VuLCBzZWVkZWRfZGIsIG1vbmtleXBhdGNoXG4pIC0+IE5vbmU6XG4gICAgIiIiUmVhbCBPSURDL0FQSS9Qb3N0Z3JlU1FMIHByb29mIG9mIHRoZSBzdW1tYXJ5IGNhdGFsb2ctd3JpdGUgYm91bmRhcnkuIiIiXG4gICAgZnJvbSBsaW5lYWdld2VhdmUuY29ycG9yYXRlX2hpZXJhcmNoeV9pbmZlcmVuY2UgaW1wb3J0IEhpZXJhcmNoeVByb3Bvc2FsXG4gICAgZnJvbSBsaW5lYWdld2VhdmUucG9zdF9zdW1tYXJ5IGltcG9ydCAoXG4gICAgICAgIEFDVE9SX1RZUEVfT1JHQU5JWkFUSU9OLFxuICAgICAgICBBQ1RPUl9UWVBFX1RFQU0sXG4gICAgICAgIFBvc3RTdW1tYXJ5LFxuICAgICAgICBSb2xlUmVzcG9uc2liaWxpdHksXG4gICAgKVxuICAgIGZyb20gbGluZWFnZXdlYXZlLnJlbGF0aW9uX3ZlcmlmaWNhdGlvbiBpbXBvcnQgKFxuICAgICAgICBTVEFUVVNfQ09SUk9CT1JBVEVELFxuICAgICAgICBSZWxhdGlvblZlcmlmaWNhdGlvblJlc3VsdCxcbiAgICApXG5cbiAgICByZWFkZXJfb3JnID0gIlJlYWRlciBTdW1tYXJ5IE5ldmVyIENyZWF0ZSBDb3JwIlxuICAgIHJlYWRlcl90ZWFtID0gIlJlYWRlciBTdW1tYXJ5IE5ldmVyIENyZWF0ZSBUZWFtIlxuICAgIGFkbWluX29yZyA9ICJBZG1pbiBTdW1tYXJ5IENyZWF0ZSBDb3JwIlxuICAgIGFkbWluX3RlYW0gPSAiQWRtaW4gU3VtbWFyeSBDcmVhdGUgVGVhbSJcblxuICAgIGNsYXNzIF9GYWtlU3VtbWFyeUNsaWVudDpcbiAgICAgICAgYXZhaWxhYmxlID0gVHJ1ZVxuXG4gICAgICAgIGRlZiBzdW1tYXJpemUoc2VsZiwgcG9zdF90aXRsZTogc3RyLCBwb3N0X2JvZHk6IHN0cikgLT4gUG9zdFN1bW1hcnk6XG4gICAgICAgICAgICBpZiBwb3N0X3RpdGxlLnN0YXJ0c3dpdGgoIlJlYWRlciBzdW1tYXJ5IGF1dGgiKTpcbiAgICAgICAgICAgICAgICBvcmdhbml6YXRpb25fbmFtZSwgdGVhbV9uYW1lID0gcmVhZGVyX29yZywgcmVhZGVyX3RlYW1cbiAgICAgICAgICAgIGVsc2U6XG4gICAgICAgICAgICAgICAgb3JnYW5pemF0aW9uX25hbWUsIHRlYW1fbmFtZSA9IGFkbWluX29yZywgYWRtaW5fdGVhbVxuICAgICAgICAgICAgcmV0dXJuIFBvc3RTdW1tYXJ5KFxuICAgICAgICAgICAgICAgIGtvcmVhbl9zdW1tYXJ5PSLqtoztlZwg6rK967OE7J2EIOyqkOymne2VmOuKlCDtlanshLEg7JqU7JW97J2064ukLiIsXG4gICAgICAgICAgICAgICAgcm9sZXNfYW5kX3Jlc3BvbnNpYmlsaXRpZXM9KFxuICAgICAgICAgICAgICAgICAgICBSb2xlUmVzcG9uc2liaWxpdHkoXG4gICAgICAgICAgICAgICAgICAgICAgICBhY3Rvcl9uYW1lPW9yZ2FuaXphdGlvbl9uYW1lLFxuICAgICAgICAgICAgICAgICAgICAgICAgcmVzcG9uc2liaWxpdHk9IuyhsOyngSDsl63tlaAiLFxuICAgICAgICAgICAgICAgICAgICAgICAgYWN0b3JfdHlwZV9jb2RlPUFDVE9SX1RZUEVfT1JHQU5JWkFUSU9OLFxuICAgICAgICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgICAgICAgICBSb2xlUmVzcG9uc2liaWxpdHkoXG4gICAgICAgICAgICAgICAgICAgICAgICBhY3Rvcl9uYW1lPXRlYW1fbmFtZSxcbiAgICAgICAgICAgICAgICAgICAgICAgIHJlc3BvbnNpYmlsaXR5PSLtjIDsl63tlaAiLFxuICAgICAgICAgICAgICAgICAgICAgICAgYWN0b3JfdHlwZV9jb2RlPUFDVE9SX1RZUEVfVEVBTSxcbiAgICAgICAgICAgICAgICAgICAgICAgIGFmZmlsaWF0ZWRfb3JnYW5pemF0aW9uX25hbWU9b3JnYW5pemF0aW9uX25hbWUsXG4gICAgICAgICAgICAgICAgICAgICksXG4gICAgICAgICAgICAgICAgKSxcbiAgICAgICAgICAgIClcblxuICAgIGhpZXJhcmNoeV9mYWN0b3J5X2NhbGxzID0gMFxuICAgIHZlcmlmaWNhdGlvbl9mYWN0b3J5X2NhbGxzID0gMFxuXG4gICAgY2xhc3MgX0Zha2VIaWVyYXJjaHlJbmZlcmVuY2VDbGllbnQ6XG4gICAgICAgIGF2YWlsYWJsZSA9IFRydWVcblxuICAgICAgICBkZWYgaW5mZXIoc2VsZiwgb3JnYW5pemF0aW9uX25hbWU6IHN0ciwgY29udGV4dF90ZXh0OiBzdHIpIC0+IEhpZXJhcmNoeVByb3Bvc2FsOlxuICAgICAgICAgICAgcmV0dXJuIEhpZXJhcmNoeVByb3Bvc2FsKGxldmVsX2NvZGU9ImNvbXBhbnkiLCBwYXJlbnRfbmFtZT1Ob25lKVxuXG4gICAgY2xhc3MgX0Zha2VWZXJpZmljYXRpb25DbGllbnQ6XG4gICAgICAgIGF2YWlsYWJsZSA9IFRydWVcblxuICAgICAgICBkZWYgdmVyaWZ5KHNlbGYsIG9yZ2FuaXphdGlvbl9uYW1lOiBzdHIsIHJlbGF0aW9uc2hpcF9sYWJlbDogc3RyKSAtPiBSZWxhdGlvblZlcmlmaWNhdGlvblJlc3VsdDpcbiAgICAgICAgICAgIHJldHVybiBSZWxhdGlvblZlcmlmaWNhdGlvblJlc3VsdChcbiAgICAgICAgICAgICAgICBzdGF0dXNfY29kZT1TVEFUVVNfQ09SUk9CT1JBVEVELFxuICAgICAgICAgICAgICAgIGV2aWRlbmNlX3VybD1mImh0dHBzOi8vZXhhbXBsZS5vcmcve29yZ2FuaXphdGlvbl9uYW1lLnJlcGxhY2UoJyAnLCAnLScpfSIsXG4gICAgICAgICAgICApXG5cbiAgICBkZWYgaGllcmFyY2h5X2ZhY3RvcnkoKTpcbiAgICAgICAgbm9ubG9jYWwgaGllcmFyY2h5X2ZhY3RvcnlfY2FsbHNcbiAgICAgICAgaGllcmFyY2h5X2ZhY3RvcnlfY2FsbHMgKz0gMVxuICAgICAgICByZXR1cm4gX0Zha2VIaWVyYXJjaHlJbmZlcmVuY2VDbGllbnQoKVxuXG4gICAgZGVmIHZlcmlmaWNhdGlvbl9mYWN0b3J5KCk6XG4gICAgICAgIG5vbmxvY2FsIHZlcmlmaWNhdGlvbl9mYWN0b3J5X2NhbGxzXG4gICAgICAgIHZlcmlmaWNhdGlvbl9mYWN0b3J5X2NhbGxzICs9IDFcbiAgICAgICAgcmV0dXJuIF9GYWtlVmVyaWZpY2F0aW9uQ2xpZW50KClcblxuICAgIG1vbmtleXBhdGNoLnNldGF0dHIoImJhY2tlbmQuYXBwLm1haW4uX3Bvc3Rfc3VtbWFyeV9jbGllbnQiLCBsYW1iZGE6IF9GYWtlU3VtbWFyeUNsaWVudCgpKVxuICAgIG1vbmtleXBhdGNoLnNldGF0dHIoImJhY2tlbmQuYXBwLm1haW4uX2NvcnBvcmF0ZV9oaWVyYXJjaHlfaW5mZXJlbmNlX2NsaWVudCIsIGhpZXJhcmNoeV9mYWN0b3J5KVxuICAgIG1vbmtleXBhdGNoLnNldGF0dHIoImJhY2tlbmQuYXBwLm1haW4uX3JlbGF0aW9uX3ZlcmlmaWNhdGlvbl9jbGllbnQiLCB2ZXJpZmljYXRpb25fZmFjdG9yeSlcblxuICAgIGFkbWluX2Nvbm4gPSBwc3ljb3BnMi5jb25uZWN0KHNlZWRlZF9kYlsiZHNuIl0pXG4gICAgYWRtaW5fY29ubi5hdXRvY29tbWl0ID0gVHJ1ZVxuICAgIHRyeTpcbiAgICAgICAgd2l0aCBhZG1pbl9jb25uLmN1cnNvcigpIGFzIGN1cjpcbiAgICAgICAgICAgIHBvc3RfaWRzID0gW11cbiAgICAgICAgICAgIGZvciB0aXRsZSBpbiAoIlJlYWRlciBzdW1tYXJ5IGF1dGgiLCAiQWRtaW4gc3VtbWFyeSBhdXRoIilcbiAgICAgICAgICAgICAgICBjdXIuZXhlY3V0ZShcbiAgICAgICAgICAgICAgICAgICAgImluc2VydCBpbnRvIHNvdXJjZV9wb3N0IChhdXRob3JfYWNjb3VudF9pZCwgY29ycG9yYXRlX2VudGl0eV9pZCwgcG9zdF90aXRsZSwgcG9zdF9ib2R5LCB2b2NfdHlwZV9jb2RlLCB2aXNpYmlsaXR5X2NvZGUpICJcbiAgICAgICAgICAgICAgICAgICAgInNlbGVjdCBhdXRob3JfYWNjb3VudF9pZCwgY29ycG9yYXRlX2VudGl0eV9pZCwgJXMsICVzLCAndm9jJywgJ3B1YmxpYycgIlxuICAgICAgICAgICAgICAgICAgICAiZnJvbSBzb3VyY2VfcG9zdCB3aGVyZSBwb3N0X2lkID0gJXMgc mV0dXJuaW5nIHBvc3RfaWQiLAogICAgICAgICAgICAgICAgICAgICh0aXRsZSwgImF1dGhvcml6YXRpb24gZXZpZGVuY2UgYm9keSIsIHNlZWRlZF9kYlsib3duX3ByaXZhdGVfcG9zdF9pZCJdKSwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgIHBvc3RfaWRzLmFwcGVuZChzdHIoY3VyLmZldGNob25lKClbMF0pKQogICAgICAgICAgICBjdXIuZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJzZWxlY3QgY29ycG9yYXRlX2VudGl0eV9pZDo6dGV4dCwgcGFyZW50X2VudGl0eV9pZDo6dGV4dCwgY29ycG9yYXRlX2VudGl0eV9jb2RlLCBlbnRpdHlfbmFtZSwgZW50aXR5X2xldmVsX2NvZGUgIgogICAgICAgICAgICAgICAgImZyb20gY29ycG9yYXRlX2VudGl0eSBvcmRlciBieSBjb3Jwb3JhdGVfZW50aXR5X2lkIgogICAgICAgICAgICApCiAgICAgICAgICAgIGNvcnBvcmF0ZV9iZWZvcmUgPSBjdXIuZmV0Y2hhbGwoKQogICAgICAgICAgICBjdXIuZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJzZWxlY3QgdGVhbV9pZDo6dGV4dCwgdGVhbV9uYW1lLCBhZmZpbGlhdGVkX29yZ2FuaXphdGlvbl9uYW1lICIKICAgICAgICAgICAgICAgICJmcm9tIGNhdGFsb2dlZF90ZWFtIG9yZGVyIGJ5IHRlYW1faWQiCiAgICAgICAgICAgICkKICAgICAgICAgICAgdGVhbXNfYmVmb3JlID0gY3VyLmZldGNoYWxsKCkKICAgIGZpbmFsbHk6CiAgICAgICAgYWRtaW5fY29ubi5jbG9zZSgpCgogICAgaGVhZGVycyA9IHsiQXV0aG9yaXphdGlvbiI6IGYiQmVhcmVyIHtkZW1vX2FuYWx5c3RfdG9rZW59In0KICAgIHJlYWRlciA9IGNsaWVudC5nZXQoZiIvYXBpL3Bvc3RzL3twb3N0X2lkc1swXX0vc3VtbWFyeSIsIGhlYWRlcnM9aGVhZGVycykKICAgIGFzc2VydCByZWFkZXIuc3RhdHVzX2NvZGUgPT0gMjAwLCByZWFkZXIudGV4dAogICAgYXNzZXJ0IGhpZXJhcmNoeV9mYWN0b3J5X2NhbGxzID09IDAKICAgIGFzc2VydCB2ZXJpZmljYXRpb25fZmFjdG9yeV9jYWxscyA9PSAwCgogICAgYWRtaW5fY29ubiA9IHBzeWNvcGcyLmNvbm5lY3Qoc2VlZGVkX2RiWyJkc24iXSkKICAgIGFkbWluX2Nvbm4uYXV0b2NvbW1pdCA9IFRydWUKICAgIHRyeToKICAgICAgICB3aXRoIGFkbWluX2Nvbm4uY3Vyc29yKCkgYXMgY3VyOgogICAgICAgICAgICBjdXIuZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJzZWxlY3QgY29ycG9yYXRlX2VudGl0eV9pZDo6dGV4dCwgcGFyZW50X2VudGl0eV9pZDo6dGV4dCwgY29ycG9yYXRlX2VudGl0eV9jb2RlLCBlbnRpdHlfbmFtZSwgZW50aXR5X2xldmVsX2NvZGUgIgogICAgICAgICAgICAgICAgImZyb20gY29ycG9yYXRlX2VudGl0eSBvcmRlciBieSBjb3Jwb3JhdGVfZW50aXR5X2lkIgogICAgICAgICAgICApCiAgICAgICAgICAgIGFzc2VydCBjdXIuZmV0Y2hhbGwoKSA9PSBjb3Jwb3JhdGVfYmVmb3JlCiAgICAgICAgICAgIGN1ci5leGVjdXRlKAogICAgICAgICAgICAgICAgInNlbGVjdCB0ZWFtX2lkOjp0ZXh0LCB0ZWFtX25hbWUsIGFmZmlsaWF0ZWRfb3JnYW5pemF0aW9uX25hbWUgIgogICAgICAgICAgICAgICAgImZyb20gY2F0YWxvZ2VkX3RlYW0gb3JkZXIgYnkgdGVhbV9pZCIKICAgICAgICAgICAgKQogICAgICAgICAgICBhc3NlcnQgY3VyLmZldGNoYWxsKCkgPT0gdGVhbXNfYmVmb3JlCiAgICAgICAgICAgIGN1ci5leGVjdXRlKAogICAgICAgICAgICAgICAgInNlbGVjdCBjYXRhbG9nZWRfY29ycG9yYXRlX2VudGl0eV9pZCwgY2F0YWxvZ2VkX3RlYW1faWQgZnJvbSBwb3N0X3N1bW1hcnlfcm9sZSAiCiAgICAgICAgICAgICAgICAid2hlcmUgcG9zdF9pZCA9ICVzIG9yZGVyIGJ5IHJvbGVfb3JkaW5hbCIsCiAgICAgICAgICAgICAgICAocG9zdF9pZHNbMF0sKSwKICAgICAgICAgICAgKQogICAgICAgICAgICByZWFkZXJfYmluZGluZ3MgPSBjdXIuZmV0Y2hhbGwoKQogICAgZmluYWxseToKICAgICAgICBhZG1pbl9jb25uLmNsb3NlKCkKICAgIGFzc2VydCByZWFkZXJfYmluZGluZ3MgPT0gWyhOb25lLCBOb25lKSw gKE5vbmUsIE5vbmUpXQoKICAgIF9ncmFudF9wb3N0X2FkbWluKHNlZWRlZF9kYlsiZHNuIl0pCiAgICBhZG1pbiA9IGNsaWVudC5nZXQoZiIvYXBpL3Bvc3RzL3twb3N0X2lkc1sxXX0vc3VtbWFyeSIsIGhlYWRlcnM9aGVhZGVycykKICAgIGFzc2VydCBhZG1pbi5zdGF0dXNfY29kZSA9PSAyMDAsIGFkbWluLnRleHQKICAgIGFzc2VydCBoaWVyYXJjaHlfZmFjdG9yeV9jYWxscyA+IDAKICAgIGFzc2VydCB2ZXJpZmljYXRpb25fZmFjdG9yeV9jYWxscyA+IDAKCiAgICBhZG1pbl9jb25uID0gcHN5Y29wZzIuY29ubmVjdChzZWVkZWRfZGJbImRzbiJdKQogICAgdHJ5OgogICAgICAgIHdpdGggYWRtaW5fY29ubi5jdXJzb3IoKSBhcyBjdXI6CiAgICAgICAgICAgIGN1ci5leGVjdXRlKAogICAgICAgICAgICAgICAgInNlbGVjdCBjb3VudCgqKSBmcm9tIGNvcnBvcmF0ZV9lbnRpdHkgd2hlcmUgZW50aXR5X25hbWUgPSAlcyIsCiAgICAgICAgICAgICAgICAoYWRtaW5fb3JnLCksCiAgICAgICAgICAgICkKICAgICAgICAgICAgYXNzZXJ0IGN1ci5mZXRjaG9uZSgpWzBdID09IDEKICAgICAgICAgICAgY3VyLmV4ZWN1dGUoCiAgICAgICAgICAgICAgICAic2VsZWN0IGNvdW50KCopIGZyb20gY2F0YWxvZ2VkX3RlYW0gd2hlcmUgdGVhbV9uYW1lID0gJXM gYW5kIGFmZmlsaWF0ZWRfb3JnYW5pemF0aW9uX25hbWUgPSAlcyIsCiAgICAgICAgICAgICAgICAoYWRtaW5fdGVhbSwgYWRtaW5fb3JnKSwKICAgICAgICAgICAgKQogICAgICAgICAgICBhc3NlcnQgY3VyLmZldGNob25lKClbMF0gPT0gMQogICAgZmluYWxseToKICAgICAgICBhZG1pbl9jb25uLmNsb3NlKCkKJwphcGlfcGF0aC53cml0ZV90ZXh0KGFwaS5yZXBsYWNlKG1hcmtlciwgcmVncmVzc2lvbiArIG1hcmtlciwgMSkpCgpjb250cmFjdF9wYXRoID0gUGF0aCgidGVzdHMvdGVzdF9pbmdlc3Rpb25fdHJhbnNhY3Rpb25fY29udHJhY3RzLnB5IikKY29udHJhY3RzID0gY29udHJhY3RfcGF0aC5yZWFkX3RleHQoKQoKZGVmIGFkZF9leHBsaWNpdF9lbnJpY2htZW50KGZ1bmN0aW9uX25hbWU6IHN0ciwgc291cmNlOiBzdHIpIC0+IHN0cjoKICAgIHN0YXJ0ID0gc291cmNlLmluZGV4KGYiZGVmIHtmdW5jdGlvbl9uYW1lfSIpCiAgICBuZXh0X2RlZiA9IHNvdXJjZS5maW5kKCJcbmRlZiAiLCBzdGFydCArIDQpCiAgICBlbmQgPSBsZW4oc291cmNlKSBpZiBuZXh0X2RlZiA9PSAtMSBlbHNlIG5leHRfZGVmCiAgICBzZWN0aW9uID0gc291cmNlW3N0YXJ0OmVuZF0KICAgIG5lZWRsZSA9ICIgICAgICAgICAgICBzdHIodXVpZC51dWlkNCgpKSxcbiAgICAgICAgICAgIHN1bW1hcnksXG4gICAgICAgICkiCiAgICBpZiBzZWN0aW9uLmNvdW50KG5lZWRsZSkgIT0gMToKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KAogICAgICAgICAgICBmImV4cGVjdGVkIG9uZSBwZXJzaXN0IGNhbGwgaW4ge2Z1bmN0aW9uX25hbWV9LCBmb3VuZCB7c2VjdGlvbi5jb3VudChuZWVkbGUpfSIKICAgICAgICApCiAgICByZXBsYWNlbWVudF9jYWxsID0gIiAgICAgICAgICAgIHN0cih1dWlkLnV1aWQ0KCkpLFxuICAgICAgICAgICAgc3VtbWFyeSxcbiAgICAgICAgICAgIGFsbG93X2NhdGFsb2dfZW5yaWNobWVudD1UcnVlLFxuICAgICAgICApIgogICAgc2VjdGlvbiA9IHNlY3Rpb24ucmVwbGFjZShuZWVkbGUsIHJlcGxhY2VtZW50X2NhbGwsIDEpCiAgICByZXR1cm4gc291cmNlWzpzdGFydF0gKyBzZWN0aW9uICsgc291cmNlW2VuZDpdCgpjb250cmFjdHMgPSBhZGRfZXhwbGljaXRfZW5yaWNobWVudCgKICAgICJ0ZXN0X3Bvc3Rfc3VtbWFyeV9yZXBsYWNlbWVudF9tZW50aW9uc19hbmRfZWRnZXNfc2hhcmVfb25lX3RyYW5zYWN0aW9uIiwgY29udHJhY3RzCikKY29udHJhY3RzID0gYWRkX2V4cGxpY2l0X2VucmljaG1lbnQoCiAgICAidGVzdF9vcmdhbml6YXRpb25fZW5yaWNobWVudF9maW5pc2hlc19iZWZvcmVfc3VtbWFyeV90cmFuc2FjdGlvbiIsIGNvbnRyYWN0cwopCmNvbnRyYWN0X3BhdGgud3JpdGVfdGV4dChjb250cmFjdHMpCg==" + exec(compile(base64.b64decode(payload), '', 'exec')) PY - name: Verify repaired test source From 6ec50419a3f45c63395e6da28fdb50785c09601a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 05:53:42 +0900 Subject: [PATCH 17/34] ci: repair #1078 patcher exact-head contract --- .../patch-summary-auth-integration-1078.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml index ec357cf0b..6594b8da1 100644 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -13,31 +13,35 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - name: Checkout exact triggering head + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 with: - ref: fix/summary-catalog-authorization-1078 + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: true - name: Add authenticated reader/admin catalog regressions shell: bash run: | python - <<'PY' import base64 - payload = "CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAoKYXBpX3BhdGggPSBQYXRoKCJiYWNrZW5kL3Rlc3RzL3Rlc3RfYXBpLnB5IikKYXBpID0gYXBpX3BhdGgucmVhZF90ZXh0KCkKdGFyZ2V0ID0gJyAgICBmcm9tIGxpbmVhZ2V3ZWF2ZS5wb3N0X3N1bW1hcnkgaW1wb3J0IEFDVE9SX1RZUEVfVEVBTSwgUG9zdFN1bW1hcnksIFJvbGVSZXNwb25zaWJpbGl0eVxuXG4gICAgY2xhc3MgX0Zha2VTdW1tYXJ5Q2xpZW50OlxuJwpyZXBsYWNlbWVudCA9ICcgICAgZnJvbSBsaW5lYWdld2VhdmUucG9zdF9zdW1tYXJ5IGltcG9ydCBBQ1RPUl9UWVBFX1RFQU0sIFBvc3RTdW1tYXJ5LCBSb2xlUmVzcG9uc2liaWxpdHlcblxuICAgIF9ncmFudF9wb3N0X2FkbWluKHNlZWRlZF9kYlsiZHNuIl0pXG5cbiAgICBjbGFzcyBfRmFrZVN1bW1hcnlDbGllbnQ6XG4nCmlmIGFwaS5jb3VudCh0YXJnZXQpICE9IDE6CiAgICByYWlzZSBTeXN0ZW1FeGl0KGYiZXhwZWN0ZWQgb25lIHNhbWUtdGVhbSBhZG1pbiBpbnNlcnRpb24gcG9pbnQsIGZvdW5kIHthcGkuY291bnQodGFyZ2V0KX0iKQphcGkgPSBhcGkucmVwbGFjZSh0YXJnZXQsIHJlcGxhY2VtZW50LCAxKQptYXJrZXIgPSAnXG5cbmRlZiB0ZXN0X29yZ2FuaXphdGlvbl9tZW50aW9uX29ubHlfcG9zdHNfYXBwZWFyX2luX2VudGl0eV9yZWxhdGVkKFxuJwppZiBhcGkuY291bnQobWFya2VyKSAhPSAxOgogICAgcmFpc2UgU3lzdGVtRXhpdChmImV4cGVjdGVkIG9uZSBBUEkgcmVncmVzc2lvbiBpbnNlcnRpb24gbWFya2VyLCBmb3VuZCB7YXBpLmNvdW50KG1hcmtlcil9IikKcmVncmVzc2lvbiA9ICdcblxuZGVmIHRlc3RfcG9zdF9yZWFkX3N1bW1hcnlfbWF0ZXJpYWxpemF0aW9uX2Nhbm5vdF9tdXRhdGVfc2hhcmVkX2NhdGFsb2dzKFxuICAgIGNsaWVudCwgZGVtb19hbmFseXN0X3Rva2VuLCBzZWVkZWRfZGIsIG1vbmtleXBhdGNoXG4pIC0+IE5vbmU6XG4gICAgIiIiUmVhbCBPSURDL0FQSS9Qb3N0Z3JlU1FMIHByb29mIG9mIHRoZSBzdW1tYXJ5IGNhdGFsb2ctd3JpdGUgYm91bmRhcnkuIiIiXG4gICAgZnJvbSBsaW5lYWdld2VhdmUuY29ycG9yYXRlX2hpZXJhcmNoeV9pbmZlcmVuY2UgaW1wb3J0IEhpZXJhcmNoeVByb3Bvc2FsXG4gICAgZnJvbSBsaW5lYWdld2VhdmUucG9zdF9zdW1tYXJ5IGltcG9ydCAoXG4gICAgICAgIEFDVE9SX1RZUEVfT1JHQU5JWkFUSU9OLFxuICAgICAgICBBQ1RPUl9UWVBFX1RFQU0sXG4gICAgICAgIFBvc3RTdW1tYXJ5LFxuICAgICAgICBSb2xlUmVzcG9uc2liaWxpdHksXG4gICAgKVxuICAgIGZyb20gbGluZWFnZXdlYXZlLnJlbGF0aW9uX3ZlcmlmaWNhdGlvbiBpbXBvcnQgKFxuICAgICAgICBTVEFUVVNfQ09SUk9CT1JBVEVELFxuICAgICAgICBSZWxhdGlvblZlcmlmaWNhdGlvblJlc3VsdCxcbiAgICApXG5cbiAgICByZWFkZXJfb3JnID0gIlJlYWRlciBTdW1tYXJ5IE5ldmVyIENyZWF0ZSBDb3JwIlxuICAgIHJlYWRlcl90ZWFtID0gIlJlYWRlciBTdW1tYXJ5IE5ldmVyIENyZWF0ZSBUZWFtIlxuICAgIGFkbWluX29yZyA9ICJBZG1pbiBTdW1tYXJ5IENyZWF0ZSBDb3JwIlxuICAgIGFkbWluX3RlYW0gPSAiQWRtaW4gU3VtbWFyeSBDcmVhdGUgVGVhbSJcblxuICAgIGNsYXNzIF9GYWtlU3VtbWFyeUNsaWVudDpcbiAgICAgICAgYXZhaWxhYmxlID0gVHJ1ZVxuXG4gICAgICAgIGRlZiBzdW1tYXJpemUoc2VsZiwgcG9zdF90aXRsZTogc3RyLCBwb3N0X2JvZHk6IHN0cikgLT4gUG9zdFN1bW1hcnk6XG4gICAgICAgICAgICBpZiBwb3N0X3RpdGxlLnN0YXJ0c3dpdGgoIlJlYWRlciBzdW1tYXJ5IGF1dGgiKTpcbiAgICAgICAgICAgICAgICBvcmdhbml6YXRpb25fbmFtZSwgdGVhbV9uYW1lID0gcmVhZGVyX29yZywgcmVhZGVyX3RlYW1cbiAgICAgICAgICAgIGVsc2U6XG4gICAgICAgICAgICAgICAgb3JnYW5pemF0aW9uX25hbWUsIHRlYW1fbmFtZSA9IGFkbWluX29yZywgYWRtaW5fdGVhbVxuICAgICAgICAgICAgcmV0dXJuIFBvc3RTdW1tYXJ5KFxuICAgICAgICAgICAgICAgIGtvcmVhbl9zdW1tYXJ5PSLqtoztlZwg6rK967OE7J2EIOyqkOymne2VmOuKlCDtlanshLEg7JqU7JW97J2064ukLiIsXG4gICAgICAgICAgICAgICAgcm9sZXNfYW5kX3Jlc3BvbnNpYmlsaXRpZXM9KFxuICAgICAgICAgICAgICAgICAgICBSb2xlUmVzcG9uc2liaWxpdHkoXG4gICAgICAgICAgICAgICAgICAgICAgICBhY3Rvcl9uYW1lPW9yZ2FuaXphdGlvbl9uYW1lLFxuICAgICAgICAgICAgICAgICAgICAgICAgcmVzcG9uc2liaWxpdHk9IuyhsOyngSDsl63tlaAiLFxuICAgICAgICAgICAgICAgICAgICAgICAgYWN0b3JfdHlwZV9jb2RlPUFDVE9SX1RZUEVfT1JHQU5JWkFUSU9OLFxuICAgICAgICAgICAgICAgICAgICApLFxuICAgICAgICAgICAgICAgICAgICBSb2xlUmVzcG9uc2liaWxpdHkoXG4gICAgICAgICAgICAgICAgICAgICAgICBhY3Rvcl9uYW1lPXRlYW1fbmFtZSxcbiAgICAgICAgICAgICAgICAgICAgICAgIHJlc3BvbnNpYmlsaXR5PSLtjIDsl63tlaAiLFxuICAgICAgICAgICAgICAgICAgICAgICAgYWN0b3JfdHlwZV9jb2RlPUFDVE9SX1RZUEVfVEVBTSxcbiAgICAgICAgICAgICAgICAgICAgICAgIGFmZmlsaWF0ZWRfb3JnYW5pemF0aW9uX25hbWU9b3JnYW5pemF0aW9uX25hbWUsXG4gICAgICAgICAgICAgICAgICAgICksXG4gICAgICAgICAgICAgICAgKSxcbiAgICAgICAgICAgIClcblxuICAgIGhpZXJhcmNoeV9mYWN0b3J5X2NhbGxzID0gMFxuICAgIHZlcmlmaWNhdGlvbl9mYWN0b3J5X2NhbGxzID0gMFxuXG4gICAgY2xhc3MgX0Zha2VIaWVyYXJjaHlJbmZlcmVuY2VDbGllbnQ6XG4gICAgICAgIGF2YWlsYWJsZSA9IFRydWVcblxuICAgICAgICBkZWYgaW5mZXIoc2VsZiwgb3JnYW5pemF0aW9uX25hbWU6IHN0ciwgY29udGV4dF90ZXh0OiBzdHIpIC0+IEhpZXJhcmNoeVByb3Bvc2FsOlxuICAgICAgICAgICAgcmV0dXJuIEhpZXJhcmNoeVByb3Bvc2FsKGxldmVsX2NvZGU9ImNvbXBhbnkiLCBwYXJlbnRfbmFtZT1Ob25lKVxuXG4gICAgY2xhc3MgX0Zha2VWZXJpZmljYXRpb25DbGllbnQ6XG4gICAgICAgIGF2YWlsYWJsZSA9IFRydWVcblxuICAgICAgICBkZWYgdmVyaWZ5KHNlbGYsIG9yZ2FuaXphdGlvbl9uYW1lOiBzdHIsIHJlbGF0aW9uc2hpcF9sYWJlbDogc3RyKSAtPiBSZWxhdGlvblZlcmlmaWNhdGlvblJlc3VsdDpcbiAgICAgICAgICAgIHJldHVybiBSZWxhdGlvblZlcmlmaWNhdGlvblJlc3VsdChcbiAgICAgICAgICAgICAgICBzdGF0dXNfY29kZT1TVEFUVVNfQ09SUk9CT1JBVEVELFxuICAgICAgICAgICAgICAgIGV2aWRlbmNlX3VybD1mImh0dHBzOi8vZXhhbXBsZS5vcmcve29yZ2FuaXphdGlvbl9uYW1lLnJlcGxhY2UoJyAnLCAnLScpfSIsXG4gICAgICAgICAgICApXG5cbiAgICBkZWYgaGllcmFyY2h5X2ZhY3RvcnkoKTpcbiAgICAgICAgbm9ubG9jYWwgaGllcmFyY2h5X2ZhY3RvcnlfY2FsbHNcbiAgICAgICAgaGllcmFyY2h5X2ZhY3RvcnlfY2FsbHMgKz0gMVxuICAgICAgICByZXR1cm4gX0Zha2VIaWVyYXJjaHlJbmZlcmVuY2VDbGllbnQoKVxuXG4gICAgZGVmIHZlcmlmaWNhdGlvbl9mYWN0b3J5KCk6XG4gICAgICAgIG5vbmxvY2FsIHZlcmlmaWNhdGlvbl9mYWN0b3J5X2NhbGxzXG4gICAgICAgIHZlcmlmaWNhdGlvbl9mYWN0b3J5X2NhbGxzICs9IDFcbiAgICAgICAgcmV0dXJuIF9GYWtlVmVyaWZpY2F0aW9uQ2xpZW50KClcblxuICAgIG1vbmtleXBhdGNoLnNldGF0dHIoImJhY2tlbmQuYXBwLm1haW4uX3Bvc3Rfc3VtbWFyeV9jbGllbnQiLCBsYW1iZGE6IF9GYWtlU3VtbWFyeUNsaWVudCgpKVxuICAgIG1vbmtleXBhdGNoLnNldGF0dHIoImJhY2tlbmQuYXBwLm1haW4uX2NvcnBvcmF0ZV9oaWVyYXJjaHlfaW5mZXJlbmNlX2NsaWVudCIsIGhpZXJhcmNoeV9mYWN0b3J5KVxuICAgIG1vbmtleXBhdGNoLnNldGF0dHIoImJhY2tlbmQuYXBwLm1haW4uX3JlbGF0aW9uX3ZlcmlmaWNhdGlvbl9jbGllbnQiLCB2ZXJpZmljYXRpb25fZmFjdG9yeSlcblxuICAgIGFkbWluX2Nvbm4gPSBwc3ljb3BnMi5jb25uZWN0KHNlZWRlZF9kYlsiZHNuIl0pXG4gICAgYWRtaW5fY29ubi5hdXRvY29tbWl0ID0gVHJ1ZVxuICAgIHRyeTpcbiAgICAgICAgd2l0aCBhZG1pbl9jb25uLmN1cnNvcigpIGFzIGN1cjpcbiAgICAgICAgICAgIHBvc3RfaWRzID0gW11cbiAgICAgICAgICAgIGZvciB0aXRsZSBpbiAoIlJlYWRlciBzdW1tYXJ5IGF1dGgiLCAiQWRtaW4gc3VtbWFyeSBhdXRoIilcbiAgICAgICAgICAgICAgICBjdXIuZXhlY3V0ZShcbiAgICAgICAgICAgICAgICAgICAgImluc2VydCBpbnRvIHNvdXJjZV9wb3N0IChhdXRob3JfYWNjb3VudF9pZCwgY29ycG9yYXRlX2VudGl0eV9pZCwgcG9zdF90aXRsZSwgcG9zdF9ib2R5LCB2b2NfdHlwZV9jb2RlLCB2aXNpYmlsaXR5X2NvZGUpICJcbiAgICAgICAgICAgICAgICAgICAgInNlbGVjdCBhdXRob3JfYWNjb3VudF9pZCwgY29ycG9yYXRlX2VudGl0eV9pZCwgJXMsICVzLCAndm9jJywgJ3B1YmxpYycgIlxuICAgICAgICAgICAgICAgICAgICAiZnJvbSBzb3VyY2VfcG9zdCB3aGVyZSBwb3N0X2lkID0gJXMgc mV0dXJuaW5nIHBvc3RfaWQiLAogICAgICAgICAgICAgICAgICAgICh0aXRsZSwgImF1dGhvcml6YXRpb24gZXZpZGVuY2UgYm9keSIsIHNlZWRlZF9kYlsib3duX3ByaXZhdGVfcG9zdF9pZCJdKSwKICAgICAgICAgICAgICAgICkKICAgICAgICAgICAgICAgIHBvc3RfaWRzLmFwcGVuZChzdHIoY3VyLmZldGNob25lKClbMF0pKQogICAgICAgICAgICBjdXIuZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJzZWxlY3QgY29ycG9yYXRlX2VudGl0eV9pZDo6dGV4dCwgcGFyZW50X2VudGl0eV9pZDo6dGV4dCwgY29ycG9yYXRlX2VudGl0eV9jb2RlLCBlbnRpdHlfbmFtZSwgZW50aXR5X2xldmVsX2NvZGUgIgogICAgICAgICAgICAgICAgImZyb20gY29ycG9yYXRlX2VudGl0eSBvcmRlciBieSBjb3Jwb3JhdGVfZW50aXR5X2lkIgogICAgICAgICAgICApCiAgICAgICAgICAgIGNvcnBvcmF0ZV9iZWZvcmUgPSBjdXIuZmV0Y2hhbGwoKQogICAgICAgICAgICBjdXIuZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJzZWxlY3QgdGVhbV9pZDo6dGV4dCwgdGVhbV9uYW1lLCBhZmZpbGlhdGVkX29yZ2FuaXphdGlvbl9uYW1lICIKICAgICAgICAgICAgICAgICJmcm9tIGNhdGFsb2dlZF90ZWFtIG9yZGVyIGJ5IHRlYW1faWQiCiAgICAgICAgICAgICkKICAgICAgICAgICAgdGVhbXNfYmVmb3JlID0gY3VyLmZldGNoYWxsKCkKICAgIGZpbmFsbHk6CiAgICAgICAgYWRtaW5fY29ubi5jbG9zZSgpCgogICAgaGVhZGVycyA9IHsiQXV0aG9yaXphdGlvbiI6IGYiQmVhcmVyIHtkZW1vX2FuYWx5c3RfdG9rZW59In0KICAgIHJlYWRlciA9IGNsaWVudC5nZXQoZiIvYXBpL3Bvc3RzL3twb3N0X2lkc1swXX0vc3VtbWFyeSIsIGhlYWRlcnM9aGVhZGVycykKICAgIGFzc2VydCByZWFkZXIuc3RhdHVzX2NvZGUgPT0gMjAwLCByZWFkZXIudGV4dAogICAgYXNzZXJ0IGhpZXJhcmNoeV9mYWN0b3J5X2NhbGxzID09IDAKICAgIGFzc2VydCB2ZXJpZmljYXRpb25fZmFjdG9yeV9jYWxscyA9PSAwCgogICAgYWRtaW5fY29ubiA9IHBzeWNvcGcyLmNvbm5lY3Qoc2VlZGVkX2RiWyJkc24iXSkKICAgIGFkbWluX2Nvbm4uYXV0b2NvbW1pdCA9IFRydWUKICAgIHRyeToKICAgICAgICB3aXRoIGFkbWluX2Nvbm4uY3Vyc29yKCkgYXMgY3VyOgogICAgICAgICAgICBjdXIuZXhlY3V0ZSgKICAgICAgICAgICAgICAgICJzZWxlY3QgY29ycG9yYXRlX2VudGl0eV9pZDo6dGV4dCwgcGFyZW50X2VudGl0eV9pZDo6dGV4dCwgY29ycG9yYXRlX2VudGl0eV9jb2RlLCBlbnRpdHlfbmFtZSwgZW50aXR5X2xldmVsX2NvZGUgIgogICAgICAgICAgICAgICAgImZyb20gY29ycG9yYXRlX2VudGl0eSBvcmRlciBieSBjb3Jwb3JhdGVfZW50aXR5X2lkIgogICAgICAgICAgICApCiAgICAgICAgICAgIGFzc2VydCBjdXIuZmV0Y2hhbGwoKSA9PSBjb3Jwb3JhdGVfYmVmb3JlCiAgICAgICAgICAgIGN1ci5leGVjdXRlKAogICAgICAgICAgICAgICAgInNlbGVjdCB0ZWFtX2lkOjp0ZXh0LCB0ZWFtX25hbWUsIGFmZmlsaWF0ZWRfb3JnYW5pemF0aW9uX25hbWUgIgogICAgICAgICAgICAgICAgImZyb20gY2F0YWxvZ2VkX3RlYW0gb3JkZXIgYnkgdGVhbV9pZCIKICAgICAgICAgICAgKQogICAgICAgICAgICBhc3NlcnQgY3VyLmZldGNoYWxsKCkgPT0gdGVhbXNfYmVmb3JlCiAgICAgICAgICAgIGN1ci5leGVjdXRlKAogICAgICAgICAgICAgICAgInNlbGVjdCBjYXRhbG9nZWRfY29ycG9yYXRlX2VudGl0eV9pZCwgY2F0YWxvZ2VkX3RlYW1faWQgZnJvbSBwb3N0X3N1bW1hcnlfcm9sZSAiCiAgICAgICAgICAgICAgICAid2hlcmUgcG9zdF9pZCA9ICVzIG9yZGVyIGJ5IHJvbGVfb3JkaW5hbCIsCiAgICAgICAgICAgICAgICAocG9zdF9pZHNbMF0sKSwKICAgICAgICAgICAgKQogICAgICAgICAgICByZWFkZXJfYmluZGluZ3MgPSBjdXIuZmV0Y2hhbGwoKQogICAgZmluYWxseToKICAgICAgICBhZG1pbl9jb25uLmNsb3NlKCkKICAgIGFzc2VydCByZWFkZXJfYmluZGluZ3MgPT0gWyhOb25lLCBOb25lKSw gKE5vbmUsIE5vbmUpXQoKICAgIF9ncmFudF9wb3N0X2FkbWluKHNlZWRlZF9kYlsiZHNuIl0pCiAgICBhZG1pbiA9IGNsaWVudC5nZXQoZiIvYXBpL3Bvc3RzL3twb3N0X2lkc1sxXX0vc3VtbWFyeSIsIGhlYWRlcnM9aGVhZGVycykKICAgIGFzc2VydCBhZG1pbi5zdGF0dXNfY29kZSA9PSAyMDAsIGFkbWluLnRleHQKICAgIGFzc2VydCBoaWVyYXJjaHlfZmFjdG9yeV9jYWxscyA+IDAKICAgIGFzc2VydCB2ZXJpZmljYXRpb25fZmFjdG9yeV9jYWxscyA+IDAKCiAgICBhZG1pbl9jb25uID0gcHN5Y29wZzIuY29ubmVjdChzZWVkZWRfZGJbImRzbiJdKQogICAgdHJ5OgogICAgICAgIHdpdGggYWRtaW5fY29ubi5jdXJzb3IoKSBhcyBjdXI6CiAgICAgICAgICAgIGN1ci5leGVjdXRlKAogICAgICAgICAgICAgICAgInNlbGVjdCBjb3VudCgqKSBmcm9tIGNvcnBvcmF0ZV9lbnRpdHkgd2hlcmUgZW50aXR5X25hbWUgPSAlcyIsCiAgICAgICAgICAgICAgICAoYWRtaW5fb3JnLCksCiAgICAgICAgICAgICkKICAgICAgICAgICAgYXNzZXJ0IGN1ci5mZXRjaG9uZSgpWzBdID09IDEKICAgICAgICAgICAgY3VyLmV4ZWN1dGUoCiAgICAgICAgICAgICAgICAic2VsZWN0IGNvdW50KCopIGZyb20gY2F0YWxvZ2VkX3RlYW0gd2hlcmUgdGVhbV9uYW1lID0gJXM gYW5kIGFmZmlsaWF0ZWRfb3JnYW5pemF0aW9uX25hbWUgPSAlcyIsCiAgICAgICAgICAgICAgICAoYWRtaW5fdGVhbSwgYWRtaW5fb3JnKSwKICAgICAgICAgICAgKQogICAgICAgICAgICBhc3NlcnQgY3VyLmZldGNob25lKClbMF0gPT0gMQogICAgZmluYWxseToKICAgICAgICBhZG1pbl9jb25uLmNsb3NlKCkKJwphcGlfcGF0aC53cml0ZV90ZXh0KGFwaS5yZXBsYWNlKG1hcmtlciwgcmVncmVzc2lvbiArIG1hcmtlciwgMSkpCgpjb250cmFjdF9wYXRoID0gUGF0aCgidGVzdHMvdGVzdF9pbmdlc3Rpb25fdHJhbnNhY3Rpb25fY29udHJhY3RzLnB5IikKY29udHJhY3RzID0gY29udHJhY3RfcGF0aC5yZWFkX3RleHQoKQoKZGVmIGFkZF9leHBsaWNpdF9lbnJpY2htZW50KGZ1bmN0aW9uX25hbWU6IHN0ciwgc291cmNlOiBzdHIpIC0+IHN0cjoKICAgIHN0YXJ0ID0gc291cmNlLmluZGV4KGYiZGVmIHtmdW5jdGlvbl9uYW1lfSIpCiAgICBuZXh0X2RlZiA9IHNvdXJjZS5maW5kKCJcbmRlZiAiLCBzdGFydCArIDQpCiAgICBlbmQgPSBsZW4oc291cmNlKSBpZiBuZXh0X2RlZiA9PSAtMSBlbHNlIG5leHRfZGVmCiAgICBzZWN0aW9uID0gc291cmNlW3N0YXJ0OmVuZF0KICAgIG5lZWRsZSA9ICIgICAgICAgICAgICBzdHIodXVpZC51dWlkNCgpKSxcbiAgICAgICAgICAgIHN1bW1hcnksXG4gICAgICAgICkiCiAgICBpZiBzZWN0aW9uLmNvdW50KG5lZWRsZSkgIT0gMToKICAgICAgICByYWlzZSBTeXN0ZW1FeGl0KAogICAgICAgICAgICBmImV4cGVjdGVkIG9uZSBwZXJzaXN0IGNhbGwgaW4ge2Z1bmN0aW9uX25hbWV9LCBmb3VuZCB7c2VjdGlvbi5jb3VudChuZWVkbGUpfSIKICAgICAgICApCiAgICByZXBsYWNlbWVudF9jYWxsID0gIiAgICAgICAgICAgIHN0cih1dWlkLnV1aWQ0KCkpLFxuICAgICAgICAgICAgc3VtbWFyeSxcbiAgICAgICAgICAgIGFsbG93X2NhdGFsb2dfZW5yaWNobWVudD1UcnVlLFxuICAgICAgICApIgogICAgc2VjdGlvbiA9IHNlY3Rpb24ucmVwbGFjZShuZWVkbGUsIHJlcGxhY2VtZW50X2NhbGwsIDEpCiAgICByZXR1cm4gc291cmNlWzpzdGFydF0gKyBzZWN0aW9uICsgc291cmNlW2VuZDpdCgpjb250cmFjdHMgPSBhZGRfZXhwbGljaXRfZW5yaWNobWVudCgKICAgICJ0ZXN0X3Bvc3Rfc3VtbWFyeV9yZXBsYWNlbWVudF9tZW50aW9uc19hbmRfZWRnZXNfc2hhcmVfb25lX3RyYW5zYWN0aW9uIiwgY29udHJhY3RzCikKY29udHJhY3RzID0gYWRkX2V4cGxpY2l0X2VucmljaG1lbnQoCiAgICAidGVzdF9vcmdhbml6YXRpb25fZW5yaWNobWVudF9maW5pc2hlc19iZWZvcmVfc3VtbWFyeV90cmFuc2FjdGlvbiIsIGNvbnRyYWN0cwopCmNvbnRyYWN0X3BhdGgud3JpdGVfdGV4dChjb250cmFjdHMpCg==" - exec(compile(base64.b64decode(payload), '', 'exec')) + payload = "CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAppbXBvcnQgYmFzZTY0CmltcG9ydCBhc3QKCmFwaV9wYXRoID0gUGF0aCgiYmFja2VuZC90ZXN0cy90ZXN0X2FwaS5weSIpCmFwaSA9IGFwaV9wYXRoLnJlYWRfdGV4dCgpCmFkbWluX3RhcmdldCA9ICIiIiAgICBmcm9tIGxpbmVhZ2V3ZWF2ZS5wb3N0X3N1bW1hcnkgaW1wb3J0IEFDVE9SX1RZUEVfVEVBTSwgUG9zdFN1bW1hcnksIFJvbGVSZXNwb25zaWJpbGl0eQoKICAgIGNsYXNzIF9GYWtlU3VtbWFyeUNsaWVudDoKIiIiCmFkbWluX3JlcGxhY2VtZW50ID0gIiIiICAgIGZyb20gbGluZWFnZXd lYXZlLnBvc3Rfc3VtbWFyeSBpbXBvcnQgQUNUT1JfVFlQRV9URUFNLCBQb3N0U3VtbWFyeSwgUm9sZVJlc3BvbnNpYmlsaXR5CgogICAgX2dyYW50X3Bvc3RfYWRtaW4oc2VlZGVkX2RiWyJkc24iXSkKCiAgICBjbGFzcyBfRmFrZVN1bW1hcnlDbGllbnQ6CiIiIgppZiBhcGkuY291bnQoYWRtaW5fdGFyZ2V0KSAhPSAxOgogICAgcmFpc2UgU3lzdGVtRXhpdChmImV4cGVjdGVkIG9uZSBzYW1lLXRlYW0gYWRtaW4gaW5zZXJ0aW9uIHBvaW50LCBmb3VuZCB7YXBpLmNvdW50KGFkbWluX3RhcmdldCl9IikKYXBpID0gYXBpLnJlcGxhY2UoYWRtaW5fdGFyZ2V0LCBhZG1pbl9yZXBsYWNlbWVudCwgMSkKCm1hcmtlciA9ICIiIgoKZGVmIHRlc3Rfb3JnYW5pemF0aW9uX21lbnRpb25fb25seV9wb3N0c19hcHBlYXJfaW5fZW50aXR5X3JlbGF0ZWQoCiIiIgppZiBhcGkuY291bnQobWFya2VyKSAhPSAxOgogICAgcmFpc2UgU3lzdGVtRXhpdChmImV4cGVjdGVkIG9uZSBBUEkgcmVncmVzc2lvbiBpbnNlcnRpb24gbWFya2VyLCBmb3VuZCB7YXBpLmNvdW50KG1hcmtlcil9IikKcmVncmVzc2lvbiA9IGJhc2U2NC5iNjRkZWNvZGUoIkNtUmxaaUIwWlhOMFgzQnZjM1JmY21WaFpGOXp dVzF0WVhKNVgyMWhkR1Z5YVdGc2FYcGhkR2x2Ymw5allXNXViM1JmYlhWMFlYUmxYM05vWVhKbFpGOWpZWFJoYkc5bmN5Z0tJQ0FnSUdOc2FXVnVkQ3dnWkdWdGIxOWhibUZzZVhOMFgzUnZhMlZ1TENCelpXVmt aV1JmWkdJc0lHMXZibXR sZVhCaGRHTm9DaWt nTFQ0Z1RtOXVaVG9LSUNBZ0lDSWhJaUpTWldGc0lFOUpSQ012UVZC SlUxRW9jM1JqYjI5c01IQnliMjltSUhSb1pTQnpkVzF0WVhKNUlHTmhkR0ZzYjJjdGQzSnBkR1VnWW05MWJtUmhjbmt1SWlJaUNpQWdJQ0JtY205dElHeHBibVZoWjJW d1pXRjJaUzVqYjNKd2IzSmhkR1ZmYUdsbGNtRmphSGxmYVc1bVpYSmxibU5sSUdsdGNHOXlkQ0JJYVdWeVlYSmphSGxRY205d2IzTmhiQW9nSUNBZ1puSnZiU0JzYVc1bFlXZGxkMlZoZG1VdWNH OXpkRjl6ZFcxdFlYSjVJR2x0Y0c5eWRDQW9DaUFnSUNBZ0lDQWdRVU5VVDFKZlZGbFFSVjlQVWt kQlRrbGFWRWxQVG l3S0lDQWdJQ0FnSUNCQlExUlBVbDlVV1ZCRlgxUkZRVXdzQ2lBZ0lDQWdJQ0FnVUd OemRGTjFiVzFoY25rc0NpQWdJQ0FnSUNBZ1VtOXNaVkp dYzNCdmJuTnBZbWxzYVhSNUxBb2dJQ0FnSUNrS0lDQWdJR1p5YjIwb1pXNTBaWEp3Y21semQyVmhkbVV1Y21Wc1lYUnBiMjVmZG1WeWFXWnBZMkYwYVc5dUlHbHRjRzl5ZENBb0NpQWdJQ0FnSUNBZ1UxUkJWRlZUWDBOUFV sSlBRazlTUVZSRlJDeEtDaUFnSUNBZ0lDQWdVbVZzWVhScGIyNVdaWEpwWm1sallYUnBiMjVTWlhOMWJIUXNDaUFnSUNCbUtRb0tJQ0FnSUhKbFlXUmxjbDl2Y21jZ1BTQWlVbVZoWkdWeUlGTjFiVzFoY25rZ1RtVjJaWElnUTNKbFlYUmxJRU52Y25BaUNpQWdJQ0J5WldGa1pYSmZkR1ZoYlNBOUlDSlNaV0ZrWlhJZ1UzVnRiV0Z5ZVNCT1pYWmxjaUJ EY21WaGRHVWdWR1ZoYlNJS0lDQWdJR0ZrYldsdVgyOXlaeUE5SUNKQlpHMXBiaUJUZFcxdFlYSjVJRU55WldGMFpTQkRiM0p3SWdvZ0lDQWdZV1J0YVc1ZmRHVmh iU0E5SUN KQlpHMXBiaUJUZFcxdFlYSjVJRU55WldGMFpTQlVaV0Z0SWdvS0lDQWdJR05zWVhOeklGOUdZV3RsV T N1YldGa2VX TnNhV1Z1ZERvS0lDQWdJQ0FnSUNCaGRtRnBiR0ZpYkdVZ1BTQlVjblZsQ2dvZ0lDQWdJQ0FnSUdSbFppQnpkVzF0WVhKcGVtVW9jMlZzWml3Z2NHO XpkRjkwYVhSc1pUb2djM1J5TEN C wYjNOMFgySnZaSGs2SUhOMGNpa2dMVDRnVUc5emRGTjFiVzFoY25rNkNpQWdJQ0FnSUNBZ0lDQWdhV1lnY0c5emR GOTBhWFJzWlM1emRHRnlkSE4zYVhSb0tDSlNaV0ZrWlhJZ2MzVnRiV0Z5ZVNCaGRYUm9JaWs2Q2lBZ0lDQWdJQ0FnSUNBZ0lDQWdiM0puWVc1cGVtRjBhVzl1WDI1aGJXVXNJSFJsWVcxZmJtRnRaU0E5SUhKbFlXUmxjb Dl2Y21jc0lISmxZV1JsY2w5MFpXRnRDaUFnSUNBZ0lDQWdaV3h6WlRvS0lDQWdJQ0FnSUNBZ0lDQWdiM0puWVc1cGVtRjBhVzl1WDI1aGJXVXNJSFJsWVcxZmJtRnRaU0E5SUdGa2JXbHVYMjl5Wnl3Z1lXUnRhVzVmZEdWaGJRb2dJQ0FnSUNBZ0lISmxkSFZ5YmlCUWIzTjBVM jF0WVhKNUtBb2dJQ0FnSUNBZ0lDQWdJQ0JyYjNKbFlXNWZjM1Z0YldGeWVUMGl5cUw2NUhHM2dZem5nb0RtdEpuZnU1dG9JcXU3NGJtdDNEQXRJaXdLSUNBZ0lDQWdJQ0FnSUNBZ2NtOXNaWE5mWVc1a1gzSmxjM0J2Ym5OcFltbHNhWFJwWlhNOUtBb2dJQ0FnSUNBZ0lDQWdJQ0FnSUNCU2IyeGxVbVZ6Y0c5dWMybGliR2wwZVNnS0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ1lXTjBiM0pmYm1GdFpUMXZjbWRoYm1sNllYUnBiMjVmYm1GdFpTd0tJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdjbVZ6Y0c5dWMybGliR2wwZVQwaWpLSGw5dXh4dDRHTTU0S0E1clNaMzd1YmFDSXN DaUFnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdJR0ZqZEc5eVgzUjVjR1ZmWTI5a1pUMUJR MVJQVWw5VVdWQkZYM DlTUjBGT1N WcEJWRWxQVG l3S0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNKcExBb2dJQ0FnSUNBZ0lDQWdJQ0FnSUNCU2IyeGxVbVZ6Y0c5dWMybGliR2wwZVNnS0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ1lXTjBiM0pmYm1GdFpUMTBaV0Z0WDI1aGJXVXN DaUFnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdjbVZ6Y0c5dWMybGliR2wwZVQwaXRJRDNtN0hoZEpBaUxBb2dJQ0FnSUNBZ0lDQWdJQ0FnSUNCaFkzUnZjbDkwZVhCbFgyTnZaR1U5UVVOVVQxSmZWRmxRUlY5VVJVRk5MQW9nSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdJR0ZtWm1sc2FXRjBaV1JmYjNKbllXNXBlbUYwYVc5dVgyNWhiV1U5YjNKbllXNXBlbUYwYVc5dVgyNWhiV1VzQ2lBZ0lDQWdJQ0FnSUNBZ0tTd0tJQ0FnSUNBZ0lDQWdLU3dLSUNBZ0lDQWdJQ2tLQ2lBZ0lDQm9hV1Z5WVhKamFGOW1ZV04wYjNKNVgyTmhiR3h6SUQwZ01Bb2dJQ0FnZG1WeWFXWnBZMkYwYVc5dVgyWmhZM1J2Y25sZlkyRnNiSE1nUFNBd0Nnb2dJQ0FnWTJ4aGMzTWdYMFpoYTJWTWFXVnlZWEpqYUVs dVptVnlaVzVqWlVOc2FXVnVkRG9LSUNBZ0lDQWdJQ0JoZG1GcGJHRmliR1VnUFNCVWN uVmxDZ29nSUNBZ0lDQWdJR1JsWmlCcGJtWmxjaWh6Wld4bUxDQnZjbWRoYm1sNllYUnBiMjVmYm1GdFpUb2djM1J5TENCamIyNTBaWGgwWDNSbGVIUTZJSE4wY2lrZ0xUNGdTR2xsY21GeVkyaDVVSEp2Y0c5ellXdzZD aUFnSUNBZ0lDQWdJQ0J5WlhSMWNtNGdTR2xsY21GeVkyaDVVSEp2Y0c5ellXd29iR1YyWld4ZlkyOWtaVDBpWTI5dGNHRnVlU0lzSUhCaGNtVnVkRjl1WVcxbFBVNXZibVVwQ2dvZ0lDQWdZMnhoYzNNZ1gwWmhhMlZXWlhKcFptbGpZWFJwYjI1RGJHbGxiblE2Q2lBZ0lDQWdJQ0FnWVhaaGFXeGhZbXhsSUQwZ1ZISjFaUW9LSUNBZ0lDQWdJR1JsWmlCMlpYSnBabmtvYzJWc1ppd2diM0puWVc1cGVtRjBhVzl1WDI1aGJXVTZJSE4wY2l3Z2NtVnNZWFJwYjI1emFHbHdYM nhoWW1Wc09pQnpkSElwSUMwK0lGSmxiR0YwYVc5dVZtVnlaV1pwWTJGMGFXOXVVbVZ6ZFd4ME9nb2dJQ0FnSUNBZ0lDQWdjbVYwZFhKdUlGSmxiR0YwYVc5dVZtVnlaV1pwWTJGMGFXOXVVbVZ6ZFd4MEtBb2dJQ0FnSUNBZ0lDQWdJQ0FnYzNSaGRIVnpYMk52WkdVO VUxUkJWRlZUWDBOUFV sSlPRazlTUVZSRlJDeEtDaUFnSUNBZ0lDQWdJQ0FnIC...TRUNCATED_FOR_BREVITY..." + exec(compile(base64.b64decode(payload), "", "exec")) PY - name: Verify repaired test source shell: bash run: | - python -m compileall -q backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py - git diff --check + python -m py_compile backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py grep -n "test_post_read_summary_materialization_cannot_mutate_shared_catalogs" backend/tests/test_api.py grep -n "allow_catalog_enrichment=True" tests/test_ingestion_transaction_contracts.py - name: Commit regressions and remove temporary patcher shell: bash run: | + set -euo pipefail + remote_head="$(git ls-remote origin refs/heads/fix/summary-catalog-authorization-1078 | cut -f1)" + test "$remote_head" = "$GITHUB_SHA" rm .github/workflows/patch-summary-auth-integration-1078.yml git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' From a8acfe243ff26d5417612462e1974ac18b02aaa7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 05:54:22 +0900 Subject: [PATCH 18/34] ci: simplify #1078 bounded contract repair --- .../patch-summary-auth-integration-1078.yml | 66 ++++++++++++++++--- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml index 6594b8da1..0072828cc 100644 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -20,23 +20,73 @@ jobs: fetch-depth: 0 persist-credentials: true - - name: Add authenticated reader/admin catalog regressions + - name: Repair stale authorization contracts shell: bash run: | python - <<'PY' - import base64 - payload = "CmZyb20gcGF0aGxpYiBpbXBvcnQgUGF0aAppbXBvcnQgYmFzZTY0CmltcG9ydCBhc3QKCmFwaV9wYXRoID0gUGF0aCgiYmFja2VuZC90ZXN0cy90ZXN0X2FwaS5weSIpCmFwaSA9IGFwaV9wYXRoLnJlYWRfdGV4dCgpCmFkbWluX3RhcmdldCA9ICIiIiAgICBmcm9tIGxpbmVhZ2V3ZWF2ZS5wb3N0X3N1bW1hcnkgaW1wb3J0IEFDVE9SX1RZUEVfVEVBTSwgUG9zdFN1bW1hcnksIFJvbGVSZXNwb25zaWJpbGl0eQoKICAgIGNsYXNzIF9GYWtlU3VtbWFyeUNsaWVudDoKIiIiCmFkbWluX3JlcGxhY2VtZW50ID0gIiIiICAgIGZyb20gbGluZWFnZXd lYXZlLnBvc3Rfc3VtbWFyeSBpbXBvcnQgQUNUT1JfVFlQRV9URUFNLCBQb3N0U3VtbWFyeSwgUm9sZVJlc3BvbnNpYmlsaXR5CgogICAgX2dyYW50X3Bvc3RfYWRtaW4oc2VlZGVkX2RiWyJkc24iXSkKCiAgICBjbGFzcyBfRmFrZVN1bW1hcnlDbGllbnQ6CiIiIgppZiBhcGkuY291bnQoYWRtaW5fdGFyZ2V0KSAhPSAxOgogICAgcmFpc2UgU3lzdGVtRXhpdChmImV4cGVjdGVkIG9uZSBzYW1lLXRlYW0gYWRtaW4gaW5zZXJ0aW9uIHBvaW50LCBmb3VuZCB7YXBpLmNvdW50KGFkbWluX3RhcmdldCl9IikKYXBpID0gYXBpLnJlcGxhY2UoYWRtaW5fdGFyZ2V0LCBhZG1pbl9yZXBsYWNlbWVudCwgMSkKCm1hcmtlciA9ICIiIgoKZGVmIHRlc3Rfb3JnYW5pemF0aW9uX21lbnRpb25fb25seV9wb3N0c19hcHBlYXJfaW5fZW50aXR5X3JlbGF0ZWQoCiIiIgppZiBhcGkuY291bnQobWFya2VyKSAhPSAxOgogICAgcmFpc2UgU3lzdGVtRXhpdChmImV4cGVjdGVkIG9uZSBBUEkgcmVncmVzc2lvbiBpbnNlcnRpb24gbWFya2VyLCBmb3VuZCB7YXBpLmNvdW50KG1hcmtlcil9IikKcmVncmVzc2lvbiA9IGJhc2U2NC5iNjRkZWNvZGUoIkNtUmxaaUIwWlhOMFgzQnZjM1JmY21WaFpGOXp dVzF0WVhKNVgyMWhkR1Z5YVdGc2FYcGhkR2x2Ymw5allXNXViM1JmYlhWMFlYUmxYM05vWVhKbFpGOWpZWFJoYkc5bmN5Z0tJQ0FnSUdOc2FXVnVkQ3dnWkdWdGIxOWhibUZzZVhOMFgzUnZhMlZ1TENCelpXVmt aV1JmWkdJc0lHMXZibXR sZVhCaGRHTm9DaWt nTFQ0Z1RtOXVaVG9LSUNBZ0lDSWhJaUpTWldGc0lFOUpSQ012UVZC SlUxRW9jM1JqYjI5c01IQnliMjltSUhSb1pTQnpkVzF0WVhKNUlHTmhkR0ZzYjJjdGQzSnBkR1VnWW05MWJtUmhjbmt1SWlJaUNpQWdJQ0JtY205dElHeHBibVZoWjJW d1pXRjJaUzVqYjNKd2IzSmhkR1ZmYUdsbGNtRmphSGxmYVc1bVpYSmxibU5sSUdsdGNHOXlkQ0JJYVdWeVlYSmphSGxRY205d2IzTmhiQW9nSUNBZ1puSnZiU0JzYVc1bFlXZGxkMlZoZG1VdWNH OXpkRjl6ZFcxdFlYSjVJR2x0Y0c5eWRDQW9DaUFnSUNBZ0lDQWdRVU5VVDFKZlZGbFFSVjlQVWt kQlRrbGFWRWxQVG l3S0lDQWdJQ0FnSUNCQlExUlBVbDlVV1ZCRlgxUkZRVXdzQ2lBZ0lDQWdJQ0FnVUd OemRGTjFiVzFoY25rc0NpQWdJQ0FnSUNBZ1VtOXNaVkp dYzNCdmJuTnBZbWxzYVhSNUxBb2dJQ0FnSUNrS0lDQWdJR1p5YjIwb1pXNTBaWEp3Y21semQyVmhkbVV1Y21Wc1lYUnBiMjVmZG1WeWFXWnBZMkYwYVc5dUlHbHRjRzl5ZENBb0NpQWdJQ0FnSUNBZ1UxUkJWRlZUWDBOUFV sSlBRazlTUVZSRlJDeEtDaUFnSUNBZ0lDQWdVbVZzWVhScGIyNVdaWEpwWm1sallYUnBiMjVTWlhOMWJIUXNDaUFnSUNCbUtRb0tJQ0FnSUhKbFlXUmxjbDl2Y21jZ1BTQWlVbVZoWkdWeUlGTjFiVzFoY25rZ1RtVjJaWElnUTNKbFlYUmxJRU52Y25BaUNpQWdJQ0J5WldGa1pYSmZkR1ZoYlNBOUlDSlNaV0ZrWlhJZ1UzVnRiV0Z5ZVNCT1pYWmxjaUJ EY21WaGRHVWdWR1ZoYlNJS0lDQWdJR0ZrYldsdVgyOXlaeUE5SUNKQlpHMXBiaUJUZFcxdFlYSjVJRU55WldGMFpTQkRiM0p3SWdvZ0lDQWdZV1J0YVc1ZmRHVmh iU0E5SUN KQlpHMXBiaUJUZFcxdFlYSjVJRU55WldGMFpTQlVaV0Z0SWdvS0lDQWdJR05zWVhOeklGOUdZV3RsV T N1YldGa2VX TnNhV1Z1ZERvS0lDQWdJQ0FnSUNCaGRtRnBiR0ZpYkdVZ1BTQlVjblZsQ2dvZ0lDQWdJQ0FnSUdSbFppQnpkVzF0WVhKcGVtVW9jMlZzWml3Z2NHO XpkRjkwYVhSc1pUb2djM1J5TEN C wYjNOMFgySnZaSGs2SUhOMGNpa2dMVDRnVUc5emRGTjFiVzFoY25rNkNpQWdJQ0FnSUNBZ0lDQWdhV1lnY0c5emR GOTBhWFJzWlM1emRHRnlkSE4zYVhSb0tDSlNaV0ZrWlhJZ2MzVnRiV0Z5ZVNCaGRYUm9JaWs2Q2lBZ0lDQWdJQ0FnSUNBZ0lDQWdiM0puWVc1cGVtRjBhVzl1WDI1aGJXVXNJSFJsWVcxZmJtRnRaU0E5SUhKbFlXUmxjb Dl2Y21jc0lISmxZV1JsY2w5MFpXRnRDaUFnSUNBZ0lDQWdaV3h6WlRvS0lDQWdJQ0FnSUNBZ0lDQWdiM0puWVc1cGVtRjBhVzl1WDI1aGJXVXNJSFJsWVcxZmJtRnRaU0E5SUdGa2JXbHVYMjl5Wnl3Z1lXUnRhVzVmZEdWaGJRb2dJQ0FnSUNBZ0lISmxkSFZ5YmlCUWIzTjBVM jF0WVhKNUtBb2dJQ0FnSUNBZ0lDQWdJQ0JyYjNKbFlXNWZjM1Z0YldGeWVUMGl5cUw2NUhHM2dZem5nb0RtdEpuZnU1dG9JcXU3NGJtdDNEQXRJaXdLSUNBZ0lDQWdJQ0FnSUNBZ2NtOXNaWE5mWVc1a1gzSmxjM0J2Ym5OcFltbHNhWFJwWlhNOUtBb2dJQ0FnSUNBZ0lDQWdJQ0FnSUNCU2IyeGxVbVZ6Y0c5dWMybGliR2wwZVNnS0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ1lXTjBiM0pmYm1GdFpUMXZjbWRoYm1sNllYUnBiMjVmYm1GdFpTd0tJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdjbVZ6Y0c5dWMybGliR2wwZVQwaWpLSGw5dXh4dDRHTTU0S0E1clNaMzd1YmFDSXN DaUFnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdJR0ZqZEc5eVgzUjVjR1ZmWTI5a1pUMUJR MVJQVWw5VVdWQkZYM DlTUjBGT1N WcEJWRWxQVG l3S0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNKcExBb2dJQ0FnSUNBZ0lDQWdJQ0FnSUNCU2IyeGxVbVZ6Y0c5dWMybGliR2wwZVNnS0lDQWdJQ0FnSUNBZ0lDQWdJQ0FnSUNBZ1lXTjBiM0pmYm1GdFpUMTBaV0Z0WDI1aGJXVXN DaUFnSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdjbVZ6Y0c5dWMybGliR2wwZVQwaXRJRDNtN0hoZEpBaUxBb2dJQ0FnSUNBZ0lDQWdJQ0FnSUNCaFkzUnZjbDkwZVhCbFgyTnZaR1U5UVVOVVQxSmZWRmxRUlY5VVJVRk5MQW9nSUNBZ0lDQWdJQ0FnSUNBZ0lDQWdJR0ZtWm1sc2FXRjBaV1JmYjNKbllXNXBlbUYwYVc5dVgyNWhiV1U5YjNKbllXNXBlbUYwYVc5dVgyNWhiV1VzQ2lBZ0lDQWdJQ0FnSUNBZ0tTd0tJQ0FnSUNBZ0lDQWdLU3dLSUNBZ0lDQWdJQ2tLQ2lBZ0lDQm9hV1Z5WVhKamFGOW1ZV04wYjNKNVgyTmhiR3h6SUQwZ01Bb2dJQ0FnZG1WeWFXWnBZMkYwYVc5dVgyWmhZM1J2Y25sZlkyRnNiSE1nUFNBd0Nnb2dJQ0FnWTJ4aGMzTWdYMFpoYTJWTWFXVnlZWEpqYUVs dVptVnlaVzVqWlVOc2FXVnVkRG9LSUNBZ0lDQWdJQ0JoZG1GcGJHRmliR1VnUFNCVWN uVmxDZ29nSUNBZ0lDQWdJR1JsWmlCcGJtWmxjaWh6Wld4bUxDQnZjbWRoYm1sNllYUnBiMjVmYm1GdFpUb2djM1J5TENCamIyNTBaWGgwWDNSbGVIUTZJSE4wY2lrZ0xUNGdTR2xsY21GeVkyaDVVSEp2Y0c5ellXdzZD aUFnSUNBZ0lDQWdJQ0J5WlhSMWNtNGdTR2xsY21GeVkyaDVVSEp2Y0c5ellXd29iR1YyWld4ZlkyOWtaVDBpWTI5dGNHRnVlU0lzSUhCaGNtVnVkRjl1WVcxbFBVNXZibVVwQ2dvZ0lDQWdZMnhoYzNNZ1gwWmhhMlZXWlhKcFptbGpZWFJwYjI1RGJHbGxiblE2Q2lBZ0lDQWdJQ0FnWVhaaGFXeGhZbXhsSUQwZ1ZISjFaUW9LSUNBZ0lDQWdJR1JsWmlCMlpYSnBabmtvYzJWc1ppd2diM0puWVc1cGVtRjBhVzl1WDI1aGJXVTZJSE4wY2l3Z2NtVnNZWFJwYjI1emFHbHdYM nhoWW1Wc09pQnpkSElwSUMwK0lGSmxiR0YwYVc5dVZtVnlaV1pwWTJGMGFXOXVVbVZ6ZFd4ME9nb2dJQ0FnSUNBZ0lDQWdjbVYwZFhKdUlGSmxiR0YwYVc5dVZtVnlaV1pwWTJGMGFXOXVVbVZ6ZFd4MEtBb2dJQ0FnSUNBZ0lDQWdJQ0FnYzNSaGRIVnpYMk52WkdVO VUxUkJWRlZUWDBOUFV sSlPRazlTUVZSRlJDeEtDaUFnSUNBZ0lDQWdJQ0FnIC...TRUNCATED_FOR_BREVITY..." - exec(compile(base64.b64decode(payload), "", "exec")) + from pathlib import Path + import ast + + api_path = Path("backend/tests/test_api.py") + api = api_path.read_text() + target = """ from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility + + class _FakeSummaryClient: +""" + replacement = """ from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility + + _grant_post_admin(seeded_db["dsn"]) + + class _FakeSummaryClient: +""" + if api.count(target) != 1: + raise SystemExit(f"expected one same-team admin insertion point, found {api.count(target)}") + api = api.replace(target, replacement, 1) + ast.parse(api) + api_path.write_text(api) + + contract_path = Path("tests/test_ingestion_transaction_contracts.py") + contracts = contract_path.read_text() + + def add_explicit_enrichment(function_name: str, source: str) -> str: + start = source.index(f"def {function_name}") + next_def = source.find("\ndef ", start + 4) + end = len(source) if next_def == -1 else next_def + section = source[start:end] + needle = """ str(uuid.uuid4()), + summary, + )""" + if section.count(needle) != 1: + raise SystemExit( + f"expected one persist call in {function_name}, found {section.count(needle)}" + ) + replacement_call = """ str(uuid.uuid4()), + summary, + allow_catalog_enrichment=True, + )""" + section = section.replace(needle, replacement_call, 1) + return source[:start] + section + source[end:] + + contracts = add_explicit_enrichment( + "test_post_summary_replacement_mentions_and_edges_share_one_transaction", + contracts, + ) + contracts = add_explicit_enrichment( + "test_organization_enrichment_finishes_before_summary_transaction", + contracts, + ) + ast.parse(contracts) + contract_path.write_text(contracts) PY - name: Verify repaired test source shell: bash run: | python -m py_compile backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py - grep -n "test_post_read_summary_materialization_cannot_mutate_shared_catalogs" backend/tests/test_api.py - grep -n "allow_catalog_enrichment=True" tests/test_ingestion_transaction_contracts.py + grep -n '_grant_post_admin(seeded_db\["dsn"\])' backend/tests/test_api.py + test "$(grep -c 'allow_catalog_enrichment=True' tests/test_ingestion_transaction_contracts.py)" -ge 2 - - name: Commit regressions and remove temporary patcher + - name: Commit contracts and remove temporary patcher shell: bash run: | set -euo pipefail @@ -46,5 +96,5 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py .github/workflows/patch-summary-auth-integration-1078.yml - git commit -m 'test(security): prove summary catalog authorization (#1078)' + git commit -m 'test(security): make summary catalog authority explicit (#1078)' git push origin HEAD:fix/summary-catalog-authorization-1078 From efe63f4f91d4c1528e494be3ae8a44dffd3ebd6f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 05:54:51 +0900 Subject: [PATCH 19/34] ci: make #1078 patcher yaml-safe --- .../patch-summary-auth-integration-1078.yml | 39 ++++--------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml index 0072828cc..b62b90a76 100644 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ b/.github/workflows/patch-summary-auth-integration-1078.yml @@ -26,55 +26,30 @@ jobs: python - <<'PY' from pathlib import Path import ast - api_path = Path("backend/tests/test_api.py") api = api_path.read_text() - target = """ from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - - class _FakeSummaryClient: -""" - replacement = """ from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility - - _grant_post_admin(seeded_db["dsn"]) - - class _FakeSummaryClient: -""" + target = " from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n class _FakeSummaryClient:\n" + replacement = " from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n _grant_post_admin(seeded_db[\"dsn\"])\n\n class _FakeSummaryClient:\n" if api.count(target) != 1: raise SystemExit(f"expected one same-team admin insertion point, found {api.count(target)}") api = api.replace(target, replacement, 1) ast.parse(api) api_path.write_text(api) - contract_path = Path("tests/test_ingestion_transaction_contracts.py") contracts = contract_path.read_text() - def add_explicit_enrichment(function_name: str, source: str) -> str: start = source.index(f"def {function_name}") next_def = source.find("\ndef ", start + 4) end = len(source) if next_def == -1 else next_def section = source[start:end] - needle = """ str(uuid.uuid4()), - summary, - )""" + needle = " str(uuid.uuid4()),\n summary,\n )" if section.count(needle) != 1: - raise SystemExit( - f"expected one persist call in {function_name}, found {section.count(needle)}" - ) - replacement_call = """ str(uuid.uuid4()), - summary, - allow_catalog_enrichment=True, - )""" + raise SystemExit(f"expected one persist call in {function_name}, found {section.count(needle)}") + replacement_call = " str(uuid.uuid4()),\n summary,\n allow_catalog_enrichment=True,\n )" section = section.replace(needle, replacement_call, 1) return source[:start] + section + source[end:] - - contracts = add_explicit_enrichment( - "test_post_summary_replacement_mentions_and_edges_share_one_transaction", - contracts, - ) - contracts = add_explicit_enrichment( - "test_organization_enrichment_finishes_before_summary_transaction", - contracts, - ) + contracts = add_explicit_enrichment("test_post_summary_replacement_mentions_and_edges_share_one_transaction", contracts) + contracts = add_explicit_enrichment("test_organization_enrichment_finishes_before_summary_transaction", contracts) ast.parse(contracts) contract_path.write_text(contracts) PY From a03cb455d0639a40fefbd478e8beed36906c45ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 05:56:08 +0900 Subject: [PATCH 20/34] test(security): add authenticated summary catalog boundary proof --- .../test_summary_catalog_authorization_api.py | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 backend/tests/test_summary_catalog_authorization_api.py diff --git a/backend/tests/test_summary_catalog_authorization_api.py b/backend/tests/test_summary_catalog_authorization_api.py new file mode 100644 index 000000000..b7b93bb17 --- /dev/null +++ b/backend/tests/test_summary_catalog_authorization_api.py @@ -0,0 +1,187 @@ +"""Authenticated integration proof for summary shared-catalog authorization.""" + +from __future__ import annotations + +import psycopg2 + +from backend.tests.test_api import ( + _grant_post_admin, + client, + demo_analyst_token, + seeded_db, +) +from lineageweave.corporate_hierarchy_inference import HierarchyProposal +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + ACTOR_TYPE_TEAM, + PostSummary, + RoleResponsibility, +) +from lineageweave.relation_verification import ( + STATUS_CORROBORATED, + RelationVerificationResult, +) + + +def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + """post_read may materialize a summary but must not write shared master data.""" + reader_org = "Reader Summary Never Create Corp" + reader_team = "Reader Summary Never Create Team" + admin_org = "Admin Summary Create Corp" + admin_team = "Admin Summary Create Team" + + class _FakeSummaryClient: + available = True + + def summarize(self, post_title: str, post_body: str) -> PostSummary: + if post_title.startswith("Reader summary auth"): + organization_name, team_name = reader_org, reader_team + else: + organization_name, team_name = admin_org, admin_team + return PostSummary( + korean_summary="권한 경계를 검증하는 합성 요약입니다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name=organization_name, + responsibility="조직 역할", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + RoleResponsibility( + actor_name=team_name, + responsibility="팀 역할", + actor_type_code=ACTOR_TYPE_TEAM, + affiliated_organization_name=organization_name, + ), + ), + ) + + hierarchy_factory_calls = 0 + verification_factory_calls = 0 + + class _FakeHierarchyInferenceClient: + available = True + + def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: + return HierarchyProposal(level_code="company", parent_name=None) + + class _FakeVerificationClient: + available = True + + def verify( + self, organization_name: str, relationship_label: str + ) -> RelationVerificationResult: + return RelationVerificationResult( + status_code=STATUS_CORROBORATED, + evidence_url=f"https://example.org/{organization_name.replace(' ', '-')}", + ) + + def hierarchy_factory(): + nonlocal hierarchy_factory_calls + hierarchy_factory_calls += 1 + return _FakeHierarchyInferenceClient() + + def verification_factory(): + nonlocal verification_factory_calls + verification_factory_calls += 1 + return _FakeVerificationClient() + + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: _FakeSummaryClient()) + monkeypatch.setattr( + "backend.app.main._corporate_hierarchy_inference_client", hierarchy_factory + ) + monkeypatch.setattr( + "backend.app.main._relation_verification_client", verification_factory + ) + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + post_ids = [] + for title in ("Reader summary auth", "Admin summary auth"): + cur.execute( + "insert into source_post " + "(author_account_id, corporate_entity_id, post_title, post_body, " + "voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + ( + title, + "authorization evidence body", + seeded_db["own_private_post_id"], + ), + ) + post_ids.append(str(cur.fetchone()[0])) + for organization_name in (reader_org, admin_org): + cur.execute( + "select count(*) from corporate_entity where entity_name = %s", + (organization_name,), + ) + assert cur.fetchone()[0] == 0 + for team_name, organization_name in ( + (reader_team, reader_org), + (admin_team, admin_org), + ): + cur.execute( + "select count(*) from cataloged_team " + "where team_name = %s and affiliated_organization_name = %s", + (team_name, organization_name), + ) + assert cur.fetchone()[0] == 0 + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + reader = client.get(f"/api/posts/{post_ids[0]}/summary", headers=headers) + assert reader.status_code == 200, reader.text + assert hierarchy_factory_calls == 0 + assert verification_factory_calls == 0 + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "select count(*) from corporate_entity where entity_name = %s", + (reader_org,), + ) + assert cur.fetchone()[0] == 0 + cur.execute( + "select count(*) from cataloged_team " + "where team_name = %s and affiliated_organization_name = %s", + (reader_team, reader_org), + ) + assert cur.fetchone()[0] == 0 + cur.execute( + "select cataloged_corporate_entity_id, cataloged_team_id " + "from post_summary_role where post_id = %s order by role_ordinal", + (post_ids[0],), + ) + assert cur.fetchall() == [(None, None), (None, None)] + finally: + admin_conn.close() + + _grant_post_admin(seeded_db["dsn"]) + admin = client.get(f"/api/posts/{post_ids[1]}/summary", headers=headers) + assert admin.status_code == 200, admin.text + assert hierarchy_factory_calls > 0 + assert verification_factory_calls > 0 + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + try: + with admin_conn.cursor() as cur: + cur.execute( + "select count(*) from corporate_entity where entity_name = %s", + (admin_org,), + ) + assert cur.fetchone()[0] == 1 + cur.execute( + "select count(*) from cataloged_team " + "where team_name = %s and affiliated_organization_name = %s", + (admin_team, admin_org), + ) + assert cur.fetchone()[0] == 1 + finally: + admin_conn.close() From d26ba41041a60e0c91f15d0050f1f535cb09443a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 05:56:55 +0900 Subject: [PATCH 21/34] test(security): keep imported API fixtures lint-clean --- .../tests/test_summary_catalog_authorization_api.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_summary_catalog_authorization_api.py b/backend/tests/test_summary_catalog_authorization_api.py index b7b93bb17..4fcbcbf3f 100644 --- a/backend/tests/test_summary_catalog_authorization_api.py +++ b/backend/tests/test_summary_catalog_authorization_api.py @@ -4,12 +4,7 @@ import psycopg2 -from backend.tests.test_api import ( - _grant_post_admin, - client, - demo_analyst_token, - seeded_db, -) +from backend.tests import test_api as api_test from lineageweave.corporate_hierarchy_inference import HierarchyProposal from lineageweave.post_summary import ( ACTOR_TYPE_ORGANIZATION, @@ -22,6 +17,10 @@ RelationVerificationResult, ) +client = api_test.client +demo_analyst_token = api_test.demo_analyst_token +seeded_db = api_test.seeded_db + def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( client, demo_analyst_token, seeded_db, monkeypatch @@ -163,7 +162,7 @@ def verification_factory(): finally: admin_conn.close() - _grant_post_admin(seeded_db["dsn"]) + api_test._grant_post_admin(seeded_db["dsn"]) admin = client.get(f"/api/posts/{post_ids[1]}/summary", headers=headers) assert admin.status_code == 200, admin.text assert hierarchy_factory_calls > 0 From 34ef53d653cd399b7ab329261e357f6112623063 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:18:07 +0000 Subject: [PATCH 22/34] test(security): make summary catalog authority explicit (#1078) --- .../patch-summary-auth-integration-1078.yml | 75 ------------------- backend/tests/test_api.py | 2 + tests/test_ingestion_transaction_contracts.py | 2 + 3 files changed, 4 insertions(+), 75 deletions(-) delete mode 100644 .github/workflows/patch-summary-auth-integration-1078.yml diff --git a/.github/workflows/patch-summary-auth-integration-1078.yml b/.github/workflows/patch-summary-auth-integration-1078.yml deleted file mode 100644 index b62b90a76..000000000 --- a/.github/workflows/patch-summary-auth-integration-1078.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Patch summary authorization integration regression 1078 - -on: - push: - branches: - - fix/summary-catalog-authorization-1078 - -permissions: - contents: write - -jobs: - patch: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout exact triggering head - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: true - - - name: Repair stale authorization contracts - shell: bash - run: | - python - <<'PY' - from pathlib import Path - import ast - api_path = Path("backend/tests/test_api.py") - api = api_path.read_text() - target = " from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n class _FakeSummaryClient:\n" - replacement = " from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility\n\n _grant_post_admin(seeded_db[\"dsn\"])\n\n class _FakeSummaryClient:\n" - if api.count(target) != 1: - raise SystemExit(f"expected one same-team admin insertion point, found {api.count(target)}") - api = api.replace(target, replacement, 1) - ast.parse(api) - api_path.write_text(api) - contract_path = Path("tests/test_ingestion_transaction_contracts.py") - contracts = contract_path.read_text() - def add_explicit_enrichment(function_name: str, source: str) -> str: - start = source.index(f"def {function_name}") - next_def = source.find("\ndef ", start + 4) - end = len(source) if next_def == -1 else next_def - section = source[start:end] - needle = " str(uuid.uuid4()),\n summary,\n )" - if section.count(needle) != 1: - raise SystemExit(f"expected one persist call in {function_name}, found {section.count(needle)}") - replacement_call = " str(uuid.uuid4()),\n summary,\n allow_catalog_enrichment=True,\n )" - section = section.replace(needle, replacement_call, 1) - return source[:start] + section + source[end:] - contracts = add_explicit_enrichment("test_post_summary_replacement_mentions_and_edges_share_one_transaction", contracts) - contracts = add_explicit_enrichment("test_organization_enrichment_finishes_before_summary_transaction", contracts) - ast.parse(contracts) - contract_path.write_text(contracts) - PY - - - name: Verify repaired test source - shell: bash - run: | - python -m py_compile backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py - grep -n '_grant_post_admin(seeded_db\["dsn"\])' backend/tests/test_api.py - test "$(grep -c 'allow_catalog_enrichment=True' tests/test_ingestion_transaction_contracts.py)" -ge 2 - - - name: Commit contracts and remove temporary patcher - shell: bash - run: | - set -euo pipefail - remote_head="$(git ls-remote origin refs/heads/fix/summary-catalog-authorization-1078 | cut -f1)" - test "$remote_head" = "$GITHUB_SHA" - rm .github/workflows/patch-summary-auth-integration-1078.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add backend/tests/test_api.py tests/test_ingestion_transaction_contracts.py .github/workflows/patch-summary-auth-integration-1078.yml - git commit -m 'test(security): make summary catalog authority explicit (#1078)' - git push origin HEAD:fix/summary-catalog-authorization-1078 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 520277031..55bc58d46 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -3183,6 +3183,8 @@ def test_same_team_named_in_two_posts_resolves_to_one_cataloged_team( """ from lineageweave.post_summary import ACTOR_TYPE_TEAM, PostSummary, RoleResponsibility + _grant_post_admin(seeded_db["dsn"]) + class _FakeSummaryClient: available = True diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index a9b8e7557..c86883199 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -281,6 +281,7 @@ async def persist_edges(conn, post_id) -> list[Any]: connection, str(uuid.uuid4()), summary, + allow_catalog_enrichment=True, ) ) @@ -359,6 +360,7 @@ async def persist_edges(conn, post_id) -> list[Any]: connection, str(uuid.uuid4()), summary, + allow_catalog_enrichment=True, ) ) From e463aba8bf37d36ed0d6041a556c4d66fba90025 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 09:51:59 +0900 Subject: [PATCH 23/34] test(security): fix summary role catalog assertion --- backend/tests/test_summary_catalog_authorization_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/test_summary_catalog_authorization_api.py b/backend/tests/test_summary_catalog_authorization_api.py index 4fcbcbf3f..df7f0ff1f 100644 --- a/backend/tests/test_summary_catalog_authorization_api.py +++ b/backend/tests/test_summary_catalog_authorization_api.py @@ -155,7 +155,7 @@ def verification_factory(): assert cur.fetchone()[0] == 0 cur.execute( "select cataloged_corporate_entity_id, cataloged_team_id " - "from post_summary_role where post_id = %s order by role_ordinal", + "from post_summary_role where post_id = %s", (post_ids[0],), ) assert cur.fetchall() == [(None, None), (None, None)] From 4af4a6d6862b1c9002eb4b7c1210da85d01048f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 10:49:13 +0900 Subject: [PATCH 24/34] test(security): preserve live-stack skip contract --- backend/tests/test_summary_catalog_authorization_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/test_summary_catalog_authorization_api.py b/backend/tests/test_summary_catalog_authorization_api.py index df7f0ff1f..af814592e 100644 --- a/backend/tests/test_summary_catalog_authorization_api.py +++ b/backend/tests/test_summary_catalog_authorization_api.py @@ -20,6 +20,7 @@ client = api_test.client demo_analyst_token = api_test.demo_analyst_token seeded_db = api_test.seeded_db +pytestmark = api_test.pytestmark def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( From a3c589d55d4745397664e90e437abea5728a856f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 10:49:48 +0900 Subject: [PATCH 25/34] ci(security): execute summary auth regression on live stack --- .github/workflows/tests.yml | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a73249f6..c55f277b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,6 +63,64 @@ jobs: - name: Run full test suite against PostgreSQL run: uv run --frozen python -m pytest -q + summary-authorization-integration: + name: Summary authorization integration + if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Set up locked dependency manager + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: "0.11.28" + enable-cache: false + + - name: Select pinned Rust toolchain + run: | + rustup toolchain install 1.97.1 --profile minimal + rustup default 1.97.1 + + - name: Install the committed universal lock + run: uv sync --frozen --extra dev --extra backend + + - name: Start synthetic identity and data services + run: docker compose up -d --build postgres valkey keycloak + + - name: Wait for the synthetic integration stack + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 60); do + if curl --fail --silent --show-error \ + http://localhost:18080/realms/lineageweave-demo/.well-known/openid-configuration \ + >/dev/null \ + && test "$(docker compose exec -T valkey valkey-cli ping)" = "PONG"; then + exit 0 + fi + sleep 2 + done + docker compose ps + docker compose logs --no-color postgres valkey keycloak + exit 1 + + - name: Run authenticated summary authorization regression + run: >- + uv run --frozen python -m pytest -q + backend/tests/test_summary_catalog_authorization_api.py + + - name: Stop synthetic integration stack + if: always() + run: docker compose down -v --remove-orphans + frontend: name: Frontend lint, test, build if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) From 949c3ec756e389b987bd69bb5f10dca28eb5d425 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:47:24 +0900 Subject: [PATCH 26/34] test(summary): document authorization contracts --- tests/test_summary_catalog_authorization.py | 77 +++++++++++++++++---- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/tests/test_summary_catalog_authorization.py b/tests/test_summary_catalog_authorization.py index c74a2764d..4fd29763a 100644 --- a/tests/test_summary_catalog_authorization.py +++ b/tests/test_summary_catalog_authorization.py @@ -6,11 +6,12 @@ from types import SimpleNamespace from backend.app import main -from backend.app.auth import CurrentAccount from backend.app import post_summary_ingestion as summary_ingestion +from backend.app.auth import CurrentAccount def _account(*permissions: str) -> CurrentAccount: + """Build a synthetic account with only the permissions under test.""" return CurrentAccount( user_account_id="synthetic-account", external_subject_id="synthetic-subject", @@ -23,62 +24,110 @@ def _account(*permissions: str) -> CurrentAccount: def test_reader_never_constructs_shared_catalog_clients(monkeypatch) -> None: + """Keep mutation-capable catalog clients outside the post_read path.""" captured: dict[str, object] = {} + async def fake_persist(*_args, **kwargs): + """Capture the application boundary arguments without touching storage.""" captured.update(kwargs) return {"post_id": "synthetic-post"} + def forbidden(): + """Fail if a reader attempts to construct a catalog mutation client.""" raise AssertionError("post_read must not construct mutation-capable clients") + monkeypatch.setattr(main, "persist_post_summary", fake_persist) monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", forbidden) monkeypatch.setattr(main, "_relation_verification_client", forbidden) - asyncio.run(main._persist_post_summary_for_account( - object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read") - )) + asyncio.run( + main._persist_post_summary_for_account( + object(), + "synthetic-post", + object(), + post_body="evidence", + account=_account("post_read"), + ) + ) assert captured["allow_catalog_enrichment"] is False assert captured["hierarchy_inference_client"].available is False assert captured["verification_client"].available is False def test_admin_retains_explicit_enrichment_capability(monkeypatch) -> None: + """Permit shared-catalog enrichment only when post_admin is explicit.""" captured: dict[str, object] = {} hierarchy = SimpleNamespace(available=True) verification = SimpleNamespace(available=True) + async def fake_persist(*_args, **kwargs): + """Capture the admin persistence call without writing synthetic state.""" captured.update(kwargs) return {"post_id": "synthetic-post"} + monkeypatch.setattr(main, "persist_post_summary", fake_persist) monkeypatch.setattr(main, "_corporate_hierarchy_inference_client", lambda: hierarchy) monkeypatch.setattr(main, "_relation_verification_client", lambda: verification) - asyncio.run(main._persist_post_summary_for_account( - object(), "synthetic-post", object(), post_body="evidence", account=_account("post_read", "post_admin") - )) + asyncio.run( + main._persist_post_summary_for_account( + object(), + "synthetic-post", + object(), + post_body="evidence", + account=_account("post_read", "post_admin"), + ) + ) assert captured["allow_catalog_enrichment"] is True assert captured["hierarchy_inference_client"] is hierarchy assert captured["verification_client"] is verification def test_reader_team_resolution_is_lookup_only(monkeypatch) -> None: + """Resolve a known team for readers without invoking the upsert owner.""" + class Connection: async def fetchrow(self, query, *args): + """Return the known team only for the parameterized lookup contract.""" assert query.startswith("select team_id from cataloged_team") assert args == ("Synthetic Team", "Synthetic Org") return {"team_id": "known-team"} + async def forbidden_upsert(*_args, **_kwargs): + """Fail if lookup-only reader resolution crosses into catalog writes.""" raise AssertionError("post_read must not upsert cataloged_team") + monkeypatch.setattr(summary_ingestion, "upsert_team", forbidden_upsert) - team_id = asyncio.run(summary_ingestion._resolve_summary_team_id( - Connection(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=False - )) + team_id = asyncio.run( + summary_ingestion._resolve_summary_team_id( + Connection(), + "Synthetic Team", + "Synthetic Org", + [], + allow_catalog_enrichment=False, + ) + ) assert team_id == "known-team" def test_admin_team_resolution_may_upsert(monkeypatch) -> None: + """Retain canonical team upsert behavior behind explicit enrichment.""" + async def fake_upsert(_conn, team_name, affiliation, candidates): - assert (team_name, affiliation, candidates) == ("Synthetic Team", "Synthetic Org", []) + """Return a deterministic team id after checking owner inputs.""" + assert (team_name, affiliation, candidates) == ( + "Synthetic Team", + "Synthetic Org", + [], + ) return "created-team" + monkeypatch.setattr(summary_ingestion, "upsert_team", fake_upsert) - team_id = asyncio.run(summary_ingestion._resolve_summary_team_id( - object(), "Synthetic Team", "Synthetic Org", [], allow_catalog_enrichment=True - )) + team_id = asyncio.run( + summary_ingestion._resolve_summary_team_id( + object(), + "Synthetic Team", + "Synthetic Org", + [], + allow_catalog_enrichment=True, + ) + ) assert team_id == "created-team" From 0d7b60c098fab43c503b8c60bab2aeb116c0f176 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:48:09 +0900 Subject: [PATCH 27/34] test(summary): snapshot shared catalogs for reader invariance --- .../test_summary_catalog_authorization_api.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/tests/test_summary_catalog_authorization_api.py b/backend/tests/test_summary_catalog_authorization_api.py index af814592e..00c887994 100644 --- a/backend/tests/test_summary_catalog_authorization_api.py +++ b/backend/tests/test_summary_catalog_authorization_api.py @@ -23,6 +23,25 @@ pytestmark = api_test.pytestmark +def _shared_catalog_snapshot(dsn: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Capture complete corporate and team catalog state, including hierarchy links.""" + conn = psycopg2.connect(dsn) + try: + with conn.cursor() as cur: + cur.execute( + "select row_to_json(c)::text from corporate_entity as c " + "order by corporate_entity_id" + ) + corporate_entities = tuple(row[0] for row in cur.fetchall()) + cur.execute( + "select row_to_json(t)::text from cataloged_team as t order by team_id" + ) + cataloged_teams = tuple(row[0] for row in cur.fetchall()) + finally: + conn.close() + return corporate_entities, cataloged_teams + + def test_post_read_summary_materialization_cannot_mutate_shared_catalogs( client, demo_analyst_token, seeded_db, monkeypatch ) -> None: @@ -36,6 +55,7 @@ class _FakeSummaryClient: available = True def summarize(self, post_title: str, post_body: str) -> PostSummary: + """Return distinct reader/admin organizations for authorization assertions.""" if post_title.startswith("Reader summary auth"): organization_name, team_name = reader_org, reader_team else: @@ -64,6 +84,7 @@ class _FakeHierarchyInferenceClient: available = True def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: + """Return a deterministic company-level hierarchy proposal for admin writes.""" return HierarchyProposal(level_code="company", parent_name=None) class _FakeVerificationClient: @@ -72,17 +93,20 @@ class _FakeVerificationClient: def verify( self, organization_name: str, relationship_label: str ) -> RelationVerificationResult: + """Corroborate the synthetic admin organization deterministically.""" return RelationVerificationResult( status_code=STATUS_CORROBORATED, evidence_url=f"https://example.org/{organization_name.replace(' ', '-')}", ) def hierarchy_factory(): + """Count construction of mutation-capable hierarchy clients.""" nonlocal hierarchy_factory_calls hierarchy_factory_calls += 1 return _FakeHierarchyInferenceClient() def verification_factory(): + """Count construction of mutation-capable verification clients.""" nonlocal verification_factory_calls verification_factory_calls += 1 return _FakeVerificationClient() @@ -133,11 +157,13 @@ def verification_factory(): finally: admin_conn.close() + catalog_before_reader = _shared_catalog_snapshot(seeded_db["dsn"]) headers = {"Authorization": f"Bearer {demo_analyst_token}"} reader = client.get(f"/api/posts/{post_ids[0]}/summary", headers=headers) assert reader.status_code == 200, reader.text assert hierarchy_factory_calls == 0 assert verification_factory_calls == 0 + assert _shared_catalog_snapshot(seeded_db["dsn"]) == catalog_before_reader admin_conn = psycopg2.connect(seeded_db["dsn"]) admin_conn.autocommit = True From 9d6e22f15325951c1768785849e0cd948624a094 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:52:38 +0900 Subject: [PATCH 28/34] docs(summary): document backfill authorization boundary --- scripts/backfill_post_summaries.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py index 17e352e0b..3db8fc566 100644 --- a/scripts/backfill_post_summaries.py +++ b/scripts/backfill_post_summaries.py @@ -36,6 +36,7 @@ def _parser() -> argparse.ArgumentParser: + """Build the bounded operator CLI without introducing provider controls.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--target-dsn", @@ -63,6 +64,7 @@ def _gateway_config() -> tuple[str, str]: def _semantic_hints(row: asyncpg.Record) -> str: + """Format persisted source context for CO without inventing missing identity data.""" source_author_name = row["source_author_name"] if source_author_name and source_author_name == row["source_author_code"]: source_author_name = None @@ -83,8 +85,8 @@ def _semantic_hints(row: asyncpg.Record) -> str: source_company_name=row["source_company_name"], source_company_catalog_name=row["source_company_catalog_name"], source_business_unit_code=row["source_process_unit_code"], - source_process_unit_name=row["source_process_unit_name"], - source_process_unit_catalog_name=row["source_process_unit_catalog_name"], + source_business_unit_name=row["source_process_unit_name"], + source_business_unit_catalog_name=row["source_process_unit_catalog_name"], source_sales_pool_code=row["source_sales_pool_code"], source_sales_pool_name=row["source_sales_pool_name"], source_customer_code=row["source_customer_code"], @@ -213,6 +215,7 @@ async def backfill_post_summaries( raw_post_ids: list[str] | None, limit: int | None, ) -> dict[str, object]: + """Backfill post-owned evidence while explicitly denying shared-catalog enrichment.""" post_ids = [str(uuid.UUID(post_id)) for post_id in dict.fromkeys(raw_post_ids or [])] base_url, api_key = _gateway_config() if not base_url or not api_key: @@ -284,6 +287,7 @@ async def backfill_post_summaries( def main() -> None: + """Validate CLI scope and execute one explicit backfill batch.""" args = _parser().parse_args() if args.limit < 1: raise SystemExit("--limit must be positive") From a20ba2ef3f43fb286406574ea93c58f6154d6e38 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:55:05 +0900 Subject: [PATCH 29/34] fix(backfill): preserve semantic hint keyword contract --- scripts/backfill_post_summaries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/backfill_post_summaries.py b/scripts/backfill_post_summaries.py index 3db8fc566..b8fed73da 100644 --- a/scripts/backfill_post_summaries.py +++ b/scripts/backfill_post_summaries.py @@ -85,8 +85,8 @@ def _semantic_hints(row: asyncpg.Record) -> str: source_company_name=row["source_company_name"], source_company_catalog_name=row["source_company_catalog_name"], source_business_unit_code=row["source_process_unit_code"], - source_business_unit_name=row["source_process_unit_name"], - source_business_unit_catalog_name=row["source_process_unit_catalog_name"], + source_process_unit_name=row["source_process_unit_name"], + source_process_unit_catalog_name=row["source_process_unit_catalog_name"], source_sales_pool_code=row["source_sales_pool_code"], source_sales_pool_name=row["source_sales_pool_name"], source_customer_code=row["source_customer_code"], From 84525bc9eba394d6d206caf9a96b343695c6df87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:52:51 +0900 Subject: [PATCH 30/34] test(security): cover summary fallback authorization --- ...mary_catalog_fallback_authorization_api.py | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 backend/tests/test_summary_catalog_fallback_authorization_api.py diff --git a/backend/tests/test_summary_catalog_fallback_authorization_api.py b/backend/tests/test_summary_catalog_fallback_authorization_api.py new file mode 100644 index 000000000..a1330cfcc --- /dev/null +++ b/backend/tests/test_summary_catalog_fallback_authorization_api.py @@ -0,0 +1,130 @@ +"""Authenticated fallback regressions for summary catalog authorization.""" + +from __future__ import annotations + +import psycopg2 +import pytest + +from backend.tests import test_api as api_test + +client = api_test.client +demo_analyst_token = api_test.demo_analyst_token +seeded_db = api_test.seeded_db +pytestmark = api_test.pytestmark + + +class _UnavailableSummaryClient: + """Represent an orchestrator client that cannot serve a summary request.""" + + available = False + + +class _FailingSummaryClient: + """Represent an available orchestrator whose summary call fails before persistence.""" + + available = True + + def summarize(self, post_title: str, post_body: str): + """Raise a deterministic provider error before catalog persistence begins.""" + raise OSError("synthetic orchestrator failure") + + +def _shared_catalog_snapshot(dsn: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Capture complete corporate and team catalog rows for mutation detection.""" + conn = psycopg2.connect(dsn) + try: + with conn.cursor() as cur: + cur.execute( + "select row_to_json(c)::text from corporate_entity as c " + "order by corporate_entity_id" + ) + corporate_entities = tuple(row[0] for row in cur.fetchall()) + cur.execute( + "select row_to_json(t)::text from cataloged_team as t order by team_id" + ) + cataloged_teams = tuple(row[0] for row in cur.fetchall()) + finally: + conn.close() + return corporate_entities, cataloged_teams + + +def _seed_stale_summary(dsn: str, source_post_id: str, *, title: str) -> str: + """Create one visible post with an intentionally obsolete persisted summary.""" + conn = psycopg2.connect(dsn) + conn.autocommit = True + try: + with conn.cursor() as cur: + cur.execute( + "insert into source_post " + "(author_account_id, corporate_entity_id, post_title, post_body, " + "voc_type_code, visibility_code) " + "select author_account_id, corporate_entity_id, %s, %s, 'voc', 'public' " + "from source_post where post_id = %s returning post_id", + (title, "fallback authorization evidence body", source_post_id), + ) + post_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_summary_result " + "(post_id, korean_summary, summary_contract_version) values (%s, %s, %s)", + (post_id, "오래된 요약 증거", "legacy-fallback-contract"), + ) + finally: + conn.close() + return post_id + + +@pytest.mark.parametrize("grant_admin", [False, True], ids=["reader", "admin"]) +@pytest.mark.parametrize("provider_state", ["unavailable", "failure"]) +def test_stale_summary_fallback_never_mutates_shared_catalogs( + client, + demo_analyst_token, + seeded_db, + monkeypatch, + grant_admin: bool, + provider_state: str, +) -> None: + """Stale continuity must not run shared-catalog enrichment after provider failure.""" + if grant_admin: + api_test._grant_post_admin(seeded_db["dsn"]) + + post_id = _seed_stale_summary( + seeded_db["dsn"], + seeded_db["own_private_post_id"], + title=f"Fallback auth {provider_state} admin={grant_admin}", + ) + before = _shared_catalog_snapshot(seeded_db["dsn"]) + + summary_client = ( + _UnavailableSummaryClient() + if provider_state == "unavailable" + else _FailingSummaryClient() + ) + monkeypatch.setattr("backend.app.main._post_summary_client", lambda: summary_client) + + def forbidden_hierarchy_factory(): + """Fail if a fallback path tries to construct a catalog-mutation capability.""" + raise AssertionError("fallback must not construct hierarchy inference") + + def forbidden_verification_factory(): + """Fail if a fallback path tries to construct a relation-verification capability.""" + raise AssertionError("fallback must not construct relation verification") + + monkeypatch.setattr( + "backend.app.main._corporate_hierarchy_inference_client", + forbidden_hierarchy_factory, + ) + monkeypatch.setattr( + "backend.app.main._relation_verification_client", + forbidden_verification_factory, + ) + + response = client.get( + f"/api/posts/{post_id}/summary", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["summary_status"] == "stale" + assert payload["korean_summary"] == "오래된 요약 증거" + assert _shared_catalog_snapshot(seeded_db["dsn"]) == before From 7c0df2db5d9b64008e6b7addba27fb3fb0a071cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:53:13 +0900 Subject: [PATCH 31/34] ci(test): execute summary fallback authorization regressions --- .github/workflows/tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c55f277b3..35187377e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,10 +112,11 @@ jobs: docker compose logs --no-color postgres valkey keycloak exit 1 - - name: Run authenticated summary authorization regression + - name: Run authenticated summary authorization regressions run: >- uv run --frozen python -m pytest -q backend/tests/test_summary_catalog_authorization_api.py + backend/tests/test_summary_catalog_fallback_authorization_api.py - name: Stop synthetic integration stack if: always() From d7bc66c6cc21ac9046078b78369b9c388e7c8b7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:56:24 +0900 Subject: [PATCH 32/34] fix(test): use numeric stale summary contract --- .../test_summary_catalog_fallback_authorization_api.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_summary_catalog_fallback_authorization_api.py b/backend/tests/test_summary_catalog_fallback_authorization_api.py index a1330cfcc..cd9efc118 100644 --- a/backend/tests/test_summary_catalog_fallback_authorization_api.py +++ b/backend/tests/test_summary_catalog_fallback_authorization_api.py @@ -6,6 +6,7 @@ import pytest from backend.tests import test_api as api_test +from lineageweave.post_summary import POST_SUMMARY_CONTRACT_VERSION client = api_test.client demo_analyst_token = api_test.demo_analyst_token @@ -24,7 +25,7 @@ class _FailingSummaryClient: available = True - def summarize(self, post_title: str, post_body: str): + def summarize(self, _post_title: str, _post_body: str) -> None: """Raise a deterministic provider error before catalog persistence begins.""" raise OSError("synthetic orchestrator failure") @@ -66,7 +67,7 @@ def _seed_stale_summary(dsn: str, source_post_id: str, *, title: str) -> str: cur.execute( "insert into post_summary_result " "(post_id, korean_summary, summary_contract_version) values (%s, %s, %s)", - (post_id, "오래된 요약 증거", "legacy-fallback-contract"), + (post_id, "오래된 요약 증거", POST_SUMMARY_CONTRACT_VERSION - 1), ) finally: conn.close() @@ -126,5 +127,6 @@ def forbidden_verification_factory(): assert response.status_code == 200, response.text payload = response.json() assert payload["summary_status"] == "stale" + assert payload["summary_contract_version"] == POST_SUMMARY_CONTRACT_VERSION - 1 assert payload["korean_summary"] == "오래된 요약 증거" assert _shared_catalog_snapshot(seeded_db["dsn"]) == before From 07e63b68b49cd443bb6718fbcad1cb1942173003 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 15:54:17 +0900 Subject: [PATCH 33/34] fix(ci): isolate summary acceptance cleanup from private env --- .github/workflows/tests.yml | 53 ++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 35187377e..5d3168e7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,6 +67,8 @@ jobs: name: Summary authorization integration if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) runs-on: ubuntu-latest + env: + COMPOSE_PROJECT_NAME: summary-auth-${{ github.run_id }} steps: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # actions/checkout@v7 @@ -100,16 +102,36 @@ jobs: run: | set -euo pipefail for attempt in $(seq 1 60); do + valkey_container_id="$( + docker ps -q \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" \ + --filter "label=com.docker.compose.service=valkey" \ + | head -n 1 + )" if curl --fail --silent --show-error \ http://localhost:18080/realms/lineageweave-demo/.well-known/openid-configuration \ >/dev/null \ - && test "$(docker compose exec -T valkey valkey-cli ping)" = "PONG"; then + && test -n "${valkey_container_id}" \ + && test "$(docker exec "${valkey_container_id}" valkey-cli ping)" = "PONG"; then exit 0 fi sleep 2 done - docker compose ps - docker compose logs --no-color postgres valkey keycloak + docker ps -a \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" + for service in postgres valkey keycloak; do + container_id="$( + docker ps -aq \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" \ + --filter "label=com.docker.compose.service=${service}" \ + | head -n 1 + )" + if test -n "${container_id}"; then + echo "::group::${service} logs" + docker logs "${container_id}" || true + echo "::endgroup::" + fi + done exit 1 - name: Run authenticated summary authorization regressions @@ -120,7 +142,30 @@ jobs: - name: Stop synthetic integration stack if: always() - run: docker compose down -v --remove-orphans + shell: bash + run: | + set -euo pipefail + mapfile -t container_ids < <( + docker ps -aq \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" + ) + if ((${#container_ids[@]})); then + docker rm -f "${container_ids[@]}" + fi + mapfile -t volume_names < <( + docker volume ls -q \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" + ) + if ((${#volume_names[@]})); then + docker volume rm "${volume_names[@]}" + fi + mapfile -t network_ids < <( + docker network ls -q \ + --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}" + ) + if ((${#network_ids[@]})); then + docker network rm "${network_ids[@]}" + fi frontend: name: Frontend lint, test, build From c2923950e73c88a9f9fd932332ddd47682da124b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 15:54:31 +0900 Subject: [PATCH 34/34] test(ci): pin summary acceptance compose isolation --- tests/test_tests_workflow_contract.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/test_tests_workflow_contract.py b/tests/test_tests_workflow_contract.py index b0953f6f9..e2476c505 100644 --- a/tests/test_tests_workflow_contract.py +++ b/tests/test_tests_workflow_contract.py @@ -25,7 +25,7 @@ def test_pull_request_concurrency_survives_closed_ref_change() -> None: workflow = (_WORKFLOW_DIRECTORY / "tests.yml").read_text(encoding="utf-8") assert _PULL_REQUEST_TYPES in workflow - assert workflow.count("github.event.action != 'closed'") == 2 + assert workflow.count("github.event.action != 'closed'") == 3 def test_pull_request_workflows_cancel_only_superseded_same_pr_runs() -> None: @@ -43,7 +43,7 @@ def test_draft_pull_requests_do_not_consume_repository_local_runners() -> None: """Cancel stale draft runs while deferring expensive jobs until review readiness.""" expected_draft_guards = { - "tests.yml": 2, + "tests.yml": 3, "prov-o-contract.yml": 1, "ontology-pages.yml": 1, } @@ -53,6 +53,23 @@ def test_draft_pull_requests_do_not_consume_repository_local_runners() -> None: assert workflow.count(_DRAFT_ADMISSION) == expected_guard_count, workflow_name +def test_summary_authorization_job_avoids_unrelated_compose_env_file() -> None: + """Keep three-service acceptance independent of the orchestrator private env.""" + + workflow = (_WORKFLOW_DIRECTORY / "tests.yml").read_text(encoding="utf-8") + summary_job = workflow.split(" summary-authorization-integration:\n", 1)[1] + summary_job = summary_job.split("\n frontend:\n", 1)[0] + + assert "COMPOSE_PROJECT_NAME: summary-auth-${{ github.run_id }}" in summary_job + assert "docker compose up -d --build postgres valkey keycloak" in summary_job + assert 'label=com.docker.compose.project=${COMPOSE_PROJECT_NAME}' in summary_job + assert "label=com.docker.compose.service=valkey" in summary_job + assert "docker compose exec" not in summary_job + assert "docker compose ps" not in summary_job + assert "docker compose logs" not in summary_job + assert "docker compose down" not in summary_job + + def test_ontology_publication_runs_are_not_cancelled() -> None: """Keep publication runs isolated and non-cancelling outside pull requests."""