diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a73249f6..5d3168e7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,6 +63,110 @@ 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 + env: + COMPOSE_PROJECT_NAME: summary-auth-${{ github.run_id }} + 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 + 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 -n "${valkey_container_id}" \ + && test "$(docker exec "${valkey_container_id}" valkey-cli ping)" = "PONG"; then + exit 0 + fi + sleep 2 + done + 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 + 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() + 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 if: github.event_name != 'pull_request' || (github.event.action != 'closed' && github.event.pull_request.draft == false) 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/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/backend/tests/test_summary_catalog_authorization_api.py b/backend/tests/test_summary_catalog_authorization_api.py new file mode 100644 index 000000000..00c887994 --- /dev/null +++ b/backend/tests/test_summary_catalog_authorization_api.py @@ -0,0 +1,213 @@ +"""Authenticated integration proof for summary shared-catalog authorization.""" + +from __future__ import annotations + +import psycopg2 + +from backend.tests import test_api as api_test +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, +) + +client = api_test.client +demo_analyst_token = api_test.demo_analyst_token +seeded_db = api_test.seeded_db +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: + """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: + """Return distinct reader/admin organizations for authorization assertions.""" + 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 a deterministic company-level hierarchy proposal for admin writes.""" + return HierarchyProposal(level_code="company", parent_name=None) + + class _FakeVerificationClient: + available = True + + 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() + + 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() + + 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 + 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", + (post_ids[0],), + ) + assert cur.fetchall() == [(None, None), (None, None)] + finally: + admin_conn.close() + + 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 + 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() 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..cd9efc118 --- /dev/null +++ b/backend/tests/test_summary_catalog_fallback_authorization_api.py @@ -0,0 +1,132 @@ +"""Authenticated fallback regressions for summary catalog authorization.""" + +from __future__ import annotations + +import psycopg2 +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 +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) -> None: + """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, "오래된 요약 증거", POST_SUMMARY_CONTRACT_VERSION - 1), + ) + 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["summary_contract_version"] == POST_SUMMARY_CONTRACT_VERSION - 1 + assert payload["korean_summary"] == "오래된 요약 증거" + assert _shared_catalog_snapshot(seeded_db["dsn"]) == before 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..b8fed73da 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 @@ -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: @@ -268,6 +271,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) @@ -283,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") 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, ) ) diff --git a/tests/test_summary_catalog_authorization.py b/tests/test_summary_catalog_authorization.py new file mode 100644 index 000000000..4fd29763a --- /dev/null +++ b/tests/test_summary_catalog_authorization.py @@ -0,0 +1,133 @@ +"""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 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", + 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: + """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"), + ) + ) + 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"), + ) + ) + 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, + ) + ) + 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): + """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, + ) + ) + assert team_id == "created-team" 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."""