feat: centralize access explanation subject selection

This commit is contained in:
2026-08-20 06:16:43 +02:00
parent 0aae6f0539
commit fd90b60430
6 changed files with 163 additions and 2 deletions
+24
View File
@@ -24,6 +24,9 @@ CAPABILITY_ACCESS_TENANT_PROVISIONER = "access.tenantProvisioner"
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner" CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER = "access.firstAdminProvisioner"
CAPABILITY_ACCESS_ADMINISTRATION = "access.administration" CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer" CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
"policy.access_explanation_subjects"
)
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver" CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
CAPABILITY_AUDIT_SINK = "audit.sink" CAPABILITY_AUDIT_SINK = "audit.sink"
CAPABILITY_AUDIT_RECORDER = "audit.recorder" 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: def _optional_str(value: object | None) -> str | None:
return str(value) if value is not None else 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 @runtime_checkable
class TenantAccessProvisioner(Protocol): class TenantAccessProvisioner(Protocol):
def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]: def ensure_default_roles(self, session: object, tenant: object | None = None) -> Mapping[str, object]:
+22
View File
@@ -32,6 +32,8 @@ from govoplan_core.core.access import (
AccessDirectory, AccessDirectory,
AccessDecisionProvenance, AccessDecisionProvenance,
AccessExplanationService, AccessExplanationService,
AccessExplanationSubjectDecision,
AccessExplanationSubjectPolicy,
AccessGovernanceMaterializer, AccessGovernanceMaterializer,
ResourceAccessExplanationProvider, ResourceAccessExplanationProvider,
AccessSemanticDirectory, AccessSemanticDirectory,
@@ -283,6 +285,22 @@ class _FakeAccessExplanationService:
return self.provenance 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: class _FakeTenantAccessProvisioner:
def ensure_default_roles(self, session: object, tenant: object | None = None): def ensure_default_roles(self, session: object, tenant: object | None = None):
del session, tenant del session, tenant
@@ -612,6 +630,10 @@ class AccessContractTests(unittest.TestCase):
self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider) self.assertIsInstance(_FakeResourceAccessExplanationProvider(), ResourceAccessExplanationProvider)
self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider) self.assertIsInstance(_FakeFileAccessProvider(), FileAccessProvider)
self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService) self.assertIsInstance(_FakeAccessExplanationService(), AccessExplanationService)
self.assertIsInstance(
_FakeAccessExplanationSubjectPolicy(),
AccessExplanationSubjectPolicy,
)
self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner) self.assertIsInstance(_FakeTenantAccessProvisioner(), TenantAccessProvisioner)
self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration) self.assertIsInstance(_FakeAccessAdministration(), AccessAdministration)
self.assertIsInstance(_FakeAccessGovernanceMaterializer(), AccessGovernanceMaterializer) self.assertIsInstance(_FakeAccessGovernanceMaterializer(), AccessGovernanceMaterializer)
+21
View File
@@ -4,12 +4,16 @@ import type {
AccessDecisionProvenanceItem, AccessDecisionProvenanceItem,
ResourceAccessExplanationOptions, ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse, ResourceAccessExplanationResponse,
ResourceAccessExplanationSubjectsOptions,
ResourceAccessExplanationSubjectsResponse,
ResourceAccessExplanationUser ResourceAccessExplanationUser
} from "./resourceAccessContracts"; } from "./resourceAccessContracts";
export type { export type {
AccessDecisionProvenanceItem, AccessDecisionProvenanceItem,
ResourceAccessExplanationOptions, ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse, ResourceAccessExplanationResponse,
ResourceAccessExplanationSubjectsOptions,
ResourceAccessExplanationSubjectsResponse,
ResourceAccessExplanationUser ResourceAccessExplanationUser
} from "./resourceAccessContracts"; } from "./resourceAccessContracts";
@@ -33,3 +37,20 @@ export function fetchResourceAccessExplanation<
tenant_id: options.tenantId 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<ResourceAccessExplanationSubjectsResponse<TUser>> {
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation/subjects", {
tenant_id: options.tenantId,
query: options.query
}));
}
+16
View File
@@ -25,6 +25,22 @@ export type ResourceAccessExplanationResponse<
provenance: TProvenance[]; 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 = { export type ResourceAccessExplanationOptions = {
userId: string; userId: string;
resourceType: string; resourceType: string;
@@ -1,21 +1,62 @@
import ContentGrid, { FormGrid } from "./ContentGrid"; 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 = { export type ResourceAccessExplanationProps = {
loading?: boolean; loading?: boolean;
explanation?: ResourceAccessExplanationResponse | null; explanation?: ResourceAccessExplanationResponse | null;
subjects?: ResourceAccessExplanationSubjectsResponse | null;
selectedUserId?: string;
onSelectedUserIdChange?: (userId: string) => void;
fallbackResourceLabel?: string; fallbackResourceLabel?: string;
}; };
export default function ResourceAccessExplanation({ export default function ResourceAccessExplanation({
loading = false, loading = false,
explanation, explanation,
subjects,
selectedUserId,
onSelectedUserIdChange,
fallbackResourceLabel = "" fallbackResourceLabel = ""
}: ResourceAccessExplanationProps) { }: ResourceAccessExplanationProps) {
if (loading) { if (loading) {
return <p className="muted small-note">i18n:govoplan-core.loading_access_explanation.04a7c934</p>; return <p className="muted small-note">i18n:govoplan-core.loading_access_explanation.04a7c934</p>;
} }
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 ? (
<div data-resource-access-subject-mode={subjects.mode}>
{selectable ? (
<FormField
label="i18n:govoplan-core.explanation_subject.6ff46d22"
helpContextId="access.resource-explanation.subject"
helpModuleId="access"
>
<select
value={selectedSubjectId}
onChange={(event) => onSelectedUserIdChange?.(event.target.value)}
>
{subjects.users.map((user) => (
<option key={user.id} value={user.id}>
{user.display_name || user.email || user.id}
</option>
))}
</select>
</FormField>
) : null}
<p className="muted small-note">{subjects.reason}</p>
</div>
) : null;
if (!explanation) return subjectControl;
const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id; const userLabel = explanation.user.display_name || explanation.user.email || explanation.user.id;
const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label || const resourceLabel = explanation.provenance.find((item) => item.kind === "resource")?.label ||
@@ -24,6 +65,7 @@ export default function ResourceAccessExplanation({
return ( return (
<> <>
{subjectControl}
<FormGrid columns={2} collapseAt="wide" className=""> <FormGrid columns={2} collapseAt="wide" className="">
<div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div> <div><span className="form-label">i18n:govoplan-core.user.9f8a2389</span><p>{userLabel}</p></div>
<div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div> <div><span className="form-label">i18n:govoplan-core.resource.d1c626a9</span><p>{resourceLabel}</p></div>
@@ -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-campaign"), "the shared component does not depend on Campaign wording");
assert(!explanationMarkup.includes("govoplan-files"), "the shared component does not depend on Files wording"); assert(!explanationMarkup.includes("govoplan-files"), "the shared component does not depend on Files wording");
const selectableSubjectMarkup = renderToStaticMarkup(
<ResourceAccessExplanation
explanation={null}
subjects={{
mode: "cross_user",
can_select_other_users: true,
reason: "Policy permits an administrator diagnostic.",
source: "policy.permission",
required_scope: "policy:access_explanation:select_user",
users: [
{ id: "user-1", display_name: "Current Reader" },
{ id: "user-2", email: "other@example.test" }
]
}}
selectedUserId="user-2"
onSelectedUserIdChange={() => undefined}
/>
);
assert(selectableSubjectMarkup.includes("<select"), "the shared component owns the subject picker");
assert(selectableSubjectMarkup.includes("other@example.test"), "permitted subject metadata is rendered");
assert(selectableSubjectMarkup.includes("Policy permits"), "the policy reason remains visible");
const currentUserSubjectMarkup = renderToStaticMarkup(
<ResourceAccessExplanation
subjects={{
mode: "current_user",
can_select_other_users: false,
reason: "Policy limits explanations to the signed-in user.",
source: "policy.permission",
users: [{ id: "user-1", display_name: "Current Reader" }]
}}
/>
);
assert(!currentUserSubjectMarkup.includes("<select"), "current-user mode does not expose a directory picker");
assert(currentUserSubjectMarkup.includes("signed-in user"), "current-user policy is explained");
const emptyMarkup = renderToStaticMarkup( const emptyMarkup = renderToStaticMarkup(
<ResourceAccessExplanation <ResourceAccessExplanation
fallbackResourceLabel="Fallback resource" fallbackResourceLabel="Fallback resource"