feat: implement access profiles and hats

This commit is contained in:
tegwick 2026-06-15 23:12:25 +02:00
parent 97cd03b551
commit 660ce24995
14 changed files with 1329 additions and 20 deletions

View file

@ -2,13 +2,18 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import Any, Iterable, Mapping
from user_engine.domain import (
Account,
AccessControlFact,
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
AccountStatus,
ActiveAccessContext,
Actor,
Application,
ApplicationBinding,
@ -117,6 +122,7 @@ class Projection:
application_id: str | None
values: Mapping[str, Any]
redactions: Mapping[str, str]
access_context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
@ -134,6 +140,31 @@ class IdentityContext:
evidence_refs: tuple[CanonEntityReference, ...]
profile: EffectiveProfile | None = None
gaps: tuple[str, ...] = ()
active_access_context: ActiveAccessContext | None = None
access_control_facts: tuple[AccessControlFact, ...] = ()
@dataclass(frozen=True)
class AccessProfileSelection:
profile: AccessProfile
active_context: ActiveAccessContext
access_control_facts: tuple[AccessControlFact, ...]
@dataclass(frozen=True)
class AccessControlFactExport:
tenant: str
facts: tuple[AccessControlFact, ...]
manifest: Mapping[str, Any]
@dataclass(frozen=True)
class AccessProfileDiagnostics:
tenant: str
profile_count: int
active_context_count: int
required_factor_types: Mapping[str, tuple[str, ...]]
issues: tuple[str, ...]
@dataclass(frozen=True)
@ -959,6 +990,270 @@ class UserEngineService:
onboarding_journeys=onboarding_journeys,
)
def register_access_profile(
self,
actor: Actor,
profile: AccessProfile,
*,
correlation_id: str | None = None,
) -> AccessProfile:
tenant_context = self.resolve_tenant_context(actor, profile.tenant)
self._validate_access_profile(profile, tenant_context.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="access_profile.write",
resource_type="user-engine:access-profile",
resource_id=profile.access_profile_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
context={
"hat": profile.hat,
"scope_type": profile.scope_type.value,
"scope_id": _access_profile_scope_id(profile),
"required_factor_types": tuple(
factor_type.value for factor_type in profile.required_factor_types
),
},
)
updated = replace(profile, tenant=tenant_context.tenant, updated_at=utc_now())
with self.store.transaction():
self.store.save_access_profile(updated)
self._record_mutation(
actor,
action="access_profile.write",
subject=updated.access_profile_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="access_profile.registered",
aggregate_id=updated.access_profile_id,
payload={
"access_profile_id": updated.access_profile_id,
"hat": updated.hat,
"scope_type": updated.scope_type,
"scope_id": _access_profile_scope_id(updated),
"required_factor_types": tuple(
factor_type.value for factor_type in updated.required_factor_types
),
"membership_requirement_count": len(
updated.membership_requirements
),
},
)
return updated
def list_access_profiles(
self,
actor: Actor,
*,
tenant: str,
correlation_id: str | None = None,
) -> tuple[AccessProfile, ...]:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="access_profile.read",
resource_type="user-engine:access-profile",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
return self.store.access_profiles_for_tenant(tenant_context.tenant)
def select_active_hat(
self,
actor: Actor,
user_id: str,
access_profile_id: str,
*,
tenant: str | None = None,
correlation_id: str | None = None,
) -> AccessProfileSelection:
profile = self._require_access_profile(access_profile_id)
tenant_context = self.resolve_tenant_context(actor, tenant or profile.tenant)
if profile.tenant != tenant_context.tenant:
raise AuthorizationDenied("access profile tenant mismatch")
if profile.requires_approval:
raise AuthorizationDenied("access profile requires approval")
user = self._require_user(user_id)
account = self._require_account(user.user_id)
tenant_account = self.store.tenant_account(tenant_context.tenant, user.user_id)
if tenant_account is None or tenant_account.status != AccountStatus.ACTIVE:
raise ValidationError("active tenant account is required")
memberships = self.store.memberships_for_user(
user.user_id, tenant=tenant_context.tenant
)
matched_memberships = self._satisfying_access_memberships(
profile, memberships
)
factor_ids = self._verified_factor_ids_for_user(
user.user_id, profile.required_factor_types
)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="active_access_context.select",
resource_type="user-engine:active-access-context",
resource_id=user.user_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=user.user_id,
context={
"access_profile_id": profile.access_profile_id,
"hat": profile.hat,
"scope_type": profile.scope_type.value,
"scope_id": _access_profile_scope_id(profile),
"membership_ids": tuple(
membership.membership_id for membership in matched_memberships
),
"required_factor_types": tuple(
factor_type.value for factor_type in profile.required_factor_types
),
},
)
group_ids = _active_group_ids(profile, memberships)
active_context = ActiveAccessContext(
user_id=user.user_id,
tenant=tenant_context.tenant,
access_profile_id=profile.access_profile_id,
hat=profile.hat,
scope_type=profile.scope_type,
scope_id=_access_profile_scope_id(profile),
realm_id=profile.realm_id,
service_id=profile.service_id,
asset_id=profile.asset_id,
membership_ids=tuple(
membership.membership_id for membership in matched_memberships
),
factor_ids=factor_ids,
group_ids=group_ids,
claims=dict(profile.claims),
profile_defaults=dict(profile.profile_defaults),
selected_by_subject=actor.subject,
)
facts = self._access_control_facts(
tenant_context.tenant,
user_id=user.user_id,
memberships=memberships,
active_context=active_context,
)
with self.store.transaction():
self.store.save_active_access_context(active_context)
self._record_mutation(
actor,
action="active_access_context.select",
subject=active_context.active_context_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="active_access_context.selected",
aggregate_id=user.user_id,
payload={
"active_context_id": active_context.active_context_id,
"user_id": user.user_id,
"access_profile_id": profile.access_profile_id,
"hat": profile.hat,
"scope_type": profile.scope_type,
"scope_id": active_context.scope_id,
"membership_ids": active_context.membership_ids,
"factor_types": tuple(
factor_type.value for factor_type in profile.required_factor_types
),
},
)
return AccessProfileSelection(
profile=profile,
active_context=active_context,
access_control_facts=facts,
)
def export_access_control_facts(
self,
actor: Actor,
*,
tenant: str,
user_id: str | None = None,
correlation_id: str | None = None,
) -> AccessControlFactExport:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="access_control_facts.export",
resource_type="user-engine:access-control-facts",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
target_user_id=user_id,
)
memberships = (
self.store.memberships_for_user(user_id, tenant=tenant_context.tenant)
if user_id is not None
else self.store.memberships_for_tenant(tenant_context.tenant)
)
active_context = (
self.store.active_access_context(user_id, tenant_context.tenant)
if user_id is not None
else None
)
facts = self._access_control_facts(
tenant_context.tenant,
user_id=user_id,
memberships=memberships,
active_context=active_context,
)
manifest = {
"tenant": tenant_context.tenant,
"fact_count": len(facts),
"scope_types": tuple(sorted({fact.scope_type for fact in facts})),
"subject_types": tuple(sorted({fact.subject_type for fact in facts})),
}
return AccessControlFactExport(
tenant=tenant_context.tenant,
facts=facts,
manifest=manifest,
)
def access_profile_diagnostics(
self,
actor: Actor,
*,
tenant: str,
correlation_id: str | None = None,
) -> AccessProfileDiagnostics:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="access_profile.diagnostics.read",
resource_type="user-engine:access-profile",
resource_id=tenant_context.tenant,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
profiles = self.store.access_profiles_for_tenant(tenant_context.tenant)
active_contexts = self.store.active_access_contexts_for_tenant(
tenant_context.tenant
)
issues = []
for profile in profiles:
if profile.requires_approval:
issues.append(f"approval-required:{profile.access_profile_id}")
return AccessProfileDiagnostics(
tenant=tenant_context.tenant,
profile_count=len(profiles),
active_context_count=len(active_contexts),
required_factor_types={
profile.access_profile_id: tuple(
factor_type.value for factor_type in profile.required_factor_types
)
for profile in profiles
},
issues=tuple(issues),
)
def me(
self,
claims: Mapping[str, Any],
@ -1451,6 +1746,11 @@ class UserEngineService:
application_id=application_id,
values=values,
redactions=redactions,
access_context=self._projection_access_context(
user_id,
tenant_context.tenant,
application_id=application_id,
),
)
def identity_context(
@ -1487,11 +1787,21 @@ class UserEngineService:
memberships = self.store.memberships_for_user(
user.user_id, tenant=tenant_context.tenant
)
active_access_context = self.store.active_access_context(
user.user_id, tenant_context.tenant
)
evidence_refs = self._identity_evidence_refs(
user,
account,
memberships,
tenant_context.tenant,
active_access_context=active_access_context,
)
access_control_facts = self._access_control_facts(
tenant_context.tenant,
user_id=user.user_id,
memberships=memberships,
active_context=active_access_context,
)
entity_refs = self._identity_entity_refs(
actor,
@ -1501,12 +1811,14 @@ class UserEngineService:
memberships,
tenant_context.tenant,
application_id,
active_access_context,
)
relationship_refs, grant_like_refs = self._identity_relationship_refs(
entity_refs,
memberships,
tenant_context.tenant,
evidence_refs[0].identifier if evidence_refs else None,
active_access_context=active_access_context,
)
profile = (
self._resolve_effective_profile(
@ -1530,6 +1842,8 @@ class UserEngineService:
evidence_refs=evidence_refs,
profile=profile,
gaps=gaps,
active_access_context=active_access_context,
access_control_facts=access_control_facts,
)
def onboard_family_dataspace(
@ -2388,6 +2702,199 @@ class UserEngineService:
)
)
def _require_access_profile(self, access_profile_id: str) -> AccessProfile:
profile = self.store.access_profile(access_profile_id)
if profile is None:
raise NotFoundError("access profile not found")
return profile
def _validate_access_profile(self, profile: AccessProfile, tenant: str) -> None:
if profile.tenant != tenant:
raise ValidationError("access profile tenant must match context")
if not profile.display_name.strip():
raise ValidationError("access profile display_name is required")
if not profile.hat.strip():
raise ValidationError("access profile hat is required")
if profile.scope_type != AccessScopeType.TENANT and not profile.scope_id:
raise ValidationError("access profile scope_id is required")
if not profile.membership_requirements:
raise ValidationError(
"access profile requires at least one membership requirement"
)
for requirement in profile.membership_requirements:
if (
not requirement.scope_type.strip()
or not requirement.scope_id.strip()
or not requirement.kind.strip()
):
raise ValidationError("membership requirements must be complete")
def _satisfying_access_memberships(
self,
profile: AccessProfile,
memberships: tuple[Membership, ...],
) -> tuple[Membership, ...]:
matched = []
for requirement in profile.membership_requirements:
match = next(
(
membership
for membership in memberships
if membership.scope_type == requirement.scope_type
and membership.scope_id == requirement.scope_id
and membership.kind == requirement.kind
),
None,
)
if match is None:
raise ValidationError(
"access profile membership requirements are not met"
)
matched.append(match)
return tuple(matched)
def _verified_factor_ids_for_user(
self,
user_id: str,
required_factor_types: tuple[IdentityFactorType, ...],
) -> tuple[str, ...]:
if not required_factor_types:
return ()
now = utc_now()
factors = self.store.factors_for_user(user_id)
factor_ids = []
for required_type in required_factor_types:
match = next(
(
factor
for factor in factors
if factor.factor_type == required_type
and (factor.expires_at is None or factor.expires_at > now)
),
None,
)
if match is None:
raise ValidationError("access profile factor requirements are not met")
factor_ids.append(match.factor_id)
return tuple(factor_ids)
def _access_control_facts(
self,
tenant: str,
*,
user_id: str | None,
memberships: tuple[Membership, ...],
active_context: ActiveAccessContext | None,
) -> tuple[AccessControlFact, ...]:
facts: list[AccessControlFact] = []
active_membership_ids = (
set(active_context.membership_ids) if active_context is not None else set()
)
for membership in memberships:
is_active = membership.membership_id in active_membership_ids
facts.append(
AccessControlFact(
tenant=tenant,
subject_type="group"
if membership.scope_type == AccessScopeType.GROUP.value
else "user",
subject_id=membership.scope_id
if membership.scope_type == AccessScopeType.GROUP.value
else membership.user_id,
user_id=membership.user_id,
scope_type=membership.scope_type,
scope_id=membership.scope_id,
role=membership.kind,
hat=active_context.hat if is_active and active_context else None,
access_profile_id=active_context.access_profile_id
if is_active and active_context
else None,
membership_id=membership.membership_id,
evidence_refs=self._access_fact_evidence_refs(
tenant, {membership.membership_id}
),
)
)
if active_context is not None:
evidence_refs = self._access_fact_evidence_refs(
tenant, {active_context.active_context_id}
)
facts.append(
AccessControlFact(
tenant=tenant,
subject_type="user",
subject_id=active_context.user_id,
user_id=active_context.user_id,
scope_type=active_context.scope_type.value,
scope_id=active_context.scope_id,
role=active_context.hat,
hat=active_context.hat,
access_profile_id=active_context.access_profile_id,
evidence_refs=evidence_refs,
)
)
for group_id in active_context.group_ids:
facts.append(
AccessControlFact(
tenant=tenant,
subject_type="group",
subject_id=group_id,
user_id=active_context.user_id,
scope_type=active_context.scope_type.value,
scope_id=active_context.scope_id,
role=active_context.hat,
hat=active_context.hat,
access_profile_id=active_context.access_profile_id,
evidence_refs=evidence_refs,
)
)
return tuple(facts)
def _access_fact_evidence_refs(
self, tenant: str, subjects: set[str]
) -> tuple[CanonEntityReference, ...]:
return tuple(
CanonEntityReference(
concept="Evidence Source",
identifier=record.audit_id,
scope=tenant,
source_system="user-engine:audit",
local_type=record.summary or record.action,
)
for record in self.store.audit_log()
if record.tenant == tenant and record.subject in subjects
)
def _projection_access_context(
self,
user_id: str,
tenant: str,
*,
application_id: str | None,
) -> Mapping[str, Any]:
active_context = self.store.active_access_context(user_id, tenant)
if active_context is None:
return {}
if (
application_id is not None
and active_context.service_id is not None
and active_context.service_id != application_id
):
return {}
return {
"active_hat": active_context.hat,
"access_profile_id": active_context.access_profile_id,
"scope_type": active_context.scope_type.value,
"scope_id": active_context.scope_id,
"realm_id": active_context.realm_id,
"service_id": active_context.service_id,
"asset_id": active_context.asset_id,
"group_ids": active_context.group_ids,
"claims": dict(active_context.claims),
"profile_defaults": dict(active_context.profile_defaults),
"factor_ids": active_context.factor_ids,
}
def _ensure_actor_session(
self, actor: Actor, correlation_id: str
) -> UserSession:
@ -2637,6 +3144,7 @@ class UserEngineService:
memberships: tuple[Membership, ...],
tenant: str,
application_id: str | None,
active_access_context: ActiveAccessContext | None,
) -> Mapping[str, CanonEntityReference]:
actor_identifier = f"{actor.issuer}:{actor.subject}"
refs: dict[str, CanonEntityReference] = {
@ -2686,6 +3194,53 @@ class UserEngineService:
scope=tenant,
local_type="application",
)
if active_access_context is not None:
refs["active_access_context"] = CanonEntityReference(
concept="Active Access Context",
identifier=active_access_context.active_context_id,
scope=tenant,
local_type=active_access_context.scope_type.value,
)
refs["active_hat"] = CanonEntityReference(
concept="Hat",
identifier=active_access_context.hat,
scope=tenant,
local_type="active",
)
refs["access_profile"] = CanonEntityReference(
concept="Access Profile",
identifier=active_access_context.access_profile_id,
scope=tenant,
local_type=active_access_context.hat,
)
if active_access_context.realm_id is not None:
refs["realm"] = CanonEntityReference(
concept="Realm",
identifier=active_access_context.realm_id,
scope=tenant,
local_type="realm",
)
if active_access_context.service_id is not None:
refs["service_area"] = CanonEntityReference(
concept="Service Area",
identifier=active_access_context.service_id,
scope=tenant,
local_type="service",
)
if active_access_context.asset_id is not None:
refs["asset_scope"] = CanonEntityReference(
concept="Asset Scope",
identifier=active_access_context.asset_id,
scope=tenant,
local_type="asset",
)
for group_id in active_access_context.group_ids:
refs[f"group:{group_id}"] = CanonEntityReference(
concept="Group",
identifier=group_id,
scope=tenant,
local_type="group",
)
for identity in identities:
refs[f"identity_record:{identity.identity_id}"] = CanonEntityReference(
concept="Identity Record",
@ -2732,6 +3287,8 @@ class UserEngineService:
memberships: tuple[Membership, ...],
tenant: str,
evidence_id: str | None,
*,
active_access_context: ActiveAccessContext | None = None,
) -> tuple[tuple[CanonRelationshipReference, ...], tuple[CanonEntityReference, ...]]:
account_ref = entity_refs["account"]
subject_ref = entity_refs["authenticated_subject"]
@ -2810,6 +3367,28 @@ class UserEngineService:
),
)
)
if active_access_context is not None:
active_ref = entity_refs["active_access_context"]
hat_ref = entity_refs["active_hat"]
profile_ref = entity_refs["access_profile"]
relationships.extend(
(
CanonRelationshipReference(
relationship_type="wears_hat",
source=user_ref,
target=hat_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="selected_access_profile",
source=active_ref,
target=profile_ref,
scope=tenant,
evidence_id=evidence_id,
),
)
)
return tuple(relationships), tuple(grant_like_refs)
def _identity_evidence_refs(
@ -2818,9 +3397,16 @@ class UserEngineService:
account: Account,
memberships: tuple[Membership, ...],
tenant: str,
*,
active_access_context: ActiveAccessContext | None = None,
) -> tuple[CanonEntityReference, ...]:
membership_ids = {membership.membership_id for membership in memberships}
subjects = {user.user_id, account.account_id, *membership_ids}
access_subjects = (
{active_access_context.active_context_id}
if active_access_context is not None
else set()
)
subjects = {user.user_id, account.account_id, *membership_ids, *access_subjects}
evidence = []
for record in self.store.audit_log():
if record.tenant != tenant:
@ -3008,6 +3594,19 @@ class UserEngineService:
}
for membership in memberships
)
active_context = self.store.active_access_context(target_user_id, tenant)
if active_context is not None:
facts["active_access_context"] = {
"active_context_id": active_context.active_context_id,
"access_profile_id": active_context.access_profile_id,
"hat": active_context.hat,
"scope_type": active_context.scope_type.value,
"scope_id": active_context.scope_id,
"realm_id": active_context.realm_id,
"service_id": active_context.service_id,
"asset_id": active_context.asset_id,
"group_ids": active_context.group_ids,
}
if context:
facts.update(context)
return facts
@ -3250,6 +3849,26 @@ def _prepared_account_matches_factors(
)
def _access_profile_scope_id(profile: AccessProfile) -> str:
if profile.scope_id:
return profile.scope_id
if profile.scope_type == AccessScopeType.TENANT:
return profile.tenant
raise ValidationError("access profile scope_id is required")
def _active_group_ids(
profile: AccessProfile, memberships: Iterable[Membership]
) -> tuple[str, ...]:
group_ids = {
membership.scope_id
for membership in memberships
if membership.scope_type == AccessScopeType.GROUP.value
}
group_ids.update(profile.group_scope_ids)
return tuple(sorted(group_ids))
def _scope_concept(scope_type: str) -> str:
concepts = {
"team": "Team",
@ -3257,6 +3876,9 @@ def _scope_concept(scope_type: str) -> str:
"application": "Scope",
"family": "Group",
"group": "Group",
"realm": "Realm",
"service": "Service Area",
"asset": "Asset Scope",
}
return concepts.get(scope_type, "Scope")