generated from coulomb/repo-seed
Implement identity canon alignment
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user