From 660ce24995f72841a1fd1b5ac57fb6632bd96253 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 23:12:25 +0200 Subject: [PATCH] feat: implement access profiles and hats --- README.md | 3 +- SCOPE.md | 8 +- docs/contracts.md | 29 +- ...-realms-services-assets-access-profiles.md | 106 +++ ...tkingdom-registration-onboarding-vision.md | 6 +- .../registration-identity-and-factor-model.md | 5 +- src/user_engine/adapters/local.py | 44 ++ src/user_engine/domain/__init__.py | 10 + src/user_engine/domain/models.py | 75 +++ src/user_engine/ports.py | 32 + src/user_engine/service.py | 626 +++++++++++++++++- tests/test_access_profiles.py | 354 ++++++++++ tests/test_ports_and_fixtures.py | 2 + ...-realms-services-assets-access-profiles.md | 49 +- 14 files changed, 1329 insertions(+), 20 deletions(-) create mode 100644 docs/hats-realms-services-assets-access-profiles.md create mode 100644 tests/test_access_profiles.py diff --git a/README.md b/README.md index 1c0b8f7..ea8a421 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`, `docs/netkingdom-registration-onboarding-vision.md`, `docs/postgres-durable-store-consumer-requirements.md`, `docs/examples.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/identity-domain-naming-decision.md`, and `docs/final-assessment.md` for implementation boundaries, contracts, canon mappings, examples, and release diff --git a/SCOPE.md b/SCOPE.md index a0c9efe..4baf38e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -60,6 +60,8 @@ truth. Implementation and planning work is tracked in `workplans/USER-WP-0001` through `USER-WP-0015`. `USER-WP-0010` implements the first headless registration and factor-evidence slice. `USER-WP-0011` implements prepared -accounts and entitlement claims. `USER-WP-0012` through `USER-WP-0015` remain -proposed future workplans for hats/access profiles, onboarding journeys, -optional UI, and security conformance. +accounts and entitlement claims. `USER-WP-0012` implements hats, realms, +services, assets, access profiles, active context, and exportable +access-control facts. `USER-WP-0013` through `USER-WP-0015` remain proposed +future workplans for onboarding journeys, optional UI, and security +conformance. diff --git a/docs/contracts.md b/docs/contracts.md index 575f221..589e0a0 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -12,6 +12,8 @@ HTTP or RPC adapters should preserve these operation names: - `prepare_account`, `update_prepared_account`, `list_prepared_accounts`, `revoke_prepared_account`, `expire_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` - `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`, `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, 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` is the first canon-facing read model for NetKingdom identity-domain consumers. It resolves a verified actor into the local user, account, external identity links, tenant scope, memberships, optional -application scope, optional effective profile, canon entity references, -relationship references, grant-like membership facts, and evidence references. +application scope, optional effective profile, optional active access context, +exportable access-control facts, canon entity references, relationship +references, grant-like membership facts, and evidence references. The method keeps these concepts distinct: diff --git a/docs/hats-realms-services-assets-access-profiles.md b/docs/hats-realms-services-assets-access-profiles.md new file mode 100644 index 0000000..3c6f7eb --- /dev/null +++ b/docs/hats-realms-services-assets-access-profiles.md @@ -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. diff --git a/docs/netkingdom-registration-onboarding-vision.md b/docs/netkingdom-registration-onboarding-vision.md index 5781c58..7daaa08 100644 --- a/docs/netkingdom-registration-onboarding-vision.md +++ b/docs/netkingdom-registration-onboarding-vision.md @@ -235,9 +235,9 @@ once. ## Recommended Workplans -As of 2026-06-15, `USER-WP-0010` and `USER-WP-0011` are implemented as -headless user-engine slices. The later workplans remain recommended follow-on -work. +As of 2026-06-15, `USER-WP-0010`, `USER-WP-0011`, and `USER-WP-0012` are +implemented as headless user-engine slices. The later workplans remain +recommended follow-on work. | Workplan | Title | Purpose | | --- | --- | --- | diff --git a/docs/registration-identity-and-factor-model.md b/docs/registration-identity-and-factor-model.md index df84818..a48efa7 100644 --- a/docs/registration-identity-and-factor-model.md +++ b/docs/registration-identity-and-factor-model.md @@ -110,8 +110,9 @@ return factor values. - Prepared account claiming is implemented by USER-WP-0011 and documented in `docs/prepared-accounts-and-entitlement-claims.md`. -- Hats, realms, services, assets, and access profiles are left to - USER-WP-0012. +- Hats, realms, services, assets, and access profiles are implemented by + 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. - Registration UI is left to USER-WP-0014. - Provider-backed proofing and credential flows remain external adapters. diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index 387a90a..251a3d7 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -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] diff --git a/src/user_engine/domain/__init__.py b/src/user_engine/domain/__init__.py index 6931078..355ad4e 100644 --- a/src/user_engine/domain/__init__.py +++ b/src/user_engine/domain/__init__.py @@ -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", diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index 2bee539..1127c20 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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 diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 81c50cc..f95ea59 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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.""" diff --git a/src/user_engine/service.py b/src/user_engine/service.py index 5bf5043..998155c 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -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") diff --git a/tests/test_access_profiles.py b/tests/test_access_profiles.py new file mode 100644 index 0000000..79ca226 --- /dev/null +++ b/tests/test_access_profiles.py @@ -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() diff --git a/tests/test_ports_and_fixtures.py b/tests/test_ports_and_fixtures.py index 5f8af11..9a1b859 100644 --- a/tests/test_ports_and_fixtures.py +++ b/tests/test_ports_and_fixtures.py @@ -154,6 +154,8 @@ class _ProtocolOnlyStore: _blocked_fields = { "accounts", + "access_profiles", + "active_access_contexts", "applications", "audit_records", "bindings", diff --git a/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md b/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md index 13de4a3..161fea4 100644 --- a/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md +++ b/workplans/USER-WP-0012-hats-realms-services-assets-access-profiles.md @@ -4,7 +4,7 @@ type: workplan title: "Hats, Realms, Services, Assets, And Access Profiles" domain: netkingdom repo: user-engine -status: proposed +status: finished owner: codex topic_slug: netkingdom planning_priority: high @@ -42,7 +42,7 @@ and protected services own runtime enforcement. ```task id: USER-WP-0012-T1 -status: todo +status: done priority: high state_hub_task_id: "b86f0072-e666-479b-9b90-96d4015bbfa0" ``` @@ -53,7 +53,7 @@ canon reference patterns. ```task id: USER-WP-0012-T2 -status: todo +status: done priority: high 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 id: USER-WP-0012-T3 -status: todo +status: done priority: high state_hub_task_id: "1dffda4c-f979-480e-9d6d-12ec9576780d" ``` @@ -73,7 +73,7 @@ requirements, profile defaults, and claims projection rules. ```task id: USER-WP-0012-T4 -status: todo +status: done priority: high state_hub_task_id: "b07494fe-f301-49e2-8ea8-267a4c5219ee" ``` @@ -83,7 +83,7 @@ realm, service, asset, group, access profile, and evidence references. ```task id: USER-WP-0012-T5 -status: todo +status: done priority: medium state_hub_task_id: "c78e10c4-b245-4a83-a75d-4b46a6073fd2" ``` @@ -93,7 +93,7 @@ systems while preserving source-of-truth boundaries. ```task id: USER-WP-0012-T6 -status: todo +status: done priority: medium 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. - Identity-context and claims projection updates. - 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 +```