feat: define bulk governance projection contract

This commit is contained in:
2026-08-20 19:46:53 +02:00
parent 604f20eed7
commit 8e687c4420
3 changed files with 212 additions and 0 deletions
+90
View File
@@ -24,6 +24,7 @@ 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_ACCESS_GOVERNANCE_PROJECTION_V1 = "access.governanceProjection.v1"
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS = (
"policy.access_explanation_subjects"
)
@@ -52,6 +53,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_ACCESS_FIRST_ADMIN_PROVISIONER,
CAPABILITY_ACCESS_ADMINISTRATION,
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
CAPABILITY_ACCESS_GOVERNANCE_PROJECTION_V1,
CAPABILITY_TENANCY_TENANT_RESOLVER,
CAPABILITY_AUDIT_SINK,
CAPABILITY_AUDIT_RECORDER,
@@ -390,6 +392,82 @@ class GovernanceTemplateMaterialization:
required: bool = False
GovernanceProjectionOperation = Literal["upsert", "remove"]
GovernanceProjectionStatus = Literal[
"created",
"updated",
"unchanged",
"removed",
"absent",
"blocked",
"failed",
]
@dataclass(frozen=True, slots=True)
class GovernanceProjectionCommand:
"""Stable Access-owned input for one governance assignment projection."""
assignment_id: str
operation: GovernanceProjectionOperation
template: GovernanceTemplateMaterialization
provenance: Mapping[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.assignment_id or len(self.assignment_id) > 255:
raise ValueError("Governance projection assignment ids must contain at most 255 characters.")
if len(self.provenance) > 20:
raise ValueError("Governance projection provenance supports at most 20 entries.")
for key, value in self.provenance.items():
if not key or len(key) > 100 or len(value) > 500:
raise ValueError("Governance projection provenance entries exceed their bounds.")
@dataclass(frozen=True, slots=True)
class GovernanceProjectionBatch:
"""Versioned, bounded reconciliation request independent of Admin internals."""
operation_id: str
commands: tuple[GovernanceProjectionCommand, ...]
version: Literal["1"] = "1"
dry_run: bool = False
def __post_init__(self) -> None:
if not self.operation_id or len(self.operation_id) > 255:
raise ValueError("Governance projection operation ids must contain at most 255 characters.")
if not self.commands or len(self.commands) > 500:
raise ValueError("Governance projection batches must contain between 1 and 500 commands.")
assignment_ids = [command.assignment_id for command in self.commands]
if len(assignment_ids) != len(set(assignment_ids)):
raise ValueError("Governance projection assignment ids must be unique within a batch.")
@dataclass(frozen=True, slots=True)
class GovernanceProjectionOutcome:
assignment_id: str
template_id: str
tenant_id: str
kind: Literal["group", "role"]
operation: GovernanceProjectionOperation
status: GovernanceProjectionStatus
resource_id: str | None = None
blocker_codes: tuple[str, ...] = ()
message: str | None = None
provenance: Mapping[str, str] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class GovernanceProjectionResult:
operation_id: str
outcomes: tuple[GovernanceProjectionOutcome, ...]
version: Literal["1"] = "1"
dry_run: bool = False
@property
def blocked(self) -> tuple[GovernanceProjectionOutcome, ...]:
return tuple(item for item in self.outcomes if item.status in {"blocked", "failed"})
@dataclass(frozen=True, slots=True)
class AuditEvent:
event_type: str
@@ -693,6 +771,18 @@ class AccessGovernanceMaterializer(Protocol):
...
@runtime_checkable
class AccessGovernanceProjectionV1(Protocol):
"""Bulk reconciliation boundary for Admin-owned governance assignments."""
def reconcile(
self,
session: object,
batch: GovernanceProjectionBatch,
) -> GovernanceProjectionResult:
...
@runtime_checkable
class AuditSink(Protocol):
def record(self, event: AuditEvent) -> None: