Implement identity canon alignment

This commit is contained in:
2026-06-05 16:04:43 +02:00
parent fe446711de
commit c6d211f472
15 changed files with 1008 additions and 21 deletions

View File

@@ -11,6 +11,8 @@ from user_engine.domain.models import (
AuthorizationDecision,
AuthorizationEffect,
AuthorizationRequest,
CanonEntityReference,
CanonRelationshipReference,
Catalog,
CatalogLifecycle,
ExternalIdentity,
@@ -41,6 +43,8 @@ __all__ = [
"AuthorizationDecision",
"AuthorizationEffect",
"AuthorizationRequest",
"CanonEntityReference",
"CanonRelationshipReference",
"Catalog",
"CatalogLifecycle",
"ExternalIdentity",

View File

@@ -99,6 +99,27 @@ class AuthorizationEffect(StrEnum):
NOT_APPLICABLE = "not_applicable"
@dataclass(frozen=True)
class CanonEntityReference:
concept: str
identifier: str
scope: str | None = None
source_system: str = "user-engine"
local_type: str | None = None
evidence_id: str | None = None
@dataclass(frozen=True)
class CanonRelationshipReference:
relationship_type: str
source: CanonEntityReference
target: CanonEntityReference
scope: str | None = None
source_system: str = "user-engine"
evidence_id: str | None = None
lifecycle_state: str = "active"
@dataclass(frozen=True)
class Actor:
issuer: str

View File

@@ -15,6 +15,7 @@ from user_engine.domain import (
AuditRecord,
AuthorizationDecision,
AuthorizationRequest,
CanonEntityReference,
Membership,
OutboxEvent,
)
@@ -76,6 +77,37 @@ class AuditWriter(Protocol):
"""Persist an audit record."""
class EvidenceReferenceExporter(Protocol):
"""Export audit/review material as identity-canon evidence references."""
def export(
self, audit_records: Iterable[AuditRecord]
) -> tuple[CanonEntityReference, ...]:
"""Return evidence references without owning the platform audit sink."""
class PolicyControlReferenceResolver(Protocol):
"""Resolve policy/control references for identity-domain traces."""
def references_for(
self, request: AuthorizationRequest, decision: AuthorizationDecision
) -> Mapping[str, CanonEntityReference]:
"""Return policy, control, review, or exception references when known."""
class LifecycleTaskSink(Protocol):
"""Handoff identity-domain gaps or lifecycle work to a task system."""
def create_or_link(
self,
*,
summary: str,
subject: CanonEntityReference,
evidence: Iterable[CanonEntityReference] = (),
) -> CanonEntityReference:
"""Return the task reference created or linked by the downstream system."""
class SecretProvider(Protocol):
"""Load runtime secret material from the active environment."""

View File

@@ -15,6 +15,8 @@ from user_engine.domain import (
AttributeDefinition,
AuditRecord,
AuthorizationRequest,
CanonEntityReference,
CanonRelationshipReference,
Catalog,
CatalogLifecycle,
ExternalIdentity,
@@ -89,6 +91,23 @@ class Projection:
redactions: Mapping[str, str]
@dataclass(frozen=True)
class IdentityContext:
actor: Actor
user: User
account: Account
identities: tuple[ExternalIdentity, ...]
tenant: str
application_id: str | None
memberships: tuple[Membership, ...]
entity_refs: Mapping[str, CanonEntityReference]
relationship_refs: tuple[CanonRelationshipReference, ...]
grant_like_refs: tuple[CanonEntityReference, ...]
evidence_refs: tuple[CanonEntityReference, ...]
profile: EffectiveProfile | None = None
gaps: tuple[str, ...] = ()
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
@@ -643,6 +662,89 @@ class UserEngineService:
redactions=redactions,
)
def identity_context(
self,
actor: Actor,
*,
user_id: str | None = None,
tenant: str | None = None,
application_id: str | None = None,
include_profile: bool = False,
correlation_id: str | None = None,
) -> IdentityContext:
tenant_context = self.resolve_tenant_context(actor, tenant)
identity = self.store.find_identity(*actor.identity_key)
if user_id is None:
if identity is None:
raise NotFoundError("identity link not found")
user_id = identity.user_id
user = self._require_user(user_id)
account = self._require_account(user_id)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="identity_context.read",
resource_type="user-engine:identity-context",
resource_id=user_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
context={"include_profile": include_profile},
)
identities = tuple(
identity
for identity in self.store.identities.values()
if identity.user_id == user.user_id
)
memberships = self.store.memberships_for_user(
user.user_id, tenant=tenant_context.tenant
)
evidence_refs = self._identity_evidence_refs(
user,
account,
memberships,
tenant_context.tenant,
)
entity_refs = self._identity_entity_refs(
actor,
user,
account,
identities,
memberships,
tenant_context.tenant,
application_id,
)
relationship_refs, grant_like_refs = self._identity_relationship_refs(
entity_refs,
memberships,
tenant_context.tenant,
evidence_refs[0].identifier if evidence_refs else None,
)
profile = (
self._resolve_effective_profile(
user.user_id, application_id, tenant_context.tenant
)
if include_profile
else None
)
gaps = () if evidence_refs else ("evidence:no-audit-records",)
return IdentityContext(
actor=actor,
user=user,
account=account,
identities=identities,
tenant=tenant_context.tenant,
application_id=application_id,
memberships=memberships,
entity_refs=entity_refs,
relationship_refs=relationship_refs,
grant_like_refs=grant_like_refs,
evidence_refs=evidence_refs,
profile=profile,
gaps=gaps,
)
def tenant_diagnostics(
self,
actor: Actor,
@@ -737,6 +839,216 @@ class UserEngineService:
context["actor_subject"] = actor.subject
return context
def _identity_entity_refs(
self,
actor: Actor,
user: User,
account: Account,
identities: tuple[ExternalIdentity, ...],
memberships: tuple[Membership, ...],
tenant: str,
application_id: str | None,
) -> Mapping[str, CanonEntityReference]:
actor_identifier = f"{actor.issuer}:{actor.subject}"
refs: dict[str, CanonEntityReference] = {
"actor": CanonEntityReference(
concept="Actor",
identifier=actor_identifier,
scope=actor.tenant,
source_system=actor.issuer,
local_type=actor.principal_type.value,
),
"user": CanonEntityReference(
concept="User",
identifier=user.user_id,
scope=tenant,
local_type="user-engine-user-record",
),
"account": CanonEntityReference(
concept="Account",
identifier=account.account_id,
scope=tenant,
local_type=account.status.value,
),
"authenticated_subject": CanonEntityReference(
concept="Authenticated Subject",
identifier=actor_identifier,
scope=actor.tenant,
source_system=actor.issuer,
local_type="issuer-subject",
),
"authorization_principal": CanonEntityReference(
concept="Authorization Principal",
identifier=f"{tenant}:{actor_identifier}",
scope=tenant,
local_type=actor.principal_type.value,
),
"tenant": CanonEntityReference(
concept="Tenant",
identifier=tenant,
scope=tenant,
local_type="isolation-scope",
),
}
if application_id is not None:
refs["application_scope"] = CanonEntityReference(
concept="Scope",
identifier=application_id,
scope=tenant,
local_type="application",
)
for identity in identities:
refs[f"identity_record:{identity.identity_id}"] = CanonEntityReference(
concept="Identity Record",
identifier=identity.identity_id,
scope=identity.issuer,
source_system=identity.issuer,
local_type=identity.provider,
)
refs[f"identifier:{identity.identity_id}"] = CanonEntityReference(
concept="Scoped Identifier",
identifier=f"{identity.issuer}:{identity.subject}",
scope=identity.issuer,
source_system=identity.issuer,
local_type="issuer-subject",
)
for membership in memberships:
scope_key = f"{membership.scope_type}:{membership.scope_id}"
refs.setdefault(
scope_key,
CanonEntityReference(
concept=_scope_concept(membership.scope_type),
identifier=membership.scope_id,
scope=tenant,
local_type=membership.scope_type,
),
)
refs[f"membership:{membership.membership_id}"] = CanonEntityReference(
concept="Membership Relationship",
identifier=membership.membership_id,
scope=tenant,
local_type=membership.kind,
)
refs[f"role:{membership.membership_id}"] = CanonEntityReference(
concept="Role",
identifier=f"{membership.scope_id}:{membership.kind}",
scope=membership.scope_id,
local_type=membership.kind,
)
return refs
def _identity_relationship_refs(
self,
entity_refs: Mapping[str, CanonEntityReference],
memberships: tuple[Membership, ...],
tenant: str,
evidence_id: str | None,
) -> tuple[tuple[CanonRelationshipReference, ...], tuple[CanonEntityReference, ...]]:
account_ref = entity_refs["account"]
subject_ref = entity_refs["authenticated_subject"]
principal_ref = entity_refs["authorization_principal"]
user_ref = entity_refs["user"]
tenant_ref = entity_refs["tenant"]
relationships = [
CanonRelationshipReference(
relationship_type="belongs_to_tenant",
source=user_ref,
target=tenant_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="authenticates_as",
source=account_ref,
target=subject_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="evaluated_as",
source=subject_ref,
target=principal_ref,
scope=tenant,
evidence_id=evidence_id,
),
]
grant_like_refs: list[CanonEntityReference] = []
for key, ref in entity_refs.items():
if key.startswith("identity_record:"):
relationships.append(
CanonRelationshipReference(
relationship_type="identity_link",
source=ref,
target=user_ref,
scope=ref.scope,
evidence_id=evidence_id,
)
)
for membership in memberships:
membership_ref = entity_refs[f"membership:{membership.membership_id}"]
role_ref = entity_refs[f"role:{membership.membership_id}"]
scope_ref = entity_refs[f"{membership.scope_type}:{membership.scope_id}"]
grant_ref = CanonEntityReference(
concept="Access Grant",
identifier=membership.membership_id,
scope=tenant,
local_type=membership.kind,
evidence_id=evidence_id,
)
grant_like_refs.append(grant_ref)
relationships.extend(
(
CanonRelationshipReference(
relationship_type="member_of",
source=user_ref,
target=scope_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="role_label",
source=membership_ref,
target=role_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="scoped_to",
source=grant_ref,
target=tenant_ref,
scope=tenant,
evidence_id=evidence_id,
),
)
)
return tuple(relationships), tuple(grant_like_refs)
def _identity_evidence_refs(
self,
user: User,
account: Account,
memberships: tuple[Membership, ...],
tenant: str,
) -> tuple[CanonEntityReference, ...]:
membership_ids = {membership.membership_id for membership in memberships}
subjects = {user.user_id, account.account_id, *membership_ids}
evidence = []
for record in self.store.audit_records:
if record.tenant != tenant:
continue
if record.subject not in subjects and record.summary != "membership.added":
continue
evidence.append(
CanonEntityReference(
concept="Evidence Source",
identifier=record.audit_id,
scope=tenant,
source_system="user-engine:audit",
local_type=record.summary or record.action,
)
)
return tuple(evidence)
def _session(self, actor: Actor, user: User, account: Account) -> UserSession:
identities = tuple(
identity
@@ -1089,6 +1401,16 @@ def _optional_claim(actor: Actor, key: str) -> str | None:
return str(value)
def _scope_concept(scope_type: str) -> str:
concepts = {
"team": "Team",
"tenant": "Tenant",
"application": "Scope",
"group": "Group",
}
return concepts.get(scope_type, "Scope")
def _visible_in_projection(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool: