diff --git a/CHANGELOG.d/2.12.7-keyverse-account-scope.md b/CHANGELOG.d/2.12.7-keyverse-account-scope.md new file mode 100644 index 000000000..d9ed8b0fe --- /dev/null +++ b/CHANGELOG.d/2.12.7-keyverse-account-scope.md @@ -0,0 +1,4 @@ +### Security + +- Bind production Keyverse account-derived organization, workspace, and role + claims to one provisioned local authorization scope before ABAC or RBAC. diff --git a/backend/app/auth.py b/backend/app/auth.py index 155974d52..6b36f9044 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -113,6 +113,7 @@ class CurrentAccount: display_name: str preferred_locale: str | None corporate_entity_ids: frozenset[str] + process_unit_ids: frozenset[str] permission_codes: frozenset[str] def has_permission(self, permission_code: str) -> bool: @@ -130,6 +131,7 @@ def _decode_access_token(token: str, settings: Settings) -> dict: issuer=settings.oidc_issuer, audience=settings.oidc_audience, leeway=settings.oidc_clock_skew_seconds, + options={"require": ["exp", "iat", "sub"]}, ) except HTTPException: raise @@ -141,6 +143,33 @@ def _decode_access_token(token: str, settings: Settings) -> dict: return claims +def _keyverse_account_claims(claims: dict) -> tuple[str, str, list[str]]: + """Return Keyverse's atomic account scope, rejecting ambiguous wire shapes.""" + organization = claims.get("org") + workspace = claims.get("workspace") + roles = claims.get("role") + valid_roles = ( + isinstance(roles, list) + and bool(roles) + and all(isinstance(role, str) and role.strip() for role in roles) + and len({role.strip() for role in roles}) == len(roles) + ) + if not ( + isinstance(organization, str) + and organization.strip() + and organization == organization.strip() + and isinstance(workspace, str) + and workspace.strip() + and workspace == workspace.strip() + and valid_roles + ): + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + "Keyverse token must contain one org, one workspace, and unique roles", + ) + return organization, workspace, [role.strip() for role in roles] + + async def get_current_account( credentials: HTTPAuthorizationCredentials = Depends(_bearer_scheme), pool: asyncpg.Pool = Depends(get_pool), @@ -149,12 +178,41 @@ async def get_current_account( settings = load_settings() claims = _decode_access_token(credentials.credentials, settings) subject = claims["sub"] + keyverse_scope = ( + _keyverse_account_claims(claims) + if settings.keyverse_claim_binding_required + else None + ) async with pool.acquire() as conn: - account_row = await conn.fetchrow( - "select user_account_id, display_name, preferred_locale from user_account where external_subject_id = $1", - subject, - ) + if keyverse_scope: + organization, workspace, token_roles = keyverse_scope + account_row = await conn.fetchrow( + """ + select account.user_account_id, account.display_name, + account.preferred_locale, affiliation.corporate_entity_id, + affiliation.process_unit_id + from user_account account + join account_affiliation affiliation + on affiliation.user_account_id = account.user_account_id + join corporate_entity entity + on entity.corporate_entity_id = affiliation.corporate_entity_id + join process_unit process + on process.process_unit_id = affiliation.process_unit_id + and process.corporate_entity_id = entity.corporate_entity_id + where account.external_subject_id = $1 + and entity.corporate_entity_code = $2 + and process.process_unit_code = $3 + """, + subject, + organization, + workspace, + ) + else: + account_row = await conn.fetchrow( + "select user_account_id, display_name, preferred_locale from user_account where external_subject_id = $1", + subject, + ) if account_row is None: raise HTTPException( status.HTTP_403_FORBIDDEN, @@ -162,19 +220,38 @@ async def get_current_account( "(run scripts/seed_demo_data.py, or provision the account, first)", ) - entity_rows = await conn.fetch( - "select corporate_entity_id from account_affiliation where user_account_id = $1", - account_row["user_account_id"], - ) - permission_rows = await conn.fetch( - """ - select distinct rp.permission_code - from account_role_assignment ara - join role_permission rp on rp.access_role_id = ara.access_role_id - where ara.user_account_id = $1 - """, - account_row["user_account_id"], - ) + if keyverse_scope: + entity_rows = [{"corporate_entity_id": account_row["corporate_entity_id"]}] + process_rows = [{"process_unit_id": account_row["process_unit_id"]}] + permission_rows = await conn.fetch( + """ + select distinct permission.permission_code + from account_role_assignment assignment + join access_role role + on role.access_role_id = assignment.access_role_id + join role_permission permission + on permission.access_role_id = assignment.access_role_id + where assignment.user_account_id = $1 + and role.role_code = any($2::text[]) + """, + account_row["user_account_id"], + token_roles, + ) + else: + entity_rows = await conn.fetch( + "select corporate_entity_id from account_affiliation where user_account_id = $1", + account_row["user_account_id"], + ) + process_rows = [] + permission_rows = await conn.fetch( + """ + select distinct rp.permission_code + from account_role_assignment ara + join role_permission rp on rp.access_role_id = ara.access_role_id + where ara.user_account_id = $1 + """, + account_row["user_account_id"], + ) return CurrentAccount( user_account_id=str(account_row["user_account_id"]), @@ -182,5 +259,6 @@ async def get_current_account( display_name=account_row["display_name"], preferred_locale=account_row["preferred_locale"], corporate_entity_ids=frozenset(str(row["corporate_entity_id"]) for row in entity_rows), + process_unit_ids=frozenset(str(row["process_unit_id"]) for row in process_rows), permission_codes=frozenset(row["permission_code"] for row in permission_rows), ) diff --git a/backend/app/config.py b/backend/app/config.py index 02dc8dc34..a7619cacd 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -37,6 +37,7 @@ class Settings: oidc_discovery_uri: str oidc_jwks_uri_override: str oidc_clock_skew_seconds: int + keyverse_claim_binding_required: bool # Exact browser origins allowed by CORS. Comma-separated FRONTEND_ORIGINS; # never a wildcard -- the backend only serves the product UI. frontend_origins: list[str] @@ -122,6 +123,7 @@ def load_settings() -> Settings: ) ), oidc_clock_skew_seconds=oidc_clock_skew_seconds, + keyverse_claim_binding_required=bool(keyverse_issuer), frontend_origins=[ origin.strip() for origin in os.environ.get("FRONTEND_ORIGINS", "http://localhost:5173").split(",") diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..8d4c5714a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -414,10 +414,16 @@ def _rankweave_client(): def _can_see_post(account: CurrentAccount, post: asyncpg.Record) -> bool: - """ABAC: public rows are visible; private rows require same-corp affiliation.""" + """ABAC: public rows are visible; private rows require the bound local scope.""" if post["visibility_code"] == "public": return True - return str(post["corporate_entity_id"]) in account.corporate_entity_ids + return ( + str(post["corporate_entity_id"]) in account.corporate_entity_ids + and ( + not account.process_unit_ids + or str(post.get("process_unit_id")) in account.process_unit_ids + ) + ) def _is_synthetic_demo_member(member: dict[str, Any], demo_entity_ids: set[str]) -> bool: @@ -540,7 +546,9 @@ async def _lookup_post_labels(conn: asyncpg.Connection, rows: list[asyncpg.Recor async def _post_filter_options( - conn: asyncpg.Connection, corporate_entity_ids: frozenset[str] + conn: asyncpg.Connection, + corporate_entity_ids: frozenset[str], + process_unit_ids: frozenset[str], ) -> tuple[list[dict[str, str]], list[dict[str, str]]]: """Return every authorized filter value, not only values on the current page.""" visibility_sql = f""" @@ -552,7 +560,9 @@ async def _post_filter_options( on lookup.lookup_category = 'post_visibility' and lookup.lookup_code = post.visibility_code where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($1::text[])) + or (post.corporate_entity_id::text = any($1::text[]) + and (cardinality($2::text[]) = 0 + or post.process_unit_id::text = any($2::text[])))) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ @@ -565,17 +575,19 @@ async def _post_filter_options( on lookup.lookup_category = 'voc_type' and lookup.lookup_code = post.voc_type_code where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($1::text[])) + or (post.corporate_entity_id::text = any($1::text[]) + and (cardinality($2::text[]) = 0 + or post.process_unit_id::text = any($2::text[])))) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. visibility_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - visibility_sql, list(corporate_entity_ids) + visibility_sql, list(corporate_entity_ids), list(process_unit_ids) ) # Safe SQL: both query strings are closed lookup statements; entity ids remain asyncpg parameters. type_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli - type_sql, list(corporate_entity_ids) + type_sql, list(corporate_entity_ids), list(process_unit_ids) ) return ( [{"code": row["code"], "label": row["label"]} for row in type_rows], @@ -711,7 +723,10 @@ async def read_customer_master( from source_post where (nullif(btrim(source_customer_code), '') is not null or nullif(btrim(source_customer_name), '') is not null) - and (visibility_code = 'public' or corporate_entity_id = any($1::uuid[])) + and (visibility_code = 'public' or ( + corporate_entity_id = any($1::uuid[]) + and (cardinality($2::uuid[]) = 0 + or process_unit_id = any($2::uuid[])))) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} ), ranked as ( select scoped.*, @@ -757,6 +772,7 @@ async def read_customer_master( order by top_groups.post_count desc, top_groups.customer_code, top_groups.customer_name """, list(account.corporate_entity_ids), + list(account.process_unit_ids), ) # Safe SQL: the evidence query uses only closed schema fragments; authorized entity ids are bound. source_author_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli @@ -777,7 +793,10 @@ async def read_customer_master( join user_account author on author.user_account_id = post.author_account_id where post.source_author_code is not null and btrim(post.source_author_code) <> '' - and (post.visibility_code = 'public' or post.corporate_entity_id = any($1::uuid[])) + and (post.visibility_code = 'public' or ( + post.corporate_entity_id = any($1::uuid[]) + and (cardinality($2::uuid[]) = 0 + or post.process_unit_id = any($2::uuid[])))) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} ), ranked as ( select scoped.*, @@ -894,6 +913,7 @@ async def read_customer_master( order by top_groups.post_count desc, top_groups.author_code """, list(account.corporate_entity_ids), + list(account.process_unit_ids), ) entity_rows = await conn.fetch( """ @@ -1132,7 +1152,7 @@ async def list_posts( search_term = search.strip() if search and search.strip() else None async with pool.acquire() as conn: voc_type_options, visibility_options = await _post_filter_options( - conn, account.corporate_entity_ids + conn, account.corporate_entity_ids, account.process_unit_ids ) body_search_ids: list[str] = [] if search_term: @@ -1173,7 +1193,7 @@ async def list_posts( post.source_project_code, post.source_project_name, post.source_system_code, post.source_record_key, - post.corporate_entity_id, post.created_at, + post.corporate_entity_id, post.process_unit_id, post.created_at, case when $1::text is null then 0 when lower(coalesce(post.post_title, '')) like '%' || lower($1) || '%' then 0 @@ -1183,7 +1203,9 @@ async def list_posts( count(*) over() as total_count from source_post post where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($2::text[])) + or (post.corporate_entity_id::text = any($2::text[]) + and (cardinality($9::text[]) = 0 + or post.process_unit_id::text = any($9::text[])))) and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} and ( $1::text is null @@ -1381,6 +1403,7 @@ async def list_posts( offset, limit, sort, + list(account.process_unit_ids), ) visible = [row for row in rows if _can_see_post(account, row)] labels = await _lookup_post_labels(conn, visible) @@ -1431,7 +1454,7 @@ async def read_post( "source_sales_pool_code, source_sales_pool_name, " "source_customer_code, source_customer_name, source_project_code, source_project_name, " "source_system_code, source_record_key, " - "corporate_entity_id, created_at " + "corporate_entity_id, process_unit_id, created_at " f"from source_post where post_id = $1 and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}", post_id, ) @@ -1621,7 +1644,7 @@ async def _load_visible_post( """ select source_post.post_id, source_post.post_title, source_post.voc_type_code, source_post.visibility_code, source_post.corporate_entity_id, - source_post.created_at, source_post.author_account_id, + source_post.process_unit_id, source_post.created_at, source_post.author_account_id, source_post.source_process_unit_code, source_post.source_author_code, source_post.source_company_code, source_post.source_customer_code, source_post.source_project_code, source_post.source_sales_pool_code, diff --git a/backend/tests/test_auth_jwks.py b/backend/tests/test_auth_jwks.py index 709d2c16e..a2bad05f5 100644 --- a/backend/tests/test_auth_jwks.py +++ b/backend/tests/test_auth_jwks.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import base64 import json from types import SimpleNamespace @@ -158,7 +159,7 @@ def fake_decode(token, **kwargs): assert captured["issuer"] == "https://id.example" assert captured["audience"] == "https://lineage.example/api" assert captured["algorithms"] == ["RS256"] - assert "options" not in captured + assert captured["options"] == {"require": ["exp", "iat", "sub"]} def test_decode_rejects_missing_subject(monkeypatch: pytest.MonkeyPatch) -> None: @@ -173,3 +174,97 @@ def test_decode_rejects_missing_subject(monkeypatch: pytest.MonkeyPatch) -> None with pytest.raises(HTTPException) as error: auth._decode_access_token("token", settings) assert error.value.status_code == 401 + + +@pytest.mark.parametrize( + "claims", + [ + {"workspace": "workspace-a", "role": ["member"]}, + {"org": "org-a", "role": ["member"]}, + {"org": "org-a", "workspace": "workspace-a", "role": "member"}, + {"org": "org-a", "workspace": "workspace-a", "role": ["member", "member"]}, + {"org": ["org-a"], "workspace": "workspace-a", "role": ["member"]}, + ], +) +def test_keyverse_account_claims_reject_partial_or_ambiguous_profiles(claims: dict) -> None: + """Keyverse account claims are atomic and never delimiter-decoded.""" + with pytest.raises(HTTPException) as error: + auth._keyverse_account_claims(claims) + assert error.value.status_code == 401 + + +def test_keyverse_account_claims_return_one_trimmed_scope() -> None: + assert auth._keyverse_account_claims( + {"org": "org-a", "workspace": "workspace-a", "role": ["member", "reviewer"]} + ) == ("org-a", "workspace-a", ["member", "reviewer"]) + + +class _Connection: + def __init__(self) -> None: + self.fetchrow_args: tuple[object, ...] | None = None + self.fetch_args: tuple[object, ...] | None = None + + async def fetchrow(self, _query: str, *args: object): + self.fetchrow_args = args + return { + "user_account_id": "account-a", + "display_name": "Synthetic Member", + "preferred_locale": "en", + "corporate_entity_id": "entity-a", + "process_unit_id": "process-a", + } + + async def fetch(self, _query: str, *args: object): + self.fetch_args = args + return [{"permission_code": "post_read"}] + + +class _Acquire: + def __init__(self, connection: _Connection) -> None: + self.connection = connection + + async def __aenter__(self) -> _Connection: + return self.connection + + async def __aexit__(self, *_args: object) -> None: + return None + + +class _Pool: + def __init__(self, connection: _Connection) -> None: + self.connection = connection + + def acquire(self) -> _Acquire: + return _Acquire(self.connection) + + +def test_keyverse_account_resolves_exact_scope_and_role_intersection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verified claims select one DB affiliation; DB roles retain authority.""" + connection = _Connection() + monkeypatch.setattr( + auth, + "load_settings", + lambda: SimpleNamespace(keyverse_claim_binding_required=True), + ) + monkeypatch.setattr( + auth, + "_decode_access_token", + lambda *_args: { + "sub": "subject-a", + "org": "org-a", + "workspace": "workspace-a", + "role": ["member"], + }, + ) + + account = asyncio.run( + auth.get_current_account(SimpleNamespace(credentials="token"), _Pool(connection)) + ) + + assert connection.fetchrow_args == ("subject-a", "org-a", "workspace-a") + assert connection.fetch_args == ("account-a", ["member"]) + assert account.corporate_entity_ids == frozenset({"entity-a"}) + assert account.process_unit_ids == frozenset({"process-a"}) + assert account.permission_codes == frozenset({"post_read"}) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index b5f6e9a12..4b2fad99d 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -60,6 +60,7 @@ def test_keyverse_issuer_overrides_local_keycloak_and_uses_oidc_discovery(monkey "https://keyverse.example/tenant/acme/.well-known/openid-configuration" ) assert settings.oidc_jwks_uri_override == "" + assert settings.keyverse_claim_binding_required is True def test_local_keycloak_discovery_uses_backend_reachable_base_url(monkeypatch) -> None: @@ -80,6 +81,7 @@ def test_local_keycloak_discovery_uses_backend_reachable_base_url(monkeypatch) - assert settings.oidc_jwks_uri_override == ( "http://keycloak:8080/realms/lineageweave-demo/protocol/openid-connect/certs" ) + assert settings.keyverse_claim_binding_required is False def test_rankweave_disabled_defaults_off(monkeypatch) -> None: diff --git a/docs/adr/0028-keyverse-oidc-provider.md b/docs/adr/0028-keyverse-oidc-provider.md index 2a2c481ed..14c5166ce 100644 --- a/docs/adr/0028-keyverse-oidc-provider.md +++ b/docs/adr/0028-keyverse-oidc-provider.md @@ -19,8 +19,10 @@ must not be presented as one. `jwks_uri` for RS256 verification. `KEYVERSE_DISCOVERY_URI` and `KEYVERSE_JWKS_URI` are explicit overrides for deployments where discovery is proxied. -3. The verified `sub` is still resolved to a provisioned `user_account`; the - database remains authoritative for corporation affiliations and permissions. +3. The verified `sub` is resolved to a provisioned `user_account`; the database + remains authoritative for affiliations and permissions. ADR 0119 further + requires the production Keyverse `org`, `workspace`, and `role` claims to + select one matching local scope before either authority is used. 4. Compose uses its existing local Keycloak realm only when no Keyverse issuer is configured. It does not add a Keyverse-shaped identity implementation. diff --git a/docs/adr/0119-keyverse-account-claim-binding.md b/docs/adr/0119-keyverse-account-claim-binding.md new file mode 100644 index 000000000..58cd3754d --- /dev/null +++ b/docs/adr/0119-keyverse-account-claim-binding.md @@ -0,0 +1,62 @@ +# ADR 0119: Bind Keyverse account claims to one local authorization scope + +## Status + +Accepted for the next release. + +## Context + +ADR 0028 validates a production Keyverse token and resolves its `sub` to a +provisioned `user_account`, while keeping affiliations and permissions in the +normalized LineageWeave database. Keyverse ADR 0009 now defines the +`lineageweave-web` relying-party profile more narrowly: `org`, `workspace`, and +the multivalued `role` claim are account-derived and atomic. A consumer must +reject a partial or ambiguous profile before ABAC or RBAC. + +Resolving only `sub` permits a valid but stale or differently scoped Keyverse +session to inherit every locally provisioned affiliation and role. Signature +verification alone therefore does not prove the requested authorization scope. + +## Decision + +When `KEYVERSE_ISSUER` selects the production Keyverse profile: + +1. Require non-empty scalar `org` and `workspace` claims and a non-empty, + duplicate-free list of non-empty scalar `role` values. + The verified JWT must also contain `exp`, `iat`, and `sub`. +2. Resolve `sub` + `org` + `workspace` to exactly one normalized + `user_account` / `account_affiliation` / `corporate_entity` / `process_unit` + row. `org` matches `corporate_entity_code`; `workspace` matches + `process_unit_code`, and the process unit must belong to that organization. +3. Load permissions only from locally assigned roles whose `role_code` is also + present in the verified token. The token selects current issuer state; the + database remains the permission authority. +4. Restrict private post reads to both the selected organization and selected + process unit. Public records retain their existing visibility contract. +5. Keep local Compose Keycloak compatible with its synthetic development claim + profile; the stricter account-derived contract activates only for an + explicitly configured Keyverse issuer. + +Missing, malformed, ambiguous, or unprovisioned scope is denied. No claim is +used to create an affiliation, process unit, role, or permission. + +## Consequences + +- A membership or role change requires a new token or session renewal and a + matching local provisioned row. +- A Keyverse token cannot widen access beyond the intersection of issuer claims + and local 3NF authorization state. +- Multi-membership tokens and delimiter conventions remain unsupported; a + future profile requires a separate ADR and cross-scope regression evidence. + +## References + +ContextualWisdomLab. (2026). *ADR 0009: Bind LineageWeave relying-party claims +to Keyverse accounts*. Keyverse. + +Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current +practice for OAuth 2.0 security* (RFC 9700). Internet Engineering Task Force. +https://doi.org/10.17487/RFC9700 + +OpenID Foundation. (2014). *OpenID Connect Core 1.0 incorporating errata set +2*. https://openid.net/specs/openid-connect-core-1_0.html diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index e65883463..7eacbd97a 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -21,6 +21,7 @@ - **Zotero Integration**: Papers and standards referenced by TEPP must be synced via Local Zotero API (http://localhost:23119/api/) and cited using APA 7th edition in docstrings. - **Testing**: We need actual testing of Psychometrics (Fast-MLSIRM parameter calibration, RMSE of estimates, Fixed-Item Parameter Calibration, CAT) against synthetic/demo data. - **Security & Compliance**: PII masking cannot break the system. Need SOC 2 and CSAP compliance alternatives to blind PII masking. +- **Keyverse account scope**: (Active PR) Production tokens must bind the account-derived `org`, `workspace`, and `role` claims to one provisioned local affiliation before ABAC/RBAC; ADR 0119 defines the fail-closed integration. - **LLM Orchestration**: Ensure ALL LLM calls route through `contextual-orchestrator` utilizing API keys (BYTEZ, NVIDIA, OPENROUTER, OPENAI) with auto model discovery and optimal reasoning effort allocation (Fugu/Conductor/TRINITY research). *This document is continuously updated by the hourly automated agent loop.* diff --git a/tests/test_post_eligibility.py b/tests/test_post_eligibility.py index 336820e3d..5be7eb265 100644 --- a/tests/test_post_eligibility.py +++ b/tests/test_post_eligibility.py @@ -4,6 +4,39 @@ source_context_missing_sql, source_context_present_sql, ) +from backend.app.auth import CurrentAccount +from backend.app.main import _can_see_post + + +def _account(*, process_unit_ids: frozenset[str]) -> CurrentAccount: + return CurrentAccount( + user_account_id="account-a", + external_subject_id="subject-a", + display_name="Synthetic Member", + preferred_locale="en", + corporate_entity_ids=frozenset({"entity-a"}), + process_unit_ids=process_unit_ids, + permission_codes=frozenset({"post_read"}), + ) + + +def test_keyverse_private_post_requires_the_bound_process_unit() -> None: + account = _account(process_unit_ids=frozenset({"process-a"})) + assert _can_see_post( + account, + {"visibility_code": "private", "corporate_entity_id": "entity-a", "process_unit_id": "process-a"}, + ) + assert not _can_see_post( + account, + {"visibility_code": "private", "corporate_entity_id": "entity-a", "process_unit_id": "process-b"}, + ) + + +def test_local_identity_retains_existing_corporate_scope() -> None: + assert _can_see_post( + _account(process_unit_ids=frozenset()), + {"visibility_code": "private", "corporate_entity_id": "entity-a", "process_unit_id": None}, + ) def test_real_source_context_hides_pure_seed_rows_at_read_boundary() -> None: