generated from coulomb/repo-seed
feat: implement access profiles and hats
This commit is contained in:
@@ -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
|
||||||
|
|||||||
8
SCOPE.md
8
SCOPE.md
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
106
docs/hats-realms-services-assets-access-profiles.md
Normal file
106
docs/hats-realms-services-assets-access-profiles.md
Normal 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.
|
||||||
@@ -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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
354
tests/test_access_profiles.py
Normal file
354
tests/test_access_profiles.py
Normal 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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user