feat: implement access profiles and hats

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

View File

@@ -12,7 +12,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
`docs/netkingdom-registration-onboarding-vision.md`, `docs/netkingdom-registration-onboarding-vision.md`,
`docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.md`, `docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.md`,
`docs/registration-identity-and-factor-model.md`, `docs/registration-identity-and-factor-model.md`,
`docs/prepared-accounts-and-entitlement-claims.md`, `docs/scenarios.md`, `docs/prepared-accounts-and-entitlement-claims.md`,
`docs/hats-realms-services-assets-access-profiles.md`, `docs/scenarios.md`,
`docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`, `docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md` `docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
for implementation boundaries, contracts, canon mappings, examples, and release for implementation boundaries, contracts, canon mappings, examples, and release

View File

@@ -60,6 +60,8 @@ truth.
Implementation and planning work is tracked in `workplans/USER-WP-0001` Implementation and planning work is tracked in `workplans/USER-WP-0001`
through `USER-WP-0015`. `USER-WP-0010` implements the first headless through `USER-WP-0015`. `USER-WP-0010` implements the first headless
registration and factor-evidence slice. `USER-WP-0011` implements prepared registration and factor-evidence slice. `USER-WP-0011` implements prepared
accounts and entitlement claims. `USER-WP-0012` through `USER-WP-0015` remain accounts and entitlement claims. `USER-WP-0012` implements hats, realms,
proposed future workplans for hats/access profiles, onboarding journeys, services, assets, access profiles, active context, and exportable
optional UI, and security conformance. access-control facts. `USER-WP-0013` through `USER-WP-0015` remain proposed
future workplans for onboarding journeys, optional UI, and security
conformance.

View File

@@ -12,6 +12,8 @@ HTTP or RPC adapters should preserve these operation names:
- `prepare_account`, `update_prepared_account`, `list_prepared_accounts`, - `prepare_account`, `update_prepared_account`, `list_prepared_accounts`,
`revoke_prepared_account`, `expire_prepared_account`, `revoke_prepared_account`, `expire_prepared_account`,
`claim_prepared_account` `claim_prepared_account`
- `register_access_profile`, `list_access_profiles`, `select_active_hat`,
`export_access_control_facts`, `access_profile_diagnostics`
- `me`, `create_user`, `set_account_status`, `link_identity` - `me`, `create_user`, `set_account_status`, `link_identity`
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`, - `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
`tenant_diagnostics` `tenant_diagnostics`
@@ -62,13 +64,36 @@ approval-required packages fail closed. Denied claim decisions are audited
without outbox events. Mutation outbox payloads include ids, counts, statuses, without outbox events. Mutation outbox payloads include ids, counts, statuses,
factor types, and journey names, but not normalized factor values. factor types, and journey names, but not normalized factor values.
## Access Profile And Hat Contract
Access profiles are tenant-scoped templates for selecting an active hat across
tenant, realm, service, asset, or group contexts. A profile combines required
memberships, required verified factor types, profile defaults, projection
claims, optional group references, and explicit realm/service/asset scope ids.
`select_active_hat` requires an active tenant account, satisfied membership
requirements, unexpired verified factor evidence, and authorization-port
approval. The selected hat is persisted as `ActiveAccessContext` and is exposed
through `identity_context` and claims-enrichment projections.
`export_access_control_facts` returns adapter-neutral `AccessControlFact`
records for authorization engines and ACL systems. These facts include direct
membership facts, group-derived facts, and active-context facts, but
user-engine still does not make final access decisions or enforce protected
service runtime policy.
Access-profile diagnostics report counts, factor requirement types, and
approval-required issues without exposing profile default values, projection
claim values, or raw factor values.
## Identity Context Contract ## Identity Context Contract
`identity_context` is the first canon-facing read model for NetKingdom `identity_context` is the first canon-facing read model for NetKingdom
identity-domain consumers. It resolves a verified actor into the local user, identity-domain consumers. It resolves a verified actor into the local user,
account, external identity links, tenant scope, memberships, optional account, external identity links, tenant scope, memberships, optional
application scope, optional effective profile, canon entity references, application scope, optional effective profile, optional active access context,
relationship references, grant-like membership facts, and evidence references. exportable access-control facts, canon entity references, relationship
references, grant-like membership facts, and evidence references.
The method keeps these concepts distinct: The method keeps these concepts distinct:

View File

@@ -0,0 +1,106 @@
# Hats, Realms, Services, Assets, And Access Profiles
Status: implemented headless slice
Date: 2026-06-15
Related workplan: USER-WP-0012
## Purpose
This slice models how a NetKingdom user can wear different hats across tenant,
realm, service, asset, and group contexts. It gives authorization systems and
service runtimes explicit access-control facts and claims-enrichment context
without moving final policy decisions into user-engine.
## Vocabulary
The USER-WP-0012 vocabulary maps onto existing user-engine facts:
- tenant: isolation boundary and tenant account state;
- realm: broad domain or community scope represented by membership scope
`realm`;
- service: protected application or service scope represented by membership
scope `service` or an access profile `service_id`;
- asset: protected resource scope represented by membership scope `asset` or
an access profile `asset_id`;
- group: group membership represented by membership scope `group`;
- hat: active role persona selected from an access profile;
- access profile: template that combines membership requirements, factor
requirements, profile defaults, and projection claim rules.
## Domain Model
`AccessProfile` defines a claimable hat for a tenant context. It stores the
hat name, scope type/id, optional realm/service/asset ids, required membership
facts, required factor types, profile defaults, claims, group ids, and an
approval flag.
`ActiveAccessContext` records the user's currently selected hat for a tenant.
It stores the selected access profile, active scope, matched membership ids,
verified factor ids, group ids, projection claims, and profile defaults.
`AccessControlFact` is the export shape for policy and ACL systems. Facts can
represent direct user memberships, group-derived facts, and active-context
facts over realm, service, or asset scopes.
## Public Facade
`UserEngineService` exposes:
- `register_access_profile(...)`
- `list_access_profiles(...)`
- `select_active_hat(...)`
- `export_access_control_facts(...)`
- `access_profile_diagnostics(...)`
All mutating and read/export operations pass through the authorization port.
## Selection Rules
Hat selection fails closed unless all of these are true:
- the actor is allowed to operate in the tenant context;
- the target user has an active tenant account;
- the access profile belongs to the tenant and is not approval-required;
- every profile membership requirement is satisfied by existing memberships;
- every required factor type has unexpired verified user evidence;
- the authorization port allows the active-context selection.
Selecting a hat records an `ActiveAccessContext`, emits
`active_access_context.selected`, and keeps raw factor values out of events and
projections.
## Identity Context And Projections
`identity_context` now includes:
- `active_access_context`;
- `access_control_facts`;
- canon references for active hat, access profile, realm, service area, asset
scope, and groups;
- relationship references such as `wears_hat` and
`selected_access_profile`.
Claims-enrichment projections include an `access_context` mapping when the
active context applies to the requested application/service. Service-specific
contexts are omitted from projections for other applications.
## Export Boundary
`export_access_control_facts` returns an adapter-neutral manifest plus facts.
External authorization engines or ACL systems can consume these facts, but
they remain responsible for final policy decisions and runtime enforcement.
## Redaction And Diagnostics
Diagnostics report counts, required factor types, and approval-required issues.
They deliberately do not return profile default values, projection claim
values, factor values, phone numbers, postal addresses, eID payloads, or other
proofing data.
## Current Limits
- user-engine does not implement a policy engine or ACL evaluator.
- Approval workflows for privileged hats remain a later slice.
- Access profile profile-default values are carried into active context and
projections, but this slice does not persist them as catalog profile values.
- UI selection flows are left to USER-WP-0014.

View File

@@ -235,9 +235,9 @@ once.
## Recommended Workplans ## Recommended Workplans
As of 2026-06-15, `USER-WP-0010` and `USER-WP-0011` are implemented as As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, and `USER-WP-0012` are
headless user-engine slices. The later workplans remain recommended follow-on implemented as headless user-engine slices. The later workplans remain
work. recommended follow-on work.
| Workplan | Title | Purpose | | Workplan | Title | Purpose |
| --- | --- | --- | | --- | --- | --- |

View File

@@ -110,8 +110,9 @@ return factor values.
- Prepared account claiming is implemented by USER-WP-0011 and documented in - Prepared account claiming is implemented by USER-WP-0011 and documented in
`docs/prepared-accounts-and-entitlement-claims.md`. `docs/prepared-accounts-and-entitlement-claims.md`.
- Hats, realms, services, assets, and access profiles are left to - Hats, realms, services, assets, and access profiles are implemented by
USER-WP-0012. USER-WP-0012 and documented in
`docs/hats-realms-services-assets-access-profiles.md`.
- Welcome protocols and onboarding journeys are left to USER-WP-0013. - Welcome protocols and onboarding journeys are left to USER-WP-0013.
- Registration UI is left to USER-WP-0014. - Registration UI is left to USER-WP-0014.
- Provider-backed proofing and credential flows remain external adapters. - Provider-backed proofing and credential flows remain external adapters.

View File

@@ -9,6 +9,8 @@ from typing import Iterable, Iterator, Mapping, cast
from user_engine.domain import ( from user_engine.domain import (
Account, Account,
AccessProfile,
ActiveAccessContext,
Application, Application,
ApplicationBinding, ApplicationBinding,
AuditRecord, AuditRecord,
@@ -56,6 +58,10 @@ class InMemoryUserEngineStore:
) )
identity_factors: dict[str, IdentityFactor] = field(default_factory=dict) identity_factors: dict[str, IdentityFactor] = field(default_factory=dict)
prepared_accounts: dict[str, PreparedAccount] = 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[ profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue tuple[str, str, ProfileScope, str | None], ProfileValue
] = field(default_factory=dict) ] = field(default_factory=dict)
@@ -198,6 +204,36 @@ class InMemoryUserEngineStore:
if account.tenant == tenant 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: def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[ self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id) (value.user_id, value.attribute_key, value.scope, value.scope_id)
@@ -258,6 +294,8 @@ class InMemoryUserEngineStore:
"registration_sessions": len(self.registration_sessions), "registration_sessions": len(self.registration_sessions),
"identity_factors": len(self.identity_factors), "identity_factors": len(self.identity_factors),
"prepared_accounts": len(self.prepared_accounts), "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), "profile_values": len(self.profile_values),
"audit_records": len(self.audit_records), "audit_records": len(self.audit_records),
"pending_outbox_events": len(self.outbox_events), "pending_outbox_events": len(self.outbox_events),
@@ -277,6 +315,8 @@ class InMemoryUserEngineStore:
"registration_sessions": copy.deepcopy(self.registration_sessions), "registration_sessions": copy.deepcopy(self.registration_sessions),
"identity_factors": copy.deepcopy(self.identity_factors), "identity_factors": copy.deepcopy(self.identity_factors),
"prepared_accounts": copy.deepcopy(self.prepared_accounts), "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), "profile_values": copy.deepcopy(self.profile_values),
"audit_records": copy.deepcopy(self.audit_records), "audit_records": copy.deepcopy(self.audit_records),
"outbox_events": copy.deepcopy(self.outbox_events), "outbox_events": copy.deepcopy(self.outbox_events),
@@ -305,6 +345,10 @@ class InMemoryUserEngineStore:
] # type: ignore[assignment] ] # type: ignore[assignment]
self.identity_factors = snapshot["identity_factors"] # type: ignore[assignment] self.identity_factors = snapshot["identity_factors"] # type: ignore[assignment]
self.prepared_accounts = snapshot["prepared_accounts"] # 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.profile_values = snapshot["profile_values"] # type: ignore[assignment]
self.audit_records = [*snapshot_audit_records, *denied_audit_records] self.audit_records = [*snapshot_audit_records, *denied_audit_records]
self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment] self.outbox_events = snapshot["outbox_events"] # type: ignore[assignment]

View File

@@ -1,8 +1,13 @@
"""Domain schemas for user-engine.""" """Domain schemas for user-engine."""
from user_engine.domain.models import ( from user_engine.domain.models import (
AccessControlFact,
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
Account, Account,
AccountStatus, AccountStatus,
ActiveAccessContext,
Actor, Actor,
Application, Application,
ApplicationBinding, ApplicationBinding,
@@ -49,7 +54,12 @@ from user_engine.domain.models import (
__all__ = [ __all__ = [
"Account", "Account",
"AccessControlFact",
"AccessMembershipRequirement",
"AccessProfile",
"AccessScopeType",
"AccountStatus", "AccountStatus",
"ActiveAccessContext",
"Actor", "Actor",
"Application", "Application",
"ApplicationBinding", "ApplicationBinding",

View File

@@ -93,6 +93,14 @@ class PreparedEntitlementKind(StrEnum):
ONBOARDING_JOURNEY = "onboarding_journey" ONBOARDING_JOURNEY = "onboarding_journey"
class AccessScopeType(StrEnum):
TENANT = "tenant"
REALM = "realm"
SERVICE = "service"
ASSET = "asset"
GROUP = "group"
class ProfileScope(StrEnum): class ProfileScope(StrEnum):
GLOBAL = "global" GLOBAL = "global"
TENANT = "tenant" TENANT = "tenant"
@@ -300,6 +308,73 @@ class Membership:
created_at: datetime = field(default_factory=utc_now) 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) @dataclass(frozen=True)
class FamilyMemberSpec: class FamilyMemberSpec:
primary_email: str primary_email: str

View File

@@ -12,6 +12,9 @@ from typing import Any, Iterable, Mapping, Protocol
from user_engine.domain import ( from user_engine.domain import (
Account, Account,
AccessControlFact,
AccessProfile,
ActiveAccessContext,
Actor, Actor,
Application, Application,
ApplicationBinding, ApplicationBinding,
@@ -160,6 +163,28 @@ class UserEngineStore(Protocol):
) -> tuple[PreparedAccount, ...]: ) -> tuple[PreparedAccount, ...]:
"""Return prepared account packages for a tenant.""" """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: def save_profile_value(self, value: ProfileValue) -> None:
"""Create or replace a profile value.""" """Create or replace a profile value."""
@@ -228,6 +253,13 @@ class MembershipFactExporter(Protocol):
"""Return an adapter-neutral membership fact manifest.""" """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): class EventOutbox(Protocol):
"""Persist and publish durable domain events.""" """Persist and publish durable domain events."""

View File

@@ -2,13 +2,18 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, replace from dataclasses import dataclass, field, replace
from datetime import datetime from datetime import datetime
from typing import Any, Iterable, Mapping from typing import Any, Iterable, Mapping
from user_engine.domain import ( from user_engine.domain import (
Account, Account,
AccessControlFact,
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
AccountStatus, AccountStatus,
ActiveAccessContext,
Actor, Actor,
Application, Application,
ApplicationBinding, ApplicationBinding,
@@ -117,6 +122,7 @@ class Projection:
application_id: str | None application_id: str | None
values: Mapping[str, Any] values: Mapping[str, Any]
redactions: Mapping[str, str] redactions: Mapping[str, str]
access_context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -134,6 +140,31 @@ class IdentityContext:
evidence_refs: tuple[CanonEntityReference, ...] evidence_refs: tuple[CanonEntityReference, ...]
profile: EffectiveProfile | None = None profile: EffectiveProfile | None = None
gaps: tuple[str, ...] = () 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) @dataclass(frozen=True)
@@ -959,6 +990,270 @@ class UserEngineService:
onboarding_journeys=onboarding_journeys, 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( def me(
self, self,
claims: Mapping[str, Any], claims: Mapping[str, Any],
@@ -1451,6 +1746,11 @@ class UserEngineService:
application_id=application_id, application_id=application_id,
values=values, values=values,
redactions=redactions, redactions=redactions,
access_context=self._projection_access_context(
user_id,
tenant_context.tenant,
application_id=application_id,
),
) )
def identity_context( def identity_context(
@@ -1487,11 +1787,21 @@ class UserEngineService:
memberships = self.store.memberships_for_user( memberships = self.store.memberships_for_user(
user.user_id, tenant=tenant_context.tenant 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( evidence_refs = self._identity_evidence_refs(
user, user,
account, account,
memberships, memberships,
tenant_context.tenant, 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( entity_refs = self._identity_entity_refs(
actor, actor,
@@ -1501,12 +1811,14 @@ class UserEngineService:
memberships, memberships,
tenant_context.tenant, tenant_context.tenant,
application_id, application_id,
active_access_context,
) )
relationship_refs, grant_like_refs = self._identity_relationship_refs( relationship_refs, grant_like_refs = self._identity_relationship_refs(
entity_refs, entity_refs,
memberships, memberships,
tenant_context.tenant, tenant_context.tenant,
evidence_refs[0].identifier if evidence_refs else None, evidence_refs[0].identifier if evidence_refs else None,
active_access_context=active_access_context,
) )
profile = ( profile = (
self._resolve_effective_profile( self._resolve_effective_profile(
@@ -1530,6 +1842,8 @@ class UserEngineService:
evidence_refs=evidence_refs, evidence_refs=evidence_refs,
profile=profile, profile=profile,
gaps=gaps, gaps=gaps,
active_access_context=active_access_context,
access_control_facts=access_control_facts,
) )
def onboard_family_dataspace( 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( def _ensure_actor_session(
self, actor: Actor, correlation_id: str self, actor: Actor, correlation_id: str
) -> UserSession: ) -> UserSession:
@@ -2637,6 +3144,7 @@ class UserEngineService:
memberships: tuple[Membership, ...], memberships: tuple[Membership, ...],
tenant: str, tenant: str,
application_id: str | None, application_id: str | None,
active_access_context: ActiveAccessContext | None,
) -> Mapping[str, CanonEntityReference]: ) -> Mapping[str, CanonEntityReference]:
actor_identifier = f"{actor.issuer}:{actor.subject}" actor_identifier = f"{actor.issuer}:{actor.subject}"
refs: dict[str, CanonEntityReference] = { refs: dict[str, CanonEntityReference] = {
@@ -2686,6 +3194,53 @@ class UserEngineService:
scope=tenant, scope=tenant,
local_type="application", 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: for identity in identities:
refs[f"identity_record:{identity.identity_id}"] = CanonEntityReference( refs[f"identity_record:{identity.identity_id}"] = CanonEntityReference(
concept="Identity Record", concept="Identity Record",
@@ -2732,6 +3287,8 @@ class UserEngineService:
memberships: tuple[Membership, ...], memberships: tuple[Membership, ...],
tenant: str, tenant: str,
evidence_id: str | None, evidence_id: str | None,
*,
active_access_context: ActiveAccessContext | None = None,
) -> tuple[tuple[CanonRelationshipReference, ...], tuple[CanonEntityReference, ...]]: ) -> tuple[tuple[CanonRelationshipReference, ...], tuple[CanonEntityReference, ...]]:
account_ref = entity_refs["account"] account_ref = entity_refs["account"]
subject_ref = entity_refs["authenticated_subject"] 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) return tuple(relationships), tuple(grant_like_refs)
def _identity_evidence_refs( def _identity_evidence_refs(
@@ -2818,9 +3397,16 @@ class UserEngineService:
account: Account, account: Account,
memberships: tuple[Membership, ...], memberships: tuple[Membership, ...],
tenant: str, tenant: str,
*,
active_access_context: ActiveAccessContext | None = None,
) -> tuple[CanonEntityReference, ...]: ) -> tuple[CanonEntityReference, ...]:
membership_ids = {membership.membership_id for membership in memberships} 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 = [] evidence = []
for record in self.store.audit_log(): for record in self.store.audit_log():
if record.tenant != tenant: if record.tenant != tenant:
@@ -3008,6 +3594,19 @@ class UserEngineService:
} }
for membership in memberships 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: if context:
facts.update(context) facts.update(context)
return facts 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: def _scope_concept(scope_type: str) -> str:
concepts = { concepts = {
"team": "Team", "team": "Team",
@@ -3257,6 +3876,9 @@ def _scope_concept(scope_type: str) -> str:
"application": "Scope", "application": "Scope",
"family": "Group", "family": "Group",
"group": "Group", "group": "Group",
"realm": "Realm",
"service": "Service Area",
"asset": "Asset Scope",
} }
return concepts.get(scope_type, "Scope") return concepts.get(scope_type, "Scope")

View File

@@ -0,0 +1,354 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import (
AccessMembershipRequirement,
AccessProfile,
AccessScopeType,
IdentityFactor,
IdentityFactorType,
ProjectionType,
)
from user_engine.errors import AuthorizationDenied, ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
sample_catalog,
)
class AccessProfileTests(unittest.TestCase):
def test_select_active_hat_updates_identity_context_and_projection(self):
service, store, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-realm-membership",
)
store.save_identity_factor(_email_factor(session.user.user_id))
profile = service.register_access_profile(
session.actor,
_realm_operator_profile(),
correlation_id="corr-profile-register",
)
selection = service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-hat",
)
context = service.identity_context(
session.actor,
application_id="app.demo",
include_profile=True,
correlation_id="corr-identity-context",
)
projection = service.projection(
session.actor,
session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
application_id="app.demo",
tenant="tenant:coulomb",
correlation_id="corr-projection",
)
self.assertEqual(selection.active_context.hat, "operator")
self.assertEqual(selection.active_context.realm_id, "realm:citadel")
self.assertEqual(selection.active_context.service_id, "app.demo")
self.assertEqual(selection.active_context.asset_id, "asset:ledger")
self.assertEqual(selection.active_context.factor_ids[0].startswith("fac_"), True)
self.assertEqual(context.active_access_context.hat, "operator")
self.assertEqual(context.entity_refs["active_hat"].concept, "Hat")
self.assertIn(
"wears_hat",
{relationship.relationship_type for relationship in context.relationship_refs},
)
self.assertTrue(
any(fact.scope_id == "realm:citadel" for fact in context.access_control_facts)
)
self.assertEqual(projection.access_context["active_hat"], "operator")
self.assertEqual(projection.access_context["claims"]["service_role"], "operator")
self.assertNotIn(
"ada@example.test",
repr([event.payload for event in service.outbox_events()]),
)
def test_cross_tenant_access_profile_denied(self):
service, _, _ = _service()
session = _bootstrap(service)
with self.assertRaises(AuthorizationDenied):
service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:faraday",
display_name="Faraday Operator",
hat="operator",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:faraday",
kind="operator",
),
),
),
correlation_id="corr-cross-tenant",
)
def test_missing_factor_assurance_fails_closed(self):
service, store, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-realm-membership",
)
profile = service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="High Assurance Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EID,),
),
correlation_id="corr-high-assurance",
)
with self.assertRaises(ValidationError):
service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-missing-factor",
)
self.assertIsNone(
store.active_access_context(session.user.user_id, "tenant:coulomb")
)
def test_group_derived_access_exports_group_facts(self):
service, _, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="group",
scope_id="group:research",
kind="member",
correlation_id="corr-group-membership",
)
profile = service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="Research Service Hat",
hat="researcher",
scope_type=AccessScopeType.SERVICE,
scope_id="app.demo",
service_id="app.demo",
membership_requirements=(
AccessMembershipRequirement(
scope_type="group",
scope_id="group:research",
kind="member",
),
),
group_scope_ids=("group:research",),
),
correlation_id="corr-research-profile",
)
service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-researcher",
)
export = service.export_access_control_facts(
session.actor,
tenant="tenant:coulomb",
user_id=session.user.user_id,
correlation_id="corr-export-facts",
)
self.assertIn("group", export.manifest["subject_types"])
self.assertTrue(
any(
fact.subject_type == "group"
and fact.subject_id == "group:research"
and fact.scope_id == "app.demo"
for fact in export.facts
)
)
def test_service_specific_projection_filters_other_services(self):
service, store, _ = _service()
session = _bootstrap(service)
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:coulomb",
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
correlation_id="corr-realm-membership",
)
store.save_identity_factor(_email_factor(session.user.user_id))
profile = service.register_access_profile(
session.actor,
_realm_operator_profile(),
correlation_id="corr-profile-register",
)
service.select_active_hat(
session.actor,
session.user.user_id,
profile.access_profile_id,
correlation_id="corr-select-hat",
)
projection = service.projection(
session.actor,
session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
application_id="app.other",
tenant="tenant:coulomb",
correlation_id="corr-other-service",
)
self.assertEqual(projection.access_context, {})
def test_access_profile_diagnostics_are_redacted(self):
service, _, _ = _service()
session = _bootstrap(service)
profile = service.register_access_profile(
session.actor,
AccessProfile(
tenant="tenant:coulomb",
display_name="Sensitive Defaults",
hat="operator",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
profile_defaults={"internal_hint": "secret-default"},
claims={"private_claim": "secret-claim"},
),
correlation_id="corr-sensitive-profile",
)
diagnostics = service.access_profile_diagnostics(
session.actor,
tenant="tenant:coulomb",
correlation_id="corr-access-diagnostics",
)
self.assertEqual(diagnostics.profile_count, 1)
self.assertIn(profile.access_profile_id, diagnostics.required_factor_types)
self.assertNotIn("secret-default", repr(diagnostics))
self.assertNotIn("secret-claim", repr(diagnostics))
self.assertNotIn(
"secret-default",
repr([event.payload for event in service.outbox_events()]),
)
self.assertNotIn(
"secret-claim",
repr([event.payload for event in service.outbox_events()]),
)
def _service():
store = InMemoryUserEngineStore()
authz = LocalAuthorizationCheckPort()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=authz,
)
return service, store, authz
def _bootstrap(service: UserEngineService):
session = service.me(_claims(), correlation_id="corr-me")
service.register_application(
session.actor,
sample_application(),
binding=sample_application_binding(),
correlation_id="corr-app",
)
service.publish_catalog(
session.actor,
sample_catalog(),
correlation_id="corr-catalog",
)
return session
def _claims():
claims = human_actor_claims(subject="ada", tenant="tenant:coulomb")
claims["roles"] = ["tenant-admin"]
claims["email"] = "ada@example.test"
return claims
def _email_factor(user_id: str) -> IdentityFactor:
return IdentityFactor(
factor_type=IdentityFactorType.EMAIL,
normalized_value="ada@example.test",
user_id=user_id,
display_value="ada@example.test",
source_system="fixture-email",
)
def _realm_operator_profile() -> AccessProfile:
return AccessProfile(
tenant="tenant:coulomb",
display_name="Realm Operator",
hat="operator",
scope_type=AccessScopeType.REALM,
scope_id="realm:citadel",
realm_id="realm:citadel",
service_id="app.demo",
asset_id="asset:ledger",
membership_requirements=(
AccessMembershipRequirement(
scope_type="realm",
scope_id="realm:citadel",
kind="operator",
),
),
required_factor_types=(IdentityFactorType.EMAIL,),
profile_defaults={"workspace_mode": "ops"},
claims={"service_role": "operator"},
)
if __name__ == "__main__":
unittest.main()

View File

@@ -154,6 +154,8 @@ class _ProtocolOnlyStore:
_blocked_fields = { _blocked_fields = {
"accounts", "accounts",
"access_profiles",
"active_access_contexts",
"applications", "applications",
"audit_records", "audit_records",
"bindings", "bindings",

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Hats, Realms, Services, Assets, And Access Profiles" title: "Hats, Realms, Services, Assets, And Access Profiles"
domain: netkingdom domain: netkingdom
repo: user-engine repo: user-engine
status: proposed status: finished
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
planning_priority: high planning_priority: high
@@ -42,7 +42,7 @@ and protected services own runtime enforcement.
```task ```task
id: USER-WP-0012-T1 id: USER-WP-0012-T1
status: todo status: done
priority: high priority: high
state_hub_task_id: "b86f0072-e666-479b-9b90-96d4015bbfa0" state_hub_task_id: "b86f0072-e666-479b-9b90-96d4015bbfa0"
``` ```
@@ -53,7 +53,7 @@ canon reference patterns.
```task ```task
id: USER-WP-0012-T2 id: USER-WP-0012-T2
status: todo status: done
priority: high priority: high
state_hub_task_id: "66117083-8e85-44e1-9a76-cfd10dd24d23" state_hub_task_id: "66117083-8e85-44e1-9a76-cfd10dd24d23"
``` ```
@@ -63,7 +63,7 @@ active hat for a tenant, realm, service, or asset context when allowed.
```task ```task
id: USER-WP-0012-T3 id: USER-WP-0012-T3
status: todo status: done
priority: high priority: high
state_hub_task_id: "1dffda4c-f979-480e-9d6d-12ec9576780d" state_hub_task_id: "1dffda4c-f979-480e-9d6d-12ec9576780d"
``` ```
@@ -73,7 +73,7 @@ requirements, profile defaults, and claims projection rules.
```task ```task
id: USER-WP-0012-T4 id: USER-WP-0012-T4
status: todo status: done
priority: high priority: high
state_hub_task_id: "b07494fe-f301-49e2-8ea8-267a4c5219ee" state_hub_task_id: "b07494fe-f301-49e2-8ea8-267a4c5219ee"
``` ```
@@ -83,7 +83,7 @@ realm, service, asset, group, access profile, and evidence references.
```task ```task
id: USER-WP-0012-T5 id: USER-WP-0012-T5
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "c78e10c4-b245-4a83-a75d-4b46a6073fd2" state_hub_task_id: "c78e10c4-b245-4a83-a75d-4b46a6073fd2"
``` ```
@@ -93,7 +93,7 @@ systems while preserving source-of-truth boundaries.
```task ```task
id: USER-WP-0012-T6 id: USER-WP-0012-T6
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "f9f32165-3a12-424e-a370-bb2ab8348c21" state_hub_task_id: "f9f32165-3a12-424e-a370-bb2ab8348c21"
``` ```
@@ -116,3 +116,38 @@ group-derived access, service-specific projection, and redacted diagnostics.
- Active context service facade. - Active context service facade.
- Identity-context and claims projection updates. - Identity-context and claims projection updates.
- Access-control fact export tests. - Access-control fact export tests.
## Implementation Notes
Implemented on 2026-06-15:
- Added `AccessScopeType`, `AccessMembershipRequirement`, `AccessProfile`,
`ActiveAccessContext`, and `AccessControlFact` domain models.
- Added access-profile and active-context persistence to `UserEngineStore` and
`InMemoryUserEngineStore`, including transaction snapshots and record
counts.
- Added `UserEngineService` facade methods:
`register_access_profile`, `list_access_profiles`, `select_active_hat`,
`export_access_control_facts`, and `access_profile_diagnostics`.
- Added fail-closed active hat selection requiring tenant context, active
tenant account state, matching membership facts, unexpired factor evidence,
non-approval-required profile state, and authorization-port approval.
- Extended `identity_context` with active access context, access-control facts,
canon references for hats/realms/services/assets/groups, and active-hat
relationship references.
- Extended claims-enrichment projections with service-filtered access context
while keeping raw factor values out of events and diagnostics.
- Added adapter-neutral access-control fact export for direct memberships,
group-derived facts, and active-context facts.
- Added `docs/hats-realms-services-assets-access-profiles.md`, public contract
updates, and tests for active hat selection, cross-tenant denial, missing
factor assurance, group-derived access, service-specific projections, and
redacted diagnostics.
Verification:
```text
make test
Ran 61 tests in 0.515s
OK
```