feat: centralize access explanation subject selection
This commit is contained in:
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user