generated from coulomb/repo-seed
feat: implement access profiles and hats
This commit is contained in:
@@ -9,6 +9,8 @@ from typing import Iterable, Iterator, Mapping, cast
|
||||
|
||||
from user_engine.domain import (
|
||||
Account,
|
||||
AccessProfile,
|
||||
ActiveAccessContext,
|
||||
Application,
|
||||
ApplicationBinding,
|
||||
AuditRecord,
|
||||
@@ -56,6 +58,10 @@ class InMemoryUserEngineStore:
|
||||
)
|
||||
identity_factors: dict[str, IdentityFactor] = field(default_factory=dict)
|
||||
prepared_accounts: dict[str, PreparedAccount] = field(default_factory=dict)
|
||||
access_profiles: dict[str, AccessProfile] = field(default_factory=dict)
|
||||
active_access_contexts: dict[
|
||||
tuple[str, str], ActiveAccessContext
|
||||
] = field(default_factory=dict)
|
||||
profile_values: dict[
|
||||
tuple[str, str, ProfileScope, str | None], ProfileValue
|
||||
] = field(default_factory=dict)
|
||||
@@ -198,6 +204,36 @@ class InMemoryUserEngineStore:
|
||||
if account.tenant == tenant
|
||||
)
|
||||
|
||||
def save_access_profile(self, profile: AccessProfile) -> None:
|
||||
self.access_profiles[profile.access_profile_id] = profile
|
||||
|
||||
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
|
||||
return self.access_profiles.get(access_profile_id)
|
||||
|
||||
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
|
||||
return tuple(
|
||||
profile
|
||||
for profile in self.access_profiles.values()
|
||||
if profile.tenant == tenant
|
||||
)
|
||||
|
||||
def save_active_access_context(self, context: ActiveAccessContext) -> None:
|
||||
self.active_access_contexts[(context.user_id, context.tenant)] = context
|
||||
|
||||
def active_access_context(
|
||||
self, user_id: str, tenant: str
|
||||
) -> ActiveAccessContext | None:
|
||||
return self.active_access_contexts.get((user_id, tenant))
|
||||
|
||||
def active_access_contexts_for_tenant(
|
||||
self, tenant: str
|
||||
) -> tuple[ActiveAccessContext, ...]:
|
||||
return tuple(
|
||||
context
|
||||
for context in self.active_access_contexts.values()
|
||||
if context.tenant == tenant
|
||||
)
|
||||
|
||||
def save_profile_value(self, value: ProfileValue) -> None:
|
||||
self.profile_values[
|
||||
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
||||
@@ -258,6 +294,8 @@ class InMemoryUserEngineStore:
|
||||
"registration_sessions": len(self.registration_sessions),
|
||||
"identity_factors": len(self.identity_factors),
|
||||
"prepared_accounts": len(self.prepared_accounts),
|
||||
"access_profiles": len(self.access_profiles),
|
||||
"active_access_contexts": len(self.active_access_contexts),
|
||||
"profile_values": len(self.profile_values),
|
||||
"audit_records": len(self.audit_records),
|
||||
"pending_outbox_events": len(self.outbox_events),
|
||||
@@ -277,6 +315,8 @@ class InMemoryUserEngineStore:
|
||||
"registration_sessions": copy.deepcopy(self.registration_sessions),
|
||||
"identity_factors": copy.deepcopy(self.identity_factors),
|
||||
"prepared_accounts": copy.deepcopy(self.prepared_accounts),
|
||||
"access_profiles": copy.deepcopy(self.access_profiles),
|
||||
"active_access_contexts": copy.deepcopy(self.active_access_contexts),
|
||||
"profile_values": copy.deepcopy(self.profile_values),
|
||||
"audit_records": copy.deepcopy(self.audit_records),
|
||||
"outbox_events": copy.deepcopy(self.outbox_events),
|
||||
@@ -305,6 +345,10 @@ class InMemoryUserEngineStore:
|
||||
] # type: ignore[assignment]
|
||||
self.identity_factors = snapshot["identity_factors"] # type: ignore[assignment]
|
||||
self.prepared_accounts = snapshot["prepared_accounts"] # type: ignore[assignment]
|
||||
self.access_profiles = snapshot["access_profiles"] # type: ignore[assignment]
|
||||
self.active_access_contexts = snapshot[
|
||||
"active_access_contexts"
|
||||
] # type: ignore[assignment]
|
||||
self.profile_values = snapshot["profile_values"] # type: ignore[assignment]
|
||||
self.audit_records = [*snapshot_audit_records, *denied_audit_records]
|
||||
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Domain schemas for user-engine."""
|
||||
|
||||
from user_engine.domain.models import (
|
||||
AccessControlFact,
|
||||
AccessMembershipRequirement,
|
||||
AccessProfile,
|
||||
AccessScopeType,
|
||||
Account,
|
||||
AccountStatus,
|
||||
ActiveAccessContext,
|
||||
Actor,
|
||||
Application,
|
||||
ApplicationBinding,
|
||||
@@ -49,7 +54,12 @@ from user_engine.domain.models import (
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccessControlFact",
|
||||
"AccessMembershipRequirement",
|
||||
"AccessProfile",
|
||||
"AccessScopeType",
|
||||
"AccountStatus",
|
||||
"ActiveAccessContext",
|
||||
"Actor",
|
||||
"Application",
|
||||
"ApplicationBinding",
|
||||
|
||||
@@ -93,6 +93,14 @@ class PreparedEntitlementKind(StrEnum):
|
||||
ONBOARDING_JOURNEY = "onboarding_journey"
|
||||
|
||||
|
||||
class AccessScopeType(StrEnum):
|
||||
TENANT = "tenant"
|
||||
REALM = "realm"
|
||||
SERVICE = "service"
|
||||
ASSET = "asset"
|
||||
GROUP = "group"
|
||||
|
||||
|
||||
class ProfileScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
TENANT = "tenant"
|
||||
@@ -300,6 +308,73 @@ class Membership:
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessMembershipRequirement:
|
||||
scope_type: str
|
||||
scope_id: str
|
||||
kind: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessProfile:
|
||||
tenant: str
|
||||
display_name: str
|
||||
hat: str
|
||||
access_profile_id: str = field(default_factory=lambda: new_id("apf"))
|
||||
scope_type: AccessScopeType = AccessScopeType.TENANT
|
||||
scope_id: str | None = None
|
||||
realm_id: str | None = None
|
||||
service_id: str | None = None
|
||||
asset_id: str | None = None
|
||||
membership_requirements: tuple[AccessMembershipRequirement, ...] = ()
|
||||
required_factor_types: tuple[IdentityFactorType, ...] = ()
|
||||
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
|
||||
claims: Mapping[str, Any] = field(default_factory=dict)
|
||||
group_scope_ids: tuple[str, ...] = ()
|
||||
requires_approval: bool = False
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveAccessContext:
|
||||
user_id: str
|
||||
tenant: str
|
||||
access_profile_id: str
|
||||
hat: str
|
||||
scope_type: AccessScopeType
|
||||
scope_id: str
|
||||
active_context_id: str = field(default_factory=lambda: new_id("actx"))
|
||||
realm_id: str | None = None
|
||||
service_id: str | None = None
|
||||
asset_id: str | None = None
|
||||
membership_ids: tuple[str, ...] = ()
|
||||
factor_ids: tuple[str, ...] = ()
|
||||
group_ids: tuple[str, ...] = ()
|
||||
claims: Mapping[str, Any] = field(default_factory=dict)
|
||||
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
|
||||
selected_by_subject: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AccessControlFact:
|
||||
tenant: str
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
scope_type: str
|
||||
scope_id: str
|
||||
role: str
|
||||
fact_id: str = field(default_factory=lambda: new_id("acf"))
|
||||
user_id: str | None = None
|
||||
hat: str | None = None
|
||||
access_profile_id: str | None = None
|
||||
membership_id: str | None = None
|
||||
evidence_refs: tuple[CanonEntityReference, ...] = ()
|
||||
source_system: str = "user-engine"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyMemberSpec:
|
||||
primary_email: str
|
||||
|
||||
@@ -12,6 +12,9 @@ from typing import Any, Iterable, Mapping, Protocol
|
||||
|
||||
from user_engine.domain import (
|
||||
Account,
|
||||
AccessControlFact,
|
||||
AccessProfile,
|
||||
ActiveAccessContext,
|
||||
Actor,
|
||||
Application,
|
||||
ApplicationBinding,
|
||||
@@ -160,6 +163,28 @@ class UserEngineStore(Protocol):
|
||||
) -> tuple[PreparedAccount, ...]:
|
||||
"""Return prepared account packages for a tenant."""
|
||||
|
||||
def save_access_profile(self, profile: AccessProfile) -> None:
|
||||
"""Create or replace an access profile template."""
|
||||
|
||||
def access_profile(self, access_profile_id: str) -> AccessProfile | None:
|
||||
"""Return an access profile template by id."""
|
||||
|
||||
def access_profiles_for_tenant(self, tenant: str) -> tuple[AccessProfile, ...]:
|
||||
"""Return access profile templates for a tenant."""
|
||||
|
||||
def save_active_access_context(self, context: ActiveAccessContext) -> None:
|
||||
"""Create or replace the user's active access context for a tenant."""
|
||||
|
||||
def active_access_context(
|
||||
self, user_id: str, tenant: str
|
||||
) -> ActiveAccessContext | None:
|
||||
"""Return the user's active access context for a tenant."""
|
||||
|
||||
def active_access_contexts_for_tenant(
|
||||
self, tenant: str
|
||||
) -> tuple[ActiveAccessContext, ...]:
|
||||
"""Return active access contexts for a tenant."""
|
||||
|
||||
def save_profile_value(self, value: ProfileValue) -> None:
|
||||
"""Create or replace a profile value."""
|
||||
|
||||
@@ -228,6 +253,13 @@ class MembershipFactExporter(Protocol):
|
||||
"""Return an adapter-neutral membership fact manifest."""
|
||||
|
||||
|
||||
class AccessControlFactExporter(Protocol):
|
||||
"""Export access-control facts to an external policy or ACL system."""
|
||||
|
||||
def export(self, facts: Iterable[AccessControlFact]) -> Mapping[str, Any]:
|
||||
"""Return an adapter-neutral access-control fact manifest."""
|
||||
|
||||
|
||||
class EventOutbox(Protocol):
|
||||
"""Persist and publish durable domain events."""
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user