Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3715eec
chore: stage bounded #1078 security repair
seonghobae Sep 13, 2026
ba79eb1
ci: repair scoped #1078 verification harness
seonghobae Sep 13, 2026
611f388
ci: fix #1078 harness workflow syntax
seonghobae Sep 13, 2026
4194d3b
ci: remove completed #1078 harness writer
seonghobae Sep 13, 2026
a111d19
ci: remove purpose-complete #1078 source writer
seonghobae Sep 13, 2026
1aacc1c
ci: execute bounded #1078 authorization repair
seonghobae Sep 13, 2026
7530a57
ci: fix #1078 executor workflow expression
seonghobae Sep 13, 2026
fe4e5f9
fix(security): gate summary catalog enrichment (#1078)
github-actions[bot] Sep 13, 2026
789c384
ci: remove purpose-complete #1078 executor
seonghobae Sep 13, 2026
ffa9c39
ci: stage bounded #1078 API regression patch
seonghobae Sep 13, 2026
a06894d
ci: repair #1078 patcher after raw-string RCA
seonghobae Sep 13, 2026
142fbc0
ci: make #1078 patch repair purpose-complete
seonghobae Sep 13, 2026
16fbed6
ci: retire inert #1078 repair helper
seonghobae Sep 13, 2026
13fabba
ci: repair #1078 patch workflow
seonghobae Sep 13, 2026
e774a88
ci: fix #1078 workflow expression syntax
seonghobae Sep 13, 2026
b87c474
ci: encode #1078 bounded patch safely
seonghobae Sep 13, 2026
6ec5041
ci: repair #1078 patcher exact-head contract
seonghobae Sep 13, 2026
a8acfe2
ci: simplify #1078 bounded contract repair
seonghobae Sep 13, 2026
efe63f4
ci: make #1078 patcher yaml-safe
seonghobae Sep 13, 2026
a03cb45
test(security): add authenticated summary catalog boundary proof
seonghobae Sep 13, 2026
d26ba41
test(security): keep imported API fixtures lint-clean
seonghobae Sep 13, 2026
34ef53d
test(security): make summary catalog authority explicit (#1078)
github-actions[bot] Sep 13, 2026
e463aba
test(security): fix summary role catalog assertion
seonghobae Sep 14, 2026
4af4a6d
test(security): preserve live-stack skip contract
seonghobae Sep 14, 2026
a3c589d
ci(security): execute summary auth regression on live stack
seonghobae Sep 14, 2026
949c3ec
test(summary): document authorization contracts
seonghobae Sep 14, 2026
0d7b60c
test(summary): snapshot shared catalogs for reader invariance
seonghobae Sep 14, 2026
9d6e22f
docs(summary): document backfill authorization boundary
seonghobae Sep 14, 2026
a20ba2e
fix(backfill): preserve semantic hint keyword contract
seonghobae Sep 14, 2026
84525bc
test(security): cover summary fallback authorization
seonghobae Sep 14, 2026
7c0df2d
ci(test): execute summary fallback authorization regressions
seonghobae Sep 14, 2026
d7bc66c
fix(test): use numeric stale summary contract
seonghobae Sep 14, 2026
07e63b6
fix(ci): isolate summary acceptance cleanup from private env
seonghobae Sep 14, 2026
c292395
test(ci): pin summary acceptance compose isolation
seonghobae Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
3 changes: 3 additions & 0 deletions CHANGELOG.d/2.28.1-summary-catalog-authorization.md
Original file line number Diff line number Diff line change
@@ -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).
36 changes: 33 additions & 3 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 35 additions & 5 deletions backend/app/post_summary_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading