From fd90b60430449504c8aed2955cb9cb739029b798 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 06:16:43 +0200 Subject: [PATCH] feat: centralize access explanation subject selection --- src/govoplan_core/core/access.py | 24 ++++++++++ tests/test_access_contracts.py | 22 +++++++++ webui/src/api/resourceAccess.ts | 21 +++++++++ webui/src/api/resourceAccessContracts.ts | 16 +++++++ .../components/ResourceAccessExplanation.tsx | 46 ++++++++++++++++++- .../resource-access-explanation.test.tsx | 36 +++++++++++++++ 6 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/govoplan_core/core/access.py b/src/govoplan_core/core/access.py index 5be8f9d..4297461 100644 --- a/src/govoplan_core/core/access.py +++ b/src/govoplan_core/core/access.py @@ -24,6 +24,9 @@ CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner" CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner" CAPABILITY_ACCESS_ADMINISTRATION = "access.administration" CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer" +CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = ( + "policy.access_explanation_subjects" +) CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver" CAPABILITY_AUDIT_SINK = "audit.sink" CAPABILITY_AUDIT_RECORDER = "audit.recorder" @@ -177,6 +180,15 @@ class PrincipalRef: ) +@dataclass(frozen=True, slots=True) +class AccessExplanationSubjectDecision: + allow_other_users: bool + reason: str + source: str + required_scope: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + def _optional_str(value: object | None) -> str | None: return str(value) if value is not None else None @@ -564,6 +576,18 @@ class AccessExplanationService(Protocol): ... +@runtime_checkable +class AccessExplanationSubjectPolicy(Protocol): + def decide_subject_selection( + self, + session: object, + principal: PrincipalRef, + *, + tenant_id: str, + ) -> AccessExplanationSubjectDecision: + ... + + @runtime_checkable class TenantAccessProvisioner(Protocol): def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]: diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index 8d91a94..f44a97d 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -32,6 +32,8 @@ from govoplan_core.core.access import ( AccessDirectory, AccessDecisionProvenance, AccessExplanationService, + AccessExplanationSubjectDecision, + AccessExplanationSubjectPolicy, AccessGovernanceMaterializer, ResourceAccessExplanationProvider, AccessSemanticDirectory, @@ -283,6 +285,22 @@ class _FakeAccessExplanationService: return self.provenance +class _FakeAccessExplanationSubjectPolicy: + def decide_subject_selection( + self, + session: object, + principal: PrincipalRef, + *, + tenant_id: str, + ) -> AccessExplanationSubjectDecision: + del session, principal, tenant_id + return AccessExplanationSubjectDecision( + allow_other_users=True, + reason="Administrator diagnostic permitted.", + source="test", + ) + + class _FakeTenantAccessProvisioner: def ensure_default_roles(self, session: object, tenant: object | None = None): del session, tenant @@ -612,6 +630,10 @@ class AccessContractTests(unittest.TestCase): self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider) self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider) self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService) + self.assertIsInstance( + _FakeAccessExplanationSubjectPolicy(), + AccessExplanationSubjectPolicy, + ) self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner) self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration) self.assertIsInstance(_FakeAccessGovernanceMaterializer(), AccessGovernanceMaterializer) diff --git a/webui/src/api/resourceAccess.ts b/webui/src/api/resourceAccess.ts index 7798c20..3eea31a 100644 --- a/webui/src/api/resourceAccess.ts +++ b/webui/src/api/resourceAccess.ts @@ -4,12 +4,16 @@ import type { AccessDecisionProvenanceItem, ResourceAccessExplanationOptions, ResourceAccessExplanationResponse, + ResourceAccessExplanationSubjectsOptions, + ResourceAccessExplanationSubjectsResponse, ResourceAccessExplanationUser } from "./resourceAccessContracts"; export type { AccessDecisionProvenanceItem, ResourceAccessExplanationOptions, ResourceAccessExplanationResponse, + ResourceAccessExplanationSubjectsOptions, + ResourceAccessExplanationSubjectsResponse, ResourceAccessExplanationUser } from "./resourceAccessContracts"; @@ -33,3 +37,20 @@ export function fetchResourceAccessExplanation< tenant_id: options.tenantId })); } + +/** + * Returns only the explanation subjects the active policy permits the actor to + * inspect. A current-user-only response deliberately contains no directory + * metadata for other users. + */ +export function fetchResourceAccessExplanationSubjects< + TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser +>( + settings: ApiSettings, + options: ResourceAccessExplanationSubjectsOptions = {} +): Promise> { + return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation/subjects", { + tenant_id: options.tenantId, + query: options.query + })); +} diff --git a/webui/src/api/resourceAccessContracts.ts b/webui/src/api/resourceAccessContracts.ts index 79efdaa..a23c1f9 100644 --- a/webui/src/api/resourceAccessContracts.ts +++ b/webui/src/api/resourceAccessContracts.ts @@ -25,6 +25,22 @@ export type ResourceAccessExplanationResponse< provenance: TProvenance[]; }; +export type ResourceAccessExplanationSubjectsResponse< + TUser extends ResourceAccessExplanationUser = ResourceAccessExplanationUser +> = { + mode: "current_user" | "cross_user"; + can_select_other_users: boolean; + reason: string; + source: string; + required_scope?: string | null; + users: TUser[]; +}; + +export type ResourceAccessExplanationSubjectsOptions = { + tenantId?: string | null; + query?: string | null; +}; + export type ResourceAccessExplanationOptions = { userId: string; resourceType: string; diff --git a/webui/src/components/ResourceAccessExplanation.tsx b/webui/src/components/ResourceAccessExplanation.tsx index d6e9341..960ade1 100644 --- a/webui/src/components/ResourceAccessExplanation.tsx +++ b/webui/src/components/ResourceAccessExplanation.tsx @@ -1,21 +1,62 @@ import ContentGrid, { FormGrid } from "./ContentGrid"; -import type { ResourceAccessExplanationResponse } from "../api/resourceAccessContracts"; +import FormField from "./FormField"; +import type { + ResourceAccessExplanationResponse, + ResourceAccessExplanationSubjectsResponse +} from "../api/resourceAccessContracts"; export type ResourceAccessExplanationProps = { loading?: boolean; explanation?: ResourceAccessExplanationResponse | null; + subjects?: ResourceAccessExplanationSubjectsResponse | null; + selectedUserId?: string; + onSelectedUserIdChange?: (userId: string) => void; fallbackResourceLabel?: string; }; export default function ResourceAccessExplanation({ loading = false, explanation, + subjects, + selectedUserId, + onSelectedUserIdChange, fallbackResourceLabel = "" }: ResourceAccessExplanationProps) { if (loading) { return

i18n:govoplan-core.loading_access_explanation.04a7c934

; } - if (!explanation) return null; + if (!explanation && !subjects) return null; + + const selectable = Boolean( + subjects?.can_select_other_users && onSelectedUserIdChange + ); + const selectedSubjectId = selectedUserId || explanation?.user.id || subjects?.users[0]?.id || ""; + + const subjectControl = subjects ? ( +
+ {selectable ? ( + + + + ) : null} +

{subjects.reason}

+
+ ) : null; + + if (!explanation) return subjectControl; const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id; const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || @@ -24,6 +65,7 @@ export default function ResourceAccessExplanation({ return ( <> + {subjectControl}
i18n:govoplan-core.user.9f8a2389

{userLabel}

i18n:govoplan-core.resource.d1c626a9

{resourceLabel}

diff --git a/webui/tests/resource-access-explanation.test.tsx b/webui/tests/resource-access-explanation.test.tsx index 67ccb55..a7111fd 100644 --- a/webui/tests/resource-access-explanation.test.tsx +++ b/webui/tests/resource-access-explanation.test.tsx @@ -47,6 +47,42 @@ assert(explanationMarkup.includes("i18n:govoplan-core.no_source.6dcf9723"), "mis assert(!explanationMarkup.includes("govoplan-campaign"), "the shared component does not depend on Campaign wording"); assert(!explanationMarkup.includes("govoplan-files"), "the shared component does not depend on Files wording"); +const selectableSubjectMarkup = renderToStaticMarkup( + undefined} + /> +); +assert(selectableSubjectMarkup.includes(" +); +assert(!currentUserSubjectMarkup.includes("