diff --git a/CHANGELOG.md b/CHANGELOG.md index 605b79a1c..48d394858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,9 @@ All notable changes to this project are documented here. Format follows - All OpenAI-compatible chat-completion consumers now validate the shared response envelope before parsing it, preventing malformed provider bodies from escaping as raw `KeyError` or response-shape details. +- The post-detail dialog focus trap now excludes descendants of `aria-hidden` + content as well as collapsed `details`, keeping keyboard focus inside the + visible modal controls. ## [2.12.6] - 2026-08-20 diff --git a/backend/app/main.py b/backend/app/main.py index 8a71dcab6..d86fac121 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -179,7 +179,10 @@ persist_post_summary, require_summary_source_body, ) -from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from backend.app.post_eligibility import ( + SOURCE_POST_ELIGIBILITY_SQL, + SOURCE_POST_VISIBILITY_SQL, +) from backend.app.demo_scope import ( fetch_demo_corporate_entity_ids, has_real_source_context, @@ -550,8 +553,7 @@ async def _post_filter_options( left join common_lookup_value lookup 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[])) + where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ @@ -563,8 +565,7 @@ async def _post_filter_options( left join common_lookup_value lookup 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[])) + where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} order by display_order, code """ @@ -740,7 +741,7 @@ 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 {SOURCE_POST_VISIBILITY_SQL.format(alias='source_post', authorized_entity_ids='$1')} and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')} ), ranked as ( select scoped.*, @@ -806,7 +807,7 @@ 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 {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} ), ranked as ( select scoped.*, @@ -924,16 +925,77 @@ async def read_customer_master( """, list(account.corporate_entity_ids), ) - entity_rows = await conn.fetch( + authorized_entity_rows = await conn.fetch( """ - select corporate_entity_id, corporate_entity_code, entity_name, - entity_level_code, parent_entity_id - from corporate_entity - where corporate_entity_id = any($1::uuid[]) - order by entity_name + select entity.corporate_entity_id, entity.corporate_entity_code, + entity.entity_name, entity.entity_level_code, + entity.parent_entity_id, + coalesce( + array_agg(distinct affiliation.affiliation_scope_code) + filter (where affiliation.affiliation_scope_code is not null), + array['scope_unclassified']::text[] + ) as affiliation_scope_codes + from corporate_entity entity + left join account_affiliation affiliation + on affiliation.corporate_entity_id = entity.corporate_entity_id + and affiliation.user_account_id = $2 + where entity.corporate_entity_id = any($1::uuid[]) + group by entity.corporate_entity_id, entity.corporate_entity_code, + entity.entity_name, entity.entity_level_code, + entity.parent_entity_id + order by entity.entity_name + """, + list(account.corporate_entity_ids), + account.user_account_id, + ) + # Resolved organization mentions enrich navigation only when the source + # post is already visible to this account. They never grant access to a + # private post and unresolved counterparty names remain hints below. + # Safe SQL: the visibility and eligibility predicates are closed schema text; ids are bound. + observed_entity_rows = await conn.fetch( # nosemgrep: python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli + f""" + select distinct entity.corporate_entity_id, entity.corporate_entity_code, + entity.entity_name, entity.entity_level_code, + entity.parent_entity_id + from post_organization_mention mention + join source_post post on post.post_id = mention.post_id + join corporate_entity entity + on entity.corporate_entity_id = mention.corporate_entity_id + where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$1')} + and {SOURCE_POST_ELIGIBILITY_SQL.format(alias='post')} + order by entity.entity_name """, list(account.corporate_entity_ids), ) + + scope_facet_by_code = { + "scope_own_entity": "authorized_own", + "scope_granted_entity": "authorized_granted", + "scope_unclassified": "scope_unclassified", + } + entity_by_id: dict[str, dict[str, Any]] = {} + + def add_entity(row: asyncpg.Record | dict[str, Any], facets: list[str]) -> None: + entity_id = str(row["corporate_entity_id"]) + current = entity_by_id.get(entity_id) + if current is None: + current = dict(row) + current["scope_facets"] = set() + entity_by_id[entity_id] = current + current["scope_facets"].update(facets) + + for row in authorized_entity_rows: + add_entity( + row, + [ + scope_facet_by_code.get(scope_code, "scope_unclassified") + for scope_code in (row["affiliation_scope_codes"] or ["scope_unclassified"]) + ], + ) + observed_entity_ids = {str(row["corporate_entity_id"]) for row in observed_entity_rows} + for row in observed_entity_rows: + add_entity(row, ["observed_organization"]) + entity_rows = sorted(entity_by_id.values(), key=lambda row: row["entity_name"]) has_source_context = bool(source_customer_rows or source_author_rows) if not has_source_context: has_source_context = await has_real_source_context( @@ -946,11 +1008,29 @@ async def read_customer_master( for row in entity_rows if str(row["corporate_entity_id"]) not in synthetic_only_entity_ids ] - entity_ids = [row["corporate_entity_id"] for row in entity_rows] + surviving_entity_by_id = { + str(row["corporate_entity_id"]): row for row in entity_rows + } + for entity_id in observed_entity_ids: + entity = surviving_entity_by_id.get(entity_id) + if entity is None: + continue + parent_id = entity["parent_entity_id"] + parent = surviving_entity_by_id.get(str(parent_id)) if parent_id is not None else None + if parent is None: + continue + entity["scope_facets"].add("observed_hierarchy") + parent["scope_facets"].add("observed_hierarchy") + authorized_entity_id_set = {str(entity_id) for entity_id in account.corporate_entity_ids} + authorized_entity_ids = [ + str(row["corporate_entity_id"]) + for row in entity_rows + if str(row["corporate_entity_id"]) in authorized_entity_id_set + ] source_author_affiliations = await _load_account_affiliation_hints( conn, [str(row["author_account_id"]) for row in source_author_rows], - [str(entity_id) for entity_id in entity_ids], + authorized_entity_ids, ) keyman_rows = await conn.fetch( """ @@ -967,11 +1047,11 @@ async def read_customer_master( where affiliation.affiliated_corporate_entity_id = any($1::uuid[]) order by person.person_name, affiliation.affiliated_organization_name """, - entity_ids, + authorized_entity_ids, ) side_labels = await labels_for_codes(conn, [row["person_side_code"] for row in keyman_rows]) entity_level_labels = await labels_for_codes(conn, [row["entity_level_code"] for row in entity_rows]) - relationship_network = await fetch_relationship_network(conn, entity_ids) + relationship_network = await fetch_relationship_network(conn, authorized_entity_ids) keymen_by_id: dict[str, dict[str, Any]] = {} for row in keyman_rows: @@ -1010,6 +1090,7 @@ async def read_customer_master( "entity_level_label": entity_level_labels.get( row["entity_level_code"], row["entity_level_code"] ), + "scope_facets": sorted(row["scope_facets"]), "parent_entity_id": ( str(row["parent_entity_id"]) if row["parent_entity_id"] is not None else None ), @@ -1216,8 +1297,7 @@ async def list_posts( end as search_priority, count(*) over() as total_count from source_post post - where (post.visibility_code = 'public' - or post.corporate_entity_id::text = any($2::text[])) + where {SOURCE_POST_VISIBILITY_SQL.format(alias='post', authorized_entity_ids='$2')} and {SOURCE_POST_ELIGIBILITY_SQL.format(alias="post")} and ( $1::text is null diff --git a/backend/app/post_eligibility.py b/backend/app/post_eligibility.py index 48e3da9db..9136961f0 100644 --- a/backend/app/post_eligibility.py +++ b/backend/app/post_eligibility.py @@ -15,6 +15,14 @@ "source_project_name", ) +# The SQL projection of the fixed ABAC rule in ``main._can_see_post``. Keep +# the authorized-id placeholder explicit so every reader query shares the +# same public-or-affiliated visibility boundary. +SOURCE_POST_VISIBILITY_SQL = ( + "({alias}.visibility_code = 'public' " + "or {alias}.corporate_entity_id = any({authorized_entity_ids}::uuid[]))" +) + def source_context_present_sql(alias: str) -> str: return " or ".join( diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c50a34ae2..2aea6264c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -123,6 +123,11 @@ / "migrations" / "0104_two_word_database_identifiers.sql" ) +_CUSTOMER_MASTER_SCOPE_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0105_customer_master_scope_facets.sql" +) def _postgres_available() -> bool: @@ -238,6 +243,7 @@ def seeded_db(demo_analyst_token): cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) cur.execute(_TENANT_SETTINGS_MIGRATION.read_text()) cur.execute(_IDENTIFIER_MIGRATION.read_text()) + cur.execute(_CUSTOMER_MASTER_SCOPE_MIGRATION.read_text()) cur.execute( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -294,6 +300,16 @@ def seeded_db(demo_analyst_token): "values ('OTHER-CORP', 'Other Corp', 'group') returning corporate_entity_id" ) other_corp_id = cur.fetchone()[0] + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('GRANTED-CORP', 'Granted Corp', 'company') returning corporate_entity_id" + ) + granted_corp_id = cur.fetchone()[0] + cur.execute( + "insert into corporate_entity (corporate_entity_code, entity_name, entity_level_code) " + "values ('HIDDEN-CORP', 'Hidden Corp', 'company') returning corporate_entity_id" + ) + hidden_corp_id = cur.fetchone()[0] cur.execute( "insert into user_account (external_subject_id, display_name, email_address) " @@ -302,8 +318,10 @@ def seeded_db(demo_analyst_token): ) account_id = cur.fetchone()[0] cur.execute( - "insert into account_affiliation (user_account_id, corporate_entity_id) values (%s, %s)", - (account_id, own_corp_id), + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "values (%s, %s, 'scope_own_entity'), (%s, %s, 'scope_granted_entity')", + (account_id, own_corp_id, account_id, granted_corp_id), ) cur.execute( "insert into access_role (role_code, role_name) values ('viewer', 'Viewer') returning access_role_id" @@ -503,6 +521,12 @@ def _insert_post( "values (%s, 'Northridge Grid'), (%s, 'Northridge Holdings')", (counterpart_person_id, counterpart_person_id), ) + cur.execute( + "insert into person_affiliation " + "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " + "values (%s, 'Other Corp Only', %s)", + (hidden_person_id, other_corp_id), + ) cur.execute( "insert into post_person_mention (post_id, person_id) values " @@ -560,6 +584,8 @@ def _insert_post( "own_group_id": str(own_group_id), "own_corp_id": str(own_corp_id), "other_corp_id": str(other_corp_id), + "granted_corp_id": str(granted_corp_id), + "hidden_corp_id": str(hidden_corp_id), "own_private_post_id": own_private_post_id, "late_own_private_post_id": late_own_private_post_id, "edited_own_post_id": edited_own_post_id, @@ -1229,7 +1255,20 @@ def test_settings_patch_requires_post_admin(client, demo_analyst_token, seeded_d assert confirm.json() == {"brandName": "LineageWeave Demo"} -def test_customer_master_returns_authorized_catalog_contract(client, demo_analyst_token, seeded_db) -> None: +def test_customer_master_returns_authorized_catalog_contract( + client, demo_analyst_token, seeded_db, monkeypatch +) -> None: + subject = jwt.decode(demo_analyst_token, options={"verify_signature": False})["sub"] + from backend.app import main as main_module + + relationship_entity_ids: list[str] = [] + original_relationship_network = main_module.fetch_relationship_network + + async def capture_relationship_entity_ids(conn, corporate_entity_ids): + relationship_entity_ids.extend(corporate_entity_ids) + return await original_relationship_network(conn, corporate_entity_ids) + + monkeypatch.setattr(main_module, "fetch_relationship_network", capture_relationship_entity_ids) admin_conn = psycopg2.connect(seeded_db["dsn"]) try: with admin_conn.cursor() as cur: @@ -1266,6 +1305,48 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys seeded_db["our_person_id"], ), ) + cur.execute( + "insert into corporate_entity " + "(parent_entity_id, corporate_entity_code, entity_name, entity_level_code) " + "values (%s, 'DEMO-GRANTED-CHILD', 'Demo Granted Child', 'company') " + "returning corporate_entity_id", + (seeded_db["granted_corp_id"],), + ) + demo_child_id = str(cur.fetchone()[0]) + cur.execute( + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "select user_account_id, %s, 'scope_granted_entity' " + "from user_account where external_subject_id = %s", + (demo_child_id, subject), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s), (%s, %s), (%s, %s), (%s, %s)", + ( + seeded_db["public_post_id"], + seeded_db["other_corp_id"], + seeded_db["other_private_post_id"], + seeded_db["hidden_corp_id"], + seeded_db["public_post_id"], + seeded_db["own_corp_id"], + seeded_db["public_post_id"], + demo_child_id, + ), + ) + cur.execute( + "insert into post_counterparty_entity " + "(post_id, counterparty_entity_name, relationship_type_code, verification_status_code) " + "values (%s, 'Private Other Corp', 'rel_voc', 'verify_pending')", + (seeded_db["other_private_post_id"],), + ) + cur.execute( + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, affiliation_scope_code) " + "select user_account_id, %s, 'scope_granted_entity' " + "from user_account where external_subject_id = %s", + (seeded_db["own_group_id"], subject), + ) # A real counterparty can hold more than one role over its # lifetime -- one post classifies "Northridge Grid" as a # customer, a different visible post classifies the same @@ -1283,6 +1364,20 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys seeded_db["public_post_id"], ), ) + # Once imported source context exists, this affiliated child is + # synthetic-only and must be removed consistently from the tree, + # stale observed hierarchy facets, Keymen, and account hints. + cur.execute( + "insert into cataloged_person (person_name, person_side_code) " + "values ('Demo Grant Only', 'our_side') returning person_id", + ) + demo_person_id = cur.fetchone()[0] + cur.execute( + "insert into person_affiliation " + "(person_id, affiliated_organization_name, affiliated_corporate_entity_id) " + "values (%s, 'Demo Granted Child', %s)", + (demo_person_id, demo_child_id), + ) admin_conn.commit() finally: admin_conn.close() @@ -1306,7 +1401,18 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys # confirm this is a real common_lookup_value label, not the code echoed back. assert entity["entity_level_code"] == "company" assert entity["entity_level_label"] not in ("", "company") + assert entity["scope_facets"] == ["authorized_own", "observed_hierarchy", "observed_organization"] + parent = next(item for item in body["corporate_entities"] if item["entity_name"] == "Test Group") + assert parent["scope_facets"] == ["authorized_granted", "observed_hierarchy"] + granted = next(item for item in body["corporate_entities"] if item["entity_name"] == "Granted Corp") + assert granted["scope_facets"] == ["authorized_granted"] + assert not any(item["entity_name"] == "Demo Granted Child" for item in body["corporate_entities"]) + observed = next(item for item in body["corporate_entities"] if item["entity_name"] == "Other Corp") + assert observed["scope_facets"] == ["observed_organization"] + assert not any(item["entity_name"] == "Hidden Corp" for item in body["corporate_entities"]) assert isinstance(body["keymen"], list) + assert not any(item["person_name"] == "Other Corp Only" for item in body["keymen"]) + assert not any(item["person_name"] == "Demo Grant Only" for item in body["keymen"]) ada_west = next(item for item in body["keymen"] if item["person_name"] == "Ada West") assert ada_west["person_side_code"] == "our_side" # Live UI finding (2026-08-19): the Customer Master Keymen list falls @@ -1316,6 +1422,8 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys assert ada_west["person_side_label"] not in ("", "our_side") network = {row["counterparty_entity_name"]: row for row in body["relationship_network"]} + assert demo_child_id not in relationship_entity_ids + assert "Private Other Corp" not in network northridge = network["Northridge Grid"] assert northridge["multi_role"] is True assert {rel["relationship_type_code"] for rel in northridge["relationships"]} == {"rel_voc", "rel_voco"} @@ -1370,6 +1478,14 @@ def test_customer_master_returns_authorized_catalog_contract(client, demo_analys affiliation["entity_name"] == "Test Corp" for affiliation in author_hint[0]["account_affiliations"] ) + assert any( + affiliation["entity_name"] == "Granted Corp" + for affiliation in author_hint[0]["account_affiliations"] + ) + assert not any( + affiliation["entity_name"] == "Demo Granted Child" + for affiliation in author_hint[0]["account_affiliations"] + ) assert "account_affiliation.corporate_entity_id" in author_hint[0]["provenance"] diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 3a16458d4..88f168a43 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0125-customer-master-scope-facets.md b/docs/adr/0125-customer-master-scope-facets.md new file mode 100644 index 000000000..3099448be --- /dev/null +++ b/docs/adr/0125-customer-master-scope-facets.md @@ -0,0 +1,106 @@ +# ADR 0125 — Customer Master separates authorization scope from observed relationship facets + +**Decision status:** Proposed +**Date:** 2026-08-21 + +## Context + +`/api/customer-master` currently reads its `corporate_entities` list only from +`account_affiliation`. That is a valid authorization boundary, but it is not a +customer hierarchy: verified organization mentions and counterparty entities +created by ADR 0010 are not necessarily account affiliations. The resulting UI +can show an authorized employer while hiding an observed customer or affiliate, +and it cannot distinguish an account's own company from an explicitly granted +company because the current affiliation row has no such attribute. + +The fix must preserve the existing ABAC decision. A visible relationship in a +public or authorized post is evidence for navigation, not permission to read a +private post. An unresolved counterparty name is a hint, not a catalog entity. + +## Decision + +1. Keep `account_affiliation` as the authorization source. Every post and + entity returned by Customer Master remains subject to the existing + per-request visibility and source-post eligibility predicates. +2. Add an explicit, nullable `affiliation_scope_code` to + `account_affiliation`, backed by `common_lookup_value`, with the controlled + values `scope_own_entity`, `scope_granted_entity`, and + `scope_unclassified`. Existing rows are migrated to `scope_unclassified`; + no own/customer identity is inferred from a login token, a PU, a post title, + or a corporate name. Authentication continues to authorize every existing + affiliation row regardless of this display facet. +3. Extend the Customer Master entity contract with repeatable, provenance-bearing + `scope_facets`: `authorized_own`, `authorized_granted`, + `scope_unclassified`, `observed_organization`, and `observed_hierarchy`. + Multiple facets are + allowed because one organization may be both an authorized entity and an + observed counterparty in different evidence. +4. Build `observed_organization` only from a visible, eligible post's resolved + `post_organization_mention` (or an equivalently persisted, verified catalog + binding). `post_counterparty_entity` names that remain unresolved or + uncorroborated stay in the existing `source_customer_hints` / relationship + evidence surfaces and do not become tree nodes. +5. Traverse `parent_entity_id` only across entities already admitted by an + authorization or visible evidence path. If a parent is not admitted, render + the admitted child as a root; never widen access merely to complete a tree. +6. The UI's own-company/customer filters consume these facets and expose + `scope_unclassified` as an honest third state. No confirmation dialog or + guessed label is introduced. The API remains the single place that applies + authorization and provenance rules. + +## Implementation sequence + +The implementation is intentionally split so an ABAC regression cannot hide in +a large customer-tree change: + +1. Add the lookup values and nullable affiliation column with a migration and + update provisioning paths to write an explicit value. +2. Add an API integration test with own, granted, unclassified, visible + organization-mention, and private-post cases. Assert that private evidence + never adds a node and unresolved names remain hints. +3. Add the API contract and frontend filter/tree tests, then implement the + query projection and UI facets. +4. Backfill only from an authoritative account-scope source. Until that source + exists, retain `scope_unclassified`; do not infer it from the corpus. + +## Consequences + +- The tree can become useful without weakening ABAC: observed nodes are bounded + by visible evidence and do not authorize unrelated reads. +- Existing deployments will initially show an explicit unknown scope instead of + a misleading own/customer label. This is preferable to a silent false fact. +- `account_affiliation` remains a normalized authorization relation; the facet + is an attribute of that relation, not a second account-to-entity authority + table. A later need for time-bounded grants requires a separate ADR rather + than overloading this column. +- The entity query needs an entity-first index on persisted organization + mentions if live corpus size requires it. Add that index with the same + migration after measuring the query plan; do not pre-emptively shard a small + table. + +## Related decisions + +- [ADR 0004](0004-knowledge-graph-ontology.md) — ontology and semantic layer. +- [ADR 0010](0010-corporate-hierarchy-auto-creation.md) — verified counterparty + hierarchy creation. +- [ADR 0041](0041-source-context-vs-authorization-scope.md) — source context + must not be confused with authorization scope. +- [ADR 0042](0042-source-hints-before-customer-binding.md) — unresolved source + customer values remain hints. +- [ADR 0052](0052-plain-orchestrator-semantic-evidence.md) — semantic evidence + must retain provenance and uncertainty. + +## References (APA 7th) + +Hu, V. C., Ferraiolo, D., Kuhn, 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 + 2019). National Institute of Standards and Technology. + https://doi.org/10.6028/NIST.SP.800-162 + +Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust + architecture* (NIST Special Publication 800-207). National Institute of + Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207 + +World Wide Web Consortium. (2009). *SKOS simple knowledge organization system + reference*. https://www.w3.org/TR/skos-reference/ diff --git a/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md b/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md new file mode 100644 index 000000000..5e5607975 --- /dev/null +++ b/docs/doctoring/CUSTOMER_MASTER_SCOPE_REFERENCES.md @@ -0,0 +1,29 @@ +# Customer Master scope references + +This register grounds the proposed Customer Master scope-facet decision in +authoritative access-control and semantic-hierarchy sources. Keep the +authorization boundary, observed evidence, and catalog identity separate when +implementing ADR 0125. + +## Evidence mapping + +| Source | Product decision | +| --- | --- | +| NIST SP 800-162 | Treat account, resource, action, and environment attributes as inputs to an ABAC decision; do not turn a display classification into a permission grant. | +| NIST SP 800-207 | Re-evaluate access at the resource boundary and minimize implicit trust; visible relationship evidence cannot widen private-post access. | +| W3C SKOS | Represent the corporate hierarchy as a broader/narrower concept relation while retaining the evidence and authorization facets separately. | + +## APA 7th references + +Hu, V. C., Ferraiolo, D., Kuhn, 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 +2019). National Institute of Standards and Technology. +https://doi.org/10.6028/NIST.SP.800-162 + +Rose, S., Borchert, O., Mitchell, S., & Connelly, S. (2020). *Zero trust +architecture* (NIST Special Publication 800-207). National Institute of +Standards and Technology. https://doi.org/10.6028/NIST.SP.800-207 + +World Wide Web Consortium. (2009). *SKOS simple knowledge organization system +reference*. https://www.w3.org/TR/skos-reference/ diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index cb68bb2e1..5bfc76566 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,20 +1,24 @@ # Product & Technical Gap Baseline -> Audit scope: the current `fix/uiux-standard-guide-v3-postmerge` worktree, -> compared with `origin/main`, the UI/UX Standard Guide v3.0 supplied for this +> Audit scope: the current `feat/customer-master-scope-facets` worktree and +> open PR #366, compared with its exact base `feat/lineage-dag-regression`, the UI/UX Standard Guide v3.0 supplied for this > product, ADR 0118, the accepted TEPP PRD/contracts, and the > contextual-orchestrator architecture. Real source identifiers are deliberately > replaced with case labels; they must not enter repository artifacts. ## 1. Exact-head evidence -Audit anchor: the exact source state carried by this commit at 2026-08-21; -record the final PR head with `git rev-parse HEAD` during acceptance. +Audit anchor: the exact source state carried by this documentation commit at +2026-08-21; record the final PR head with `git rev-parse HEAD` during +acceptance. -Current source/test exact head observed before this documentation update: -`0e63ba0a2e23949630f8997cbe001b6e13b2d274`, the squash merge of PR #350. -This documentation update will create the next exact head and therefore -requires the protected checks to rerun. +Current implementation source/test exact head observed before this +documentation update: `a5aa0daa`, the +fully-contained popup keyboard-navigation repair including nested +`aria-hidden` descendants, on top of the customer-scope facets and +closed-details focus fixes, with PR #366 based on +`8bed77e7e7b91b633bb92d3a82d0187c387206af`. The documentation commit creates +the next exact head; protected checks must rerun for the resulting PR head. - **Implemented in source:** PostgreSQL-backed API boundaries, Keyverse/OIDC identity boundary, workspace navigation, post popup, ABAC/RBAC surfaces, Korean @@ -33,15 +37,83 @@ requires the protected checks to rerun. - **Figma reference:** ADR 0118 records file `1Su3lDRmiZdcUs47t1QwIX`; the inspected Event Lineage frames are desktop `5:14` and mobile `5:15`. - **Local quality evidence at the source/test head:** backend `uv run pytest -q` - passed `786` tests with `16` skips; frontend Vitest passed `174` tests in `19` + passed `788` tests with `17` skips; frontend Vitest passed `177` tests in `19` files, frontend lint/build passed, and Storybook build completed. These are local checks, not hosted protected-gate or independent-review evidence. - **Current PR gate:** PR #350 merged at `0e63ba0a2e23949630f8997cbe001b6e13b2d274` after its source head `819ef876270212305c89743e5443b3ce0b871e66` was reviewed and squashed into - `feat/lineage-dag-regression`. This docs-only follow-up requires its own - protected checks and independent approval; neither is claimed yet. The - prior PR #347 merged at `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. + `feat/lineage-dag-regression`; PR #364 then merged the evidence-only + baseline at `8bed77e7e7b91b633bb92d3a82d0187c387206af`. This ADR/docs + follow-up requires its own protected checks and independent approval; + neither is claimed yet. PR #366 remains open at code head + `a5aa0daa`; its hosted Tests run is queued, + Devin Review is pending, and no independent approval or merge is claimed. + The prior PR #347 merged at + `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. + +- **Adjacent current parsing PR:** PR #367 remains open and unmerged at exact + head `5194d267b90430d7a27a9752a49d73617cb5756c`, based on + `docs/customer-master-scope-adr` at `f66991699506ef14607de5946da1efcfd20ae6da`. + It addresses the supplied numbered-footnote and empty-table-cell cases, + removes a dead footnote-reference branch, and rejects short non-footnote IDs. + Its focused parser gate is `47 passed`, with `compileall` and + `git diff --check` passing. GitHub Tests run `32474961105` is queued, + Devin Review is successful, CodeRabbit is successful, and no unresolved + review thread, independent approval, or merge is claimed. + +- **Latest #366 review repair before this documentation commit:** code head + `e2ebd156c89fb810de44207c25a57a92372ab5d2` removes the discarded pre-filter + hierarchy pass and centralizes the public-or-authorized-corporate-entity SQL + projection used by post lists, filter options, source hints, and observed + organization enrichment. The focused PostgreSQL/API regression set passed + `3 passed` and the shared SQL contract test passed; full API-file execution + remains unclaimed because its external integration setup did not terminate. + +- **#366 latest review repair:** code head `09b47ccb` excludes descendants of + closed `details` elements from the post-popup focus-trap candidate set, so + keyboard focus cannot land on collapsed related-post controls. The same + change removes the now-dead observed-hierarchy discard loop after scope + filtering. The focused frontend keyboard regression passed (`1 passed`, 86 + deselected), the customer-master/API regression set passed (`3 passed`), and + `compileall` plus `git diff --check` passed. The final documentation commit + records the resulting PR head below; hosted Checks and review state remain + external evidence. + +- **#366 current-head frontend gate:** at `fbffec488a7db4029682b19bf22f4818ae17037e`, + `pnpm run lint`, `pnpm run test` (`177 passed` in 19 files), `pnpm run build`, + and `pnpm run build-storybook` all passed. Storybook emitted only the existing + post-build bundle-size advisory; no build failure was observed. + +- **#366 current-head backend gate:** at `e6d94c23ae74c39007f97fbe0afddcfe8e27451c`, + `uv run pytest -q` completed with `788 passed, 17 skipped, 4 warnings` in + 117.45 seconds. The warnings are existing Starlette deprecations and the + synthetic forged-token test key-length warning; no test failure was hidden. + +- **#366 latest accessibility repair:** code head + `a5aa0daa` always intercepts popup Tab events and advances only through the + filtered visible focus set, so native browser tabbing cannot land on an + `aria-hidden` control, a nested descendant of an `aria-hidden` container, or + a control inside closed `details`. The synthetic direct and nested + `aria-hidden` tab-stop regressions passed, and the frontend whole gate at + the preceding code head passed: 177 tests in 19 files, lint, + build, and Storybook. The existing Storybook bundle-size advisory is not a + failure. Customer-scope filtering intentionally re-roots an authorized child + when its parent is absent; the backend observed-hierarchy contract preserves + the hierarchy facet without reintroducing an unauthorized parent. + +- **Canonical ontology explorer PR:** PR #349 remains open and unmerged at + exact head `e88f3862215e76d0702204f29aba75ddc902d19f`, based on `main` at + `ef6f5a5ffcb467bd935dc1e53acc0029669b0bd7`. Its latest stabilization fixes + final-depth endpoint visibility, bounded stale-summary diagnostics, node + evidence after node-bound trimming, replay-safe tenant settings migration, + login return-path sanitization, OWL-Time citation accuracy, cursor-page + graph merging, and the intentional ontology direction contract. Its current + local gate is `812 passed, 17 skipped, 4 warnings` in the backend and 168 + frontend tests in 20 files with lint, build, and Storybook passing. The + baseline's historical case references are sanitized to non-identifying case + labels. Hosted Tests, Security, SAST, PROV-O, and Devin Review remain + pending/queued, so no approval or merge is claimed. ## 2. UI/UX Standard Guide v3.0 comparison @@ -64,6 +136,12 @@ requires the protected checks to rerun. - **Event Lineage Figma parity — fixed in this worktree:** the DAG now includes lineage-evidence context, legend, horizontal overflow on phones, inference boundary, direction markers, and an evidence trail table/cards treatment. +- **Post detail modal keyboard access — fixed in this worktree:** the existing + 50% backdrop now exposes a named modal dialog with `aria-modal`, moves focus + into the panel, closes on Escape, contains Tab focus, and restores focus to + the opener. Native interactive controls also share the token-based + `:focus-visible` ring. The behavior is covered by the authenticated React + and CSS tests; fresh browser evidence remains open. - **Approved CI/BI asset — open:** the header/footer currently render the tenant brand name as text. Do not invent or alter a corporate logo; add the approved asset only after the tenant CI/BI source and usage permission are @@ -181,10 +259,10 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to | Approved CI/BI logo asset | Tenant text is present; approved asset and permission are absent | open | | User/logout/language/global search header actions | `App.tsx`, `i18n.ts`, handled/pending search-focus tests | source + unit | | Site map / utility menu | `SiteMapUtility`, accessible toggle/region, Escape and destination-close behavior, responsive CSS contract, locale coverage | source + unit; authenticated browser evidence open | -| Noto Sans, palette, table/form/button conventions, modal 50% mask | ADR 0118, token CSS, component tests | source + unit | +| Noto Sans, palette, table/form/button conventions, modal 50% mask and keyboard semantics | ADR 0118, token CSS, popup dialog implementation, frontend tests | source + unit | | Keyverse/OIDC login with real account | `auth.py`, OIDC discovery/JWKS boundary, local redirect check | source + local-integration; Keyverse open | | Authenticated corp/PU attributes | `/api/me` returns DB-backed codes; backend integration test covers `TEST-CORP`/`TEST-PU` and header displays them | source + local-integration | -| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post`, API authorization tests, aggregate-only runtime checks | source + local-integration | +| RBAC/ABAC, public/private visibility, tenant isolation | `_can_see_post`, shared `SOURCE_POST_VISIBILITY_SQL`, API authorization tests, aggregate-only runtime checks | source + unit + local-integration | | React product surface and PostgreSQL boundary | React routes/components, asyncpg API, Compose stack | source + local-integration | | Authorized PostgreSQL export import mapping | `scripts/import_postgresql_posts.py`, ADR 0121, hash-verified RFC 2557 MHTML resolver, and synthetic preflight/import tests; authorized relation has artifact-path metadata but no body/content/HTML field | source + unit + local-integration partial; operator artifact files and authorized live import open | | Bounded large-body search migration | `0035_body_search_prefix.sql`, `0036_normalized_body_search.sql`; live replay completed after bounded rendered-text indexing | source + local-integration | @@ -194,13 +272,13 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to | Keyman on both sides, titles, affiliations, related KG nodes | Keyman/affiliate-tree/related-node routes and popup | source + unit; live extraction open | | Ontology, semantic layer, provenance, W3C PROV-O projection | normalized schema, SKOS operational vocabulary concepts, `ontology_annotations` label fallback, ADR 0124, provenance modules, ADRs, evidence UI | source + unit; corpus verification open | | Branching Event Lineage DAG with evidence trail | `LineageDag.tsx`, Storybook story, Figma frames, accessible node-kind names for screen readers/tooltips, frontend tests; runtime cases include both a rendered DAG and honest empty states, while current corpus coverage remains sparse | source + unit + local-integration partial | -| Customer master and hierarchy tree | `/api/customer-master`, affiliate tree, catalog migrations | source + unit; scoped to `account_affiliation` only, no own-company/customer distinction — see §5 | +| Customer master and hierarchy tree | `/api/customer-master`, `scope_facets`, visible `post_organization_mention` enrichment, affiliate tree, migration `0105`, scope filter | source + unit + local-integration partial; authorized own/granted/unclassified facets, visible observed organizations, and admitted observed hierarchy facets are implemented, while authoritative scope backfill and broader hierarchy traversal remain open | | VOC/VOM/VOP/VOCC/VOCO/VOS role classification | common lookup values and relationship APIs | source + unit; live classification open | | Evidence-grounded chat and source navigation | `/chat`, `/ask`, citation/evidence UI | source + unit; synthetic orchestrator judge route verified, corpus chat/runtime evidence open | | PU/team/project weekly/monthly reports | report API/UI and grouping controls | source + unit; TEPP-backed live report open | | TEPP calibrated measurement, dichotomous items, multilevel/MMM/time model | published import/REST boundary and TEPP ADR/PRD references | boundary-only; live-external open | | contextual-orchestrator routing, VISION, embedding, schema repair | clients and provenance/session boundary; synthetic authenticated route returned a judge score of `0.98`, OCR succeeded, and region location returned five regions | source + local-integration partial; corpus backfill, capability/readiness evidence, and schema-repair workflow open | -| HTML semantic units, tables, indentation, footnotes, formulas | parser modules and synthetic tests; 11-case authenticated popup sweep had no popup errors and rendered the supplied footnote/table cases; bounded metric superscript/subscript normalization has backend/frontend focused coverage | source + unit + local-integration partial; arbitrary formula/semantic correctness open | +| HTML semantic units, tables, indentation, footnotes, formulas | parser modules and synthetic tests; adjacent open PR #367 at exact head `b628722cb000717b0198e4337d12306d4306922d` adds numbered-footnote, leading-empty-cell, and short-ID regressions; 11-case authenticated popup sweep had no popup errors and rendered the supplied footnote/table cases; bounded metric superscript/subscript normalization has backend/frontend focused coverage | source + unit + local-integration partial; PR #367 protected checks, arbitrary formula/semantic correctness, and corpus re-backfill remain open | | Base64/file image regions and multimodal evidence | image-region schema and VISION client boundary; live aggregate has 12,823 images, 22 described images/regions, and 422 failed images; current synthetic VISION route returned five regions | source + local-integration partial; supplied image-table case re-backfill and complete corpus coverage open | | Abbreviation/multilingual alias/entity disambiguation | catalog hints and resolver boundary | source; live corroboration open | | SearXNG/internal relation fact check | verification endpoint and unavailable handling; local SearXNG health and JSON query both returned HTTP 200, while some upstream engines reported rate-limit/CAPTCHA results | source + local-integration partial; corroboration policy and reliable external coverage open | @@ -213,7 +291,7 @@ adapter, fixture, or HTTP-shaped test double never upgrades a row to | External email/project lineage package boundary | PR #343 publishes strict v1.0.0 bounded request/result types, available-time cutoff handling, observed/inferred/proposed truth states, pair-budget enforcement, and no source/provider access | source + focused unit; exact-head hosted gates, independent review, and immutable release open | | Naruon calendar projection boundary | PR #337 is closed as superseded; draft PR #355 carries the strict read projection contract without making LineageWeave a CalDAV provider | source + focused unit; Naruon endpoint, runtime wiring, restack, and review open | | Hourly PR review/repair/merge loop | Central `ContextualWisdomLab/.github` scheduler owns `*/15 * * * *` sweep and `0 * * * *` heartbeat; no duplicate repo-local scheduler is required | boundary accepted; current-head runtime open | -| 100% coverage/docstrings/edge-case/release gates | current checks and coverage evidence are not complete on PR #350 | open | +| 100% coverage/docstrings/edge-case/release gates | current local checks pass, but repository-wide coverage/docstring reports, hosted checks, independent review, and release evidence are not complete on PR #366 | open | ## 4. Supplied parsing and semantic cases @@ -241,32 +319,32 @@ or an explicit unavailable result. ## 5. Product and technical gaps -- **Customer master "customer tree" — scope gap, evidence-backed (2026-08-21):** - `/api/customer-master`'s `corporate_entities` list (`backend/app/main.py` - `read_customer_master`, `entity_rows` query) is scoped to - `account.corporate_entity_ids`, which comes only from `account_affiliation` - rows (`backend/app/auth.py` `get_current_account`) — the account's own - employer plus any explicitly granted entities. A live query against the - seeded stack confirms the Demo Corp account's `account_affiliation` grants - exactly one entity (Demo Corp itself) with zero `source_customer_code`/ - `source_customer_name` hints on its posts, so `buildCustomerEntityTree` - (`frontend/src/App.tsx`) renders a single un-nested node, not the "Harbor - Group -> Harbor Devices Korea" customer-affiliate tree ADR 0004 and ADR 0010 - describe as a standing requirement. Counterparty `corporate_entity` rows - that ADR 0010's `get_or_create_corporate_entity` auto-creates are never - linked via `account_affiliation`, so they cannot reach this endpoint no - matter how well-populated the corpus becomes — the tree needs to traverse - observed post/VOC/affiliate-tree evidence, not `account_affiliation` alone. - Separately, there is no schema signal distinguishing "own company" from - "granted customer entity" inside `account_affiliation`: both use the same - `process_unit_id`-bearing row shape (the Demo Analyst account's grants into - "Source company H504"/"H904" carry `process_unit_id` exactly like Demo - Corp's own grant does), so a same-screen filter separating 자사(own - company) attributes from customer attributes has no field to filter on - yet. Needs an ADR before implementation: either an explicit - `account_affiliation`/`corporate_entity` scope flag, or a customer-tree - query redesign: guessing at either without a reviewed decision risks an - ABAC-adjacent regression. +- **Customer master "customer tree" — scope facet slice implemented, hierarchy gap open (2026-08-21):** + ADR 0125 defines `account_affiliation` as the authorization source and adds + the normalized `affiliation_scope_code` lookup-backed attribute. Migration + `0105_customer_master_scope_facets.sql` defaults existing rows to + `scope_unclassified`; it does not infer own-company or granted scope from a + token, PU, title, or corporate name. `/api/customer-master` now returns + repeatable `scope_facets` (`authorized_own`, `authorized_granted`, + `scope_unclassified`, `observed_organization`, and admitted + `observed_hierarchy`) and the React Customer Master panel can filter those + server-provided facets. `observed_hierarchy` is emitted only when a visible + observed child and its already-admitted parent are both in the response. + Resolved + `post_organization_mention` rows enrich navigation only when their source + post is public or already authorized and eligible; they never widen ABAC, + and unresolved counterparty names remain hints. Synthetic schema/API/UI + coverage is present at implementation head `1eef321b`; the follow-up also + separates observed navigation IDs from authorized IDs for Keyman and + relationship-network queries, with regression coverage preventing observed + non-authorized organizations from widening ABAC. Hierarchy facets are + recomputed after synthetic filtering so removed children cannot leave stale + parent facets. + The remaining product gap is authoritative scope backfill for existing live + affiliations, persisted parent/hierarchy evidence, and a customer tree that + can safely traverse admitted observed hierarchy nodes. Until that evidence + exists, the UI must retain `scope_unclassified` rather than inventing a + customer label. - **Entity and abbreviation resolution — open:** canonical names, aliases, multilingual labels, team-vs-organization typing, title-aware person disambiguation, and SearXNG/internal corroboration need end-to-end evidence. @@ -326,8 +404,8 @@ or an explicit unavailable result. ## 6. Next acceptance loop -1. Re-fetch the exact PR head and required reviews/checks for the workspace UI stack, - PR #343, and the superseding calendar contract PR #355. +1. Re-fetch PR #366's exact head and required reviews/checks, then separately + audit PR #343 and the superseding calendar contract PR #355. 2. Run frontend lint, tests, build, Storybook, backend tests, and authenticated browser checks when the local stack is available. 3. Reproduce each case label using synthetic fixtures or authorized runtime diff --git a/frontend/src/App.css b/frontend/src/App.css index d188b03e9..0d0f52596 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -297,6 +297,15 @@ } /* Button Standards (§4.3) */ +button:focus-visible, +select:focus-visible, +input:focus-visible, +textarea:focus-visible, +a:focus-visible { + outline: 2px solid var(--color-focus-border); + outline-offset: 2px; +} + .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); @@ -626,6 +635,29 @@ margin: 1.25rem 0 0; } +.customer-master-scope-filter { + display: grid; + grid-template-columns: auto minmax(12rem, 1fr); + align-items: center; + gap: 0.65rem; + margin-top: 1.25rem; +} + +.customer-master-scope-filter label { + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 700; +} + +.customer-master-scope-filter select { + min-height: 2.75rem; + padding: 0.45rem 0.65rem; + border: 1px solid var(--border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); +} + .customer-master-tree-children { margin-top: 0.55rem; padding-left: 1rem; @@ -658,6 +690,22 @@ font-size: 0.8rem; } +.customer-entity-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.3rem 0.5rem; +} + +.customer-scope-chip { + padding: 0.15rem 0.4rem; + border-radius: 999px; + background: var(--color-accent-background); + color: var(--text-h) !important; + font-size: 0.72rem !important; + font-weight: 700; +} + .customer-related-posts { margin: 0.45rem 0 0.75rem; padding: 0.75rem; @@ -1782,6 +1830,19 @@ grid-column: auto; } + .customer-master-scope-filter { + grid-template-columns: 1fr; + } + + .customer-entity-button { + align-items: flex-start; + flex-direction: column; + } + + .customer-entity-meta { + justify-content: flex-start; + } + .post-list-item { grid-template-columns: 1fr; gap: 0.8rem; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 69d7ca008..d45abc403 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1597,6 +1597,7 @@ describe("App, authenticated", () => { entity_level_code: "group", entity_level_label: "Group", parent_entity_id: null, + scope_facets: ["scope_unclassified"], }, { corporate_entity_id: "corp-demo", @@ -1605,6 +1606,7 @@ describe("App, authenticated", () => { entity_level_code: "company", entity_level_label: "Company", parent_entity_id: "corp-group", + scope_facets: ["authorized_own", "observed_hierarchy"], }, ] : [ @@ -1752,6 +1754,24 @@ describe("App, authenticated", () => { expect(parentRow?.contains(subsidiaryRow)).toBe(true); }); + it("filters the Customer Master tree by the server-provided scope facet", async () => { + stubBackend({ customerEntityHierarchy: true }); + render(); + expect(await screen.findByRole("button", { name: "View post: Public post" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Customer master" })); + + const filter = screen.getByLabelText("Filter customer entities"); + await userEvent.selectOptions(filter, "authorized_own"); + + expect(screen.getByText("Demo Corp")).toBeInTheDocument(); + expect(screen.queryByText("Demo Group")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Demo Corp.*Own company/ })).toBeInTheDocument(); + + await userEvent.selectOptions(filter, "observed_hierarchy"); + expect(screen.getByText("Demo Corp")).toBeInTheDocument(); + expect(screen.queryByText("Demo Group")).not.toBeInTheDocument(); + }); + it("shows every observed relationship role for a counterparty, flagging multi-role names", async () => { // Feature request (2026-08-19): a real counterparty is not limited // to one role -- a customer in one post can be a competitor, @@ -2035,6 +2055,9 @@ describe("App, authenticated", () => { expect(screen.queryByText("Related to Priya Nair")).not.toBeInTheDocument(); const popup = document.querySelector(".popup-panel"); expect(popup).not.toBeNull(); + expect(screen.getByRole("dialog", { name: "Public post" })).toBe(popup); + expect(popup).toHaveAttribute("aria-modal", "true"); + expect(popup).toHaveAttribute("aria-labelledby", "post-detail-title"); const evaluation = within(popup as HTMLElement).getByRole("heading", { name: "Post quality (IRT)", }); @@ -2052,6 +2075,45 @@ describe("App, authenticated", () => { expect(keyman.compareDocumentPosition(ask) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0); }); + it("keeps the post popup keyboard-contained and restores the opener after Escape", async () => { + const user = userEvent.setup(); + stubBackend(); + render(); + + const opener = await screen.findByRole("button", { name: "View post: Public post" }); + await user.click(opener); + const dialog = await screen.findByRole("dialog", { name: "Public post" }); + await waitFor(() => expect(dialog).toHaveFocus()); + + await user.keyboard("{Tab}"); + expect(screen.getByRole("button", { name: "Close" })).toHaveFocus(); + const evidenceSummary = screen.getByText("Evidence provenance"); + for (let step = 0; step < 40 && document.activeElement !== evidenceSummary; step += 1) { + await user.keyboard("{Tab}"); + } + expect(evidenceSummary).toHaveFocus(); + const ariaHiddenTabStop = document.createElement("button"); + ariaHiddenTabStop.setAttribute("aria-hidden", "true"); + dialog.insertBefore(ariaHiddenTabStop, screen.getByRole("button", { name: "Close" }).nextSibling); + await user.keyboard("{Tab}"); + expect(ariaHiddenTabStop).not.toHaveFocus(); + const ariaHiddenGroup = document.createElement("div"); + ariaHiddenGroup.setAttribute("aria-hidden", "true"); + const nestedAriaHiddenTabStop = document.createElement("button"); + ariaHiddenGroup.append(nestedAriaHiddenTabStop); + dialog.insertBefore(ariaHiddenGroup, screen.getByRole("button", { name: "Close" }).nextSibling); + await user.keyboard("{Tab}"); + expect(nestedAriaHiddenTabStop).not.toHaveFocus(); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(dialog).toContainElement(document.activeElement as HTMLElement); + expect((document.activeElement as HTMLElement).closest("details:not([open])")).toBeNull(); + await user.keyboard("{Escape}"); + + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + expect(opener).toHaveFocus(); + }); + it("labels a stale summary and retries the semantic refresh on request", async () => { const fetchMock = stubBackend({ staleSummary: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 40a8be40c..ba6888fef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -58,6 +58,7 @@ import { type CorporateEntityRef, type CustomerMasterEntity, type CustomerMasterResponse, + type CustomerMasterScopeFacet, type Counterparty, type EvaluationResponse, type IssueTicket, @@ -1744,6 +1745,57 @@ function PostDetailPopup({ const [focusEntity, setFocusEntity] = useState<{ entityId: string; entityName: string } | null>(null); const [focusTeam, setFocusTeam] = useState<{ teamId: string; teamName: string } | null>(null); const contentReloadRef = useRef<() => void>(() => undefined); + const popupPanelRef = useRef(null); + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + const panel = popupPanelRef.current; + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + if (!panel) return; + panel.focus({ preventScroll: true }); + + const focusableSelector = + 'a[href], area[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab") return; + const focusable = Array.from(panel.querySelectorAll(focusableSelector)).filter( + (element) => + !element.hidden && + !element.closest('[aria-hidden="true"]') && + (!element.closest("details:not([open])") || element.matches("summary")), + ); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + const currentIndex = focusable.indexOf(document.activeElement as HTMLElement); + const nextIndex = event.shiftKey + ? currentIndex <= 0 + ? focusable.length - 1 + : currentIndex - 1 + : currentIndex < 0 || currentIndex === focusable.length - 1 + ? 0 + : currentIndex + 1; + event.preventDefault(); + focusable[nextIndex].focus(); + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus(); + }; + }, []); function reloadKeymen() { fetchPostKeymen(accessToken, postId) @@ -1919,13 +1971,22 @@ function PostDetailPopup({ return (
-
event.stopPropagation()}> +
event.stopPropagation()} + > {error &&

{error}

} {!post && !error &&

{t("Loading...")}

} {post && ( <> -

{post.post_title}

+

{post.post_title}

{post.voc_type_label ?? post.voc_type_code} ·{" "} {post.visibility_label ?? post.visibility_code} ·{" "} @@ -4206,6 +4267,30 @@ function buildCustomerEntityTree(entities: CustomerMasterEntity[]): CustomerEnti return roots.map(toNode); } +type CustomerMasterScopeFilter = "all" | CustomerMasterScopeFacet; + +function customerScopeFacetLabel(facet: CustomerMasterScopeFacet): string { + switch (facet) { + case "authorized_own": + return t("Own company"); + case "authorized_granted": + return t("Granted company"); + case "scope_unclassified": + return t("Scope not classified"); + case "observed_organization": + return t("Observed organization"); + case "observed_hierarchy": + return t("Observed hierarchy"); + } +} + +function customerEntityMatchesScope( + entity: CustomerMasterEntity, + filter: CustomerMasterScopeFilter, +): boolean { + return filter === "all" || (entity.scope_facets ?? []).includes(filter); +} + function CustomerEntityTreeRow({ node, depth, @@ -4236,7 +4321,12 @@ function CustomerEntityTreeRow({ onClick={() => onToggle(entity.corporate_entity_id)} > {entity.entity_name} - {entity.corporate_entity_code} · {entity.entity_level_label} + + {entity.corporate_entity_code} · {entity.entity_level_label} + {(entity.scope_facets ?? []).map((facet) => ( + {customerScopeFacetLabel(facet)} + ))} + {expandedEntityId === entity.corporate_entity_id ? (

@@ -4327,6 +4417,7 @@ function CustomerMasterPanel({ const [relatedLoading, setRelatedLoading] = useState(null); const [resolvingHint, setResolvingHint] = useState(null); const [resolveError, setResolveError] = useState(null); + const [scopeFilter, setScopeFilter] = useState("all"); // Fetched independently, same pattern as PostList's own canRebuild -- // CustomerMasterPanel is a sibling of PostList under App, not a child, // so it cannot read PostList's local post_admin check. @@ -4358,6 +4449,10 @@ function CustomerMasterPanel({ void loadMaster(); }, [loadMaster]); + const visibleEntities = (master?.corporate_entities ?? []).filter((entity) => + customerEntityMatchesScope(entity, scopeFilter), + ); + async function handleResolveHint(hintCode: string) { setResolvingHint(hintCode); setResolveError(null); @@ -4391,7 +4486,7 @@ function CustomerMasterPanel({ return (
-

{t("Authorized customer scope")}

+

{t("Customer scope")}

{t("Customer master")}

{t("Customer entities available to this account.")}

{error ?

{error}

: null} @@ -4400,20 +4495,41 @@ function CustomerMasterPanel({

{t("No customer entities are connected to this account.")}

) : null} {master && master.corporate_entities.length > 0 ? ( -
    - {buildCustomerEntityTree(master.corporate_entities).map((node) => ( - - ))} -
+ <> +
+ + +
+ {visibleEntities.length > 0 ? ( +
    + {buildCustomerEntityTree(visibleEntities).map((node) => ( + + ))} +
+ ) : ( +

{t("No customer entities match this scope.")}

+ )} + ) : null} {master && (master.relationship_network ?? []).length > 0 ? (
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f30941870..2ed948bbb 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -442,8 +442,16 @@ export interface CustomerMasterEntity extends CorporateEntityRef { entity_level_code: string; entity_level_label: string; parent_entity_id: string | null; + scope_facets?: CustomerMasterScopeFacet[]; } +export type CustomerMasterScopeFacet = + | "authorized_own" + | "authorized_granted" + | "scope_unclassified" + | "observed_organization" + | "observed_hierarchy"; + export interface CustomerMasterKeymanAffiliation { organization_name: string; corporate_entity_id: string | null; diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index e867376f6..14991ca99 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -41,12 +41,21 @@ describe("i18n", () => { "Thinking...", "Open source", "Enter to send. Shift+Enter for a new line.", + "Filter customer entities", + "All customer scopes", + "Own company", + "Granted company", + "Scope not classified", + "Observed organization", + "Observed hierarchy", + "No customer entities match this scope.", "Page", "Answer", "Showing the first {shown} of {total} posts known at this cutoff.", ] as const; const eventLineageLabels = [ "Authorized scope", + "Customer scope", "Open navigation", "Skip to main content", "Lineage legend", diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 8d2a26e88..5d4776ab2 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -140,7 +140,16 @@ const TRANSLATIONS: Partial>> = { "Site map": "사이트맵", "Authorized scope": "권한 범위", "Authorized customer scope": "권한이 있는 고객 범위", + "Customer scope": "고객 범위", "Customer entities available to this account.": "이 계정에서 사용할 수 있는 고객 엔터티입니다.", + "Filter customer entities": "고객 엔터티 필터", + "All customer scopes": "모든 고객 범위", + "Own company": "자사", + "Granted company": "권한 부여된 법인", + "Scope not classified": "범위 미분류", + "Observed organization": "관찰된 조직", + "Observed hierarchy": "관찰된 계층", + "No customer entities match this scope.": "이 범위에 해당하는 고객 엔터티가 없습니다.", "Loading customer master...": "고객 마스터를 불러오는 중...", "Customer master could not be loaded.": "고객 마스터를 불러오지 못했습니다.", "No customer entities are connected to this account.": "이 계정에 연결된 고객 엔터티가 없습니다.", @@ -499,7 +508,16 @@ const TRANSLATIONS: Partial>> = { "Workspace navigation": "工作区导航", "Site map": "站点地图", "Authorized customer scope": "已授权的客户范围", + "Customer scope": "客户范围", "Customer entities available to this account.": "此账户可用的客户实体。", + "Filter customer entities": "筛选客户实体", + "All customer scopes": "所有客户范围", + "Own company": "本公司", + "Granted company": "已授权公司", + "Scope not classified": "范围未分类", + "Observed organization": "观测到的组织", + "Observed hierarchy": "观测到的层级", + "No customer entities match this scope.": "没有符合此范围的客户实体。", "Loading customer master...": "正在加载客户主数据...", "Customer master could not be loaded.": "无法加载客户主数据。", "No customer entities are connected to this account.": "此账户没有连接的客户实体。", @@ -882,7 +900,16 @@ const TRANSLATIONS: Partial>> = { "Workspace navigation": "ワークスペースナビゲーション", "Site map": "サイトマップ", "Authorized customer scope": "許可された顧客範囲", + "Customer scope": "顧客範囲", "Customer entities available to this account.": "このアカウントで利用できる顧客エンティティです。", + "Filter customer entities": "顧客エンティティを絞り込む", + "All customer scopes": "すべての顧客範囲", + "Own company": "自社", + "Granted company": "許可された会社", + "Scope not classified": "範囲未分類", + "Observed organization": "観測された組織", + "Observed hierarchy": "観測された階層", + "No customer entities match this scope.": "この範囲に一致する顧客エンティティはありません。", "Loading customer master...": "顧客マスターを読み込んでいます...", "Customer master could not be loaded.": "顧客マスターを読み込めませんでした。", "No customer entities are connected to this account.": "このアカウントに接続された顧客エンティティはありません。", @@ -1241,7 +1268,16 @@ const TRANSLATIONS: Partial>> = { "Workspace navigation": "Điều hướng không gian làm việc", "Site map": "Sơ đồ trang", "Authorized customer scope": "Phạm vi khách hàng được cấp quyền", + "Customer scope": "Phạm vi khách hàng", "Customer entities available to this account.": "Các thực thể khách hàng mà tài khoản này được phép sử dụng.", + "Filter customer entities": "Lọc thực thể khách hàng", + "All customer scopes": "Tất cả phạm vi khách hàng", + "Own company": "Công ty của chúng ta", + "Granted company": "Công ty được cấp quyền", + "Scope not classified": "Phạm vi chưa phân loại", + "Observed organization": "Tổ chức được quan sát", + "Observed hierarchy": "Cấp bậc được quan sát", + "No customer entities match this scope.": "Không có thực thể khách hàng nào khớp với phạm vi này.", "Loading customer master...": "Đang tải danh mục khách hàng...", "Customer master could not be loaded.": "Không thể tải danh mục khách hàng.", "No customer entities are connected to this account.": "Tài khoản này chưa được kết nối với thực thể khách hàng nào.", diff --git a/frontend/src/styles/tokens.test.ts b/frontend/src/styles/tokens.test.ts index 54432b43a..744769a2a 100644 --- a/frontend/src/styles/tokens.test.ts +++ b/frontend/src/styles/tokens.test.ts @@ -70,4 +70,10 @@ describe("design tokens", () => { expect(appCss, `App.css never references var(${token})`).toContain(`var(${token})`); } }); + + it("gives native interactive controls a shared visible focus token", () => { + expect(appCss).toContain("button:focus-visible"); + expect(appCss).toContain("outline: 2px solid var(--color-focus-border);"); + expect(appCss).toContain("outline-offset: 2px;"); + }); }); diff --git a/migrations/0105_customer_master_scope_facets.sql b/migrations/0105_customer_master_scope_facets.sql new file mode 100644 index 000000000..0c96a1d18 --- /dev/null +++ b/migrations/0105_customer_master_scope_facets.sql @@ -0,0 +1,18 @@ +begin; + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) +values + ('account_affiliation_scope', 'scope_own_entity', 'Own company', 0), + ('account_affiliation_scope', 'scope_granted_entity', 'Granted company', 1), + ('account_affiliation_scope', 'scope_unclassified', 'Scope not classified', 2) +on conflict (lookup_code) do nothing; + +alter table account_affiliation + add column if not exists affiliation_scope_code text + not null default 'scope_unclassified' + references common_lookup_value (lookup_code); + +create index if not exists account_affiliation_scope_idx + on account_affiliation (user_account_id, affiliation_scope_code, corporate_entity_id); + +commit; diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 3c84ba8b8..7b32154c4 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -231,8 +231,9 @@ def seed( account_ids[username] = account_id cur.execute( - "insert into account_affiliation (user_account_id, corporate_entity_id, process_unit_id) " - "values (%s, %s, %s) on conflict do nothing", + "insert into account_affiliation " + "(user_account_id, corporate_entity_id, process_unit_id, affiliation_scope_code) " + "values (%s, %s, %s, 'scope_own_entity') on conflict do nothing", (account_id, corporate_entity_id, process_units[pu_code]), ) cur.execute( diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 1e10dbf6c..26155da28 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -69,3 +69,15 @@ def test_migrate_sh_replays_database_identifier_migration_on_existing_volumes() ).read_text(encoding="utf-8") assert "0104_*" in script + + +def test_migrate_sh_replays_customer_master_scope_migration_on_existing_volumes() -> None: + """Existing Compose volumes must receive Customer Master scope facets.""" + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0105_*" in script diff --git a/tests/test_post_eligibility.py b/tests/test_post_eligibility.py index 336820e3d..8a7602c93 100644 --- a/tests/test_post_eligibility.py +++ b/tests/test_post_eligibility.py @@ -1,6 +1,7 @@ from backend.app.post_eligibility import ( SOURCE_CONTEXT_COLUMNS, SOURCE_POST_ELIGIBILITY_SQL, + SOURCE_POST_VISIBILITY_SQL, source_context_missing_sql, source_context_present_sql, ) @@ -16,3 +17,14 @@ def test_real_source_context_hides_pure_seed_rows_at_read_boundary() -> None: for column in SOURCE_CONTEXT_COLUMNS: assert f"post.{column}" in source_context_missing_sql("post") assert f"real_post.{column}" in source_context_present_sql("real_post") + + +def test_visibility_sql_is_shared_public_or_affiliated_abac_projection() -> None: + visibility = SOURCE_POST_VISIBILITY_SQL.format( + alias="post", authorized_entity_ids="$2" + ) + + assert visibility == ( + "(post.visibility_code = 'public' or " + "post.corporate_entity_id = any($2::uuid[]))" + ) diff --git a/tests/test_schema.py b/tests/test_schema.py index 076ebb5c9..6e8b6196a 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -51,6 +51,11 @@ / "migrations" / "0104_two_word_database_identifiers.sql" ) +_CUSTOMER_MASTER_SCOPE_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0105_customer_master_scope_facets.sql" +) def _postgres_available() -> bool: @@ -91,6 +96,8 @@ def schema_db(): identifier_migration = _IDENTIFIER_MIGRATION.read_text() cur.execute(identifier_migration) cur.execute(identifier_migration) + cur.execute(_CUSTOMER_MASTER_SCOPE_MIGRATION.read_text()) + cur.execute(_CUSTOMER_MASTER_SCOPE_MIGRATION.read_text()) conn.commit() yield conn finally: @@ -249,6 +256,38 @@ def test_invalid_lookup_code_is_rejected_by_a_real_foreign_key(schema_db) -> Non schema_db.rollback() +def test_account_affiliation_scope_facets_are_lookup_backed(schema_db) -> None: + """Own/granted/unknown facets are data, not an authorization bypass.""" + with schema_db.cursor() as cur: + cur.execute( + """ + select column_name, is_nullable, column_default + from information_schema.columns + where table_schema = 'public' + and table_name = 'account_affiliation' + and column_name = 'affiliation_scope_code' + """ + ) + assert cur.fetchone() == ( + "affiliation_scope_code", + "NO", + "'scope_unclassified'::text", + ) + cur.execute( + """ + select lookup_code + from common_lookup_value + where lookup_category = 'account_affiliation_scope' + order by lookup_code + """ + ) + assert [row[0] for row in cur.fetchall()] == [ + "scope_granted_entity", + "scope_own_entity", + "scope_unclassified", + ] + + def test_lookup_code_is_unique_across_categories(schema_db) -> None: """The deliberate simplification documented in the migration: a single- column FK to common_lookup_value requires lookup_code to be globally diff --git a/tests/test_static_sql_review_contracts.py b/tests/test_static_sql_review_contracts.py index 7269e6acf..31c7896bc 100644 --- a/tests/test_static_sql_review_contracts.py +++ b/tests/test_static_sql_review_contracts.py @@ -28,7 +28,7 @@ ) ASYNC_STATEMENT_METHODS = {"execute", "fetch", "fetchrow", "fetchval"} SQL_REVIEW_RULE = "python.lang.security.audit.sqli.asyncpg-sqli.asyncpg-sqli" -EXPECTED_SQL_SUPPRESSION_COUNT = 35 +EXPECTED_SQL_SUPPRESSION_COUNT = 36 @pytest.mark.parametrize("relative_path", SQL_REVIEW_PATHS)