Implement identity canon alignment

This commit is contained in:
2026-06-05 16:04:43 +02:00
parent fe446711de
commit c6d211f472
15 changed files with 1008 additions and 21 deletions

View File

@@ -7,6 +7,9 @@ make test
```
See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
`docs/examples.md`, `docs/scenarios.md`, `docs/operability.md`,
`docs/release.md`, `docs/ui-contracts.md`, and `docs/final-assessment.md`
for implementation boundaries, contracts, examples, and release readiness.
`docs/canon-mapping.md`, `docs/canon-interface-card.yaml`,
`docs/evidence-gap-examples.md`, `docs/examples.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
readiness.

View File

@@ -2,27 +2,35 @@
## One-Liner
Headless user-domain and profile engine for accounts, identity links,
preferences, memberships, application catalogs, projections, audit, and
events.
Headless user-domain and identity-domain integration engine for accounts,
identity links, actor/principal/subject context, preferences, memberships,
application catalogs, projections, evidence references, audit, and events.
## In Scope
- user and account records;
- account lifecycle state;
- external identity links;
- actor, authenticated subject, authorization principal, account, and user
context mappings;
- global, tenant, application, and membership profile values;
- preference values;
- tenant, application, team, and scope memberships;
- identity-context read models for domain consumers;
- canon interface cards, entity mappings, relationship mappings, and explicit
gap records;
- application registry for profile consumers;
- customization catalog registry and validation;
- effective profile resolution;
- projection APIs for self-service, admin, application runtime, audit, and
agent contexts;
- audit records and lifecycle/profile-change events;
- local evidence references derived from audit and event records;
- local standalone development mode;
- integration ports for identity claims, authorization checks, events, and
runtime secrets.
runtime secrets;
- adapter contracts for evidence export, policy/control references, and
lifecycle task handoff.
## Out Of Scope
@@ -30,16 +38,21 @@ events.
- password, passkey, session, and MFA lifecycle;
- OIDC/SAML token issuance;
- final authorization policy decisions;
- durable authorization grant authority outside user-engine-owned memberships;
- policy, control, access-review, exception, and organization source-of-truth
ownership;
- runtime secret custody;
- UI implementation;
- full SCIM server or enterprise directory replacement in the initial product.
## Boundary Rule
user-engine owns user-domain facts and projections. Other systems may provide
identity, authorization, deployment, event transport, or UI surfaces, but they
must integrate through explicit interfaces rather than becoming hidden sources
of profile truth.
user-engine owns user-domain facts, identity-context mappings, and projections.
Other systems may provide authentication, IAM claims, authorization decisions,
policy/control authority, deployment, event transport, durable audit, secrets,
organization records, or UI surfaces, but they must integrate through explicit
interfaces rather than becoming hidden sources of profile or identity-domain
truth.
## Current Planning

View File

@@ -0,0 +1,118 @@
subsystem: user-engine
description: >
NetKingdom identity-domain integration layer for user, account, identity-link,
tenant, membership, profile, lifecycle, and evidence-facing context.
status: candidate
owner: codex
updated: "2026-06-05"
implements:
- identity-canon conceptual model as an implementation-facing domain facade
- InfoTechCanon user-engine evaluation pack
- small-saas user-management alignment surface
produces:
- User
- Account
- Identity Record
- Scoped Identifier
- Profile
- Tenant
- Membership Relationship
- Authenticated Subject reference
- Authorization Principal reference
- Evidence Source reference
- Access Grant or grant-like membership fact
consumes:
- NetKingdom IAM Profile claims
- verified issuer and subject identifiers
- assurance and principal type claims
- authorization decisions and obligations
- policy, control, review, exception, and evidence references
- lifecycle task references from downstream task systems
- platform audit and event sinks
owned_concepts:
user_record: User-engine local user record mapped to identity-canon User as a convenience term.
account_record: Operational account state for a user-engine scope.
external_identity_link: Source-specific issuer and subject link to a user record.
profile_value: Scoped profile or preference value.
membership_fact: User-domain relationship to tenant, team, application, or scope.
identity_context: Canon-facing read model over user, account, actor, subject, principal, scope, memberships, profile, and evidence references.
mapped_not_owned:
Actor: Consumed from verified identity claims and represented as canon reference.
Authenticated Subject: Projected from issuer and subject after authentication.
Authorization Principal: Projected for the authorization system; final decisions remain outside user-engine.
Policy: Referenced from authorization/governance systems.
Control: Referenced from security/governance systems.
Evidence Source: Referenced from audit, review, approval, or verification systems.
AccessReview: Referenced from governance or review systems.
Organization: Referenced when tenants, teams, or users map to organization systems.
Credential: Explicitly not stored or lifecycle-managed by user-engine.
accepted_relationships:
- identity_link
- belongs_to_tenant
- authenticates_as
- evaluated_as
- member_of
- role_label
- scoped_to
- governed_by
- implemented_by
- evidenced_by
- creates_task
emitted_events:
- user.created
- account.status_changed
- tenant_account.status_changed
- identity.linked
- membership.added
- profile.value_set
- application.registered
- catalog.published
required_identifiers:
actor_key: "issuer + subject"
user_id: "opaque user-engine user id"
account_id: "opaque user-engine account id"
identity_id: "opaque user-engine external identity id"
tenant: "NetKingdom-aligned tenant or platform scope"
application_id: "registered application id when projection is application-scoped"
correlation_id: "operation-level audit and event correlation id"
mapping_rules:
- Resolve source terms such as user, group, role, tenant, subject, and principal into identity-canon layers before exposing them as implementation concepts.
- Keep account records, authenticated subjects, and authorization principals distinct even when they share issuer or subject identifiers.
- Treat memberships as relationship facts that may produce grant-like access facts, not as final authorization decisions.
- Preserve source system, scope, lifecycle state, and evidence reference whenever a relationship affects access, privacy, or lifecycle.
- Link policy, control, review, exception, and task concepts by reference unless user-engine is the actual source of truth.
validation_rules:
- User, Account, Actor, Authenticated Subject, Authorization Principal, Tenant, Team, Membership, and Profile must remain distinguishable in the identity context read model.
- Tenant-scoped privileged membership must expose scope and evidence or an explicit evidence gap.
- Cross-tenant context must be denied unless the actor is a platform operator.
- Claims enrichment projections must not imply token issuance ownership.
- Credential, MFA, policy decision, and durable platform audit ownership must remain outside user-engine.
source_of_truth:
user_records: user-engine
account_state: user-engine
external_identity_links: user-engine
profile_values: user-engine
membership_facts_created_here: user-engine
authentication: NetKingdom IAM infrastructure
credential_assurance: NetKingdom IAM/security infrastructure
authorization_decisions: NetKingdom authorization infrastructure
policy_and_control_definitions: NetKingdom governance/security infrastructure
durable_platform_audit: NetKingdom audit infrastructure
organization_authority: NetKingdom organization or directory systems
known_deviations:
- User remains a local implementation class even though identity-canon treats user as a non-root convenience term; mappings must state whether it represents actor-facing profile holder, account owner, or local user record.
- Access Grant is currently a grant-like reference derived from memberships, not a durable authorization grant table.
- Evidence Source references currently derive from local audit records unless an external evidence exporter is supplied.
- AccessReview, Policy, Control, Exception, and lifecycle Task are references or gaps, not first-class owned records.

103
docs/canon-mapping.md Normal file
View File

@@ -0,0 +1,103 @@
# Canon Mapping
Status: candidate
Updated: 2026-06-05
This document maps current `user-engine` implementation concepts to
identity-canon and InfoTechCanon concepts. It is intentionally explicit about
owned facts, consumed facts, references, and gaps.
## Mapping Stance
`identity-canon` treats `User` as a convenience term, not the root concept.
`user-engine` keeps the existing `User` implementation class for compatibility,
but exposes canon-facing context through `identity_context` so consumers can see
the distinct account, actor, subject, principal, tenant, membership, profile,
and evidence references.
## Entity Mapping
| user-engine concept | Canon concept | Ownership | Notes |
| --- | --- | --- | --- |
| `Actor` | Actor | consumed/reference | Normalized from verified IAM claims; not persisted as source of truth. |
| `Actor.issuer + Actor.subject` | Authenticated Subject / Scoped Identifier | consumed/reference | Protocol-level identity after authentication. |
| `User` | User convenience term / profile holder | owned local record | Not treated as Natural Person unless a future mapping proves that relationship. |
| `Account` | Account | owned | Operational account state for the user-engine scope. |
| `TenantAccount` | Account scoped to Tenant | owned | Tenant-local account lifecycle state. |
| `ExternalIdentity` | Identity Record / Scoped Identifier / Synonymity Assertion seed | owned link | Links issuer-subject pairs to local user records without destructive merge. |
| `Application` | Scope / Application Service reference | owned local registry | Application-specific profile consumer and catalog owner. |
| `ApplicationBinding` | Trust Relationship / integration binding | owned local registry | Binds user-engine app IDs to external clients, protected systems, events, and catalogs. |
| `Membership` | Membership Relationship | owned when created here | Relationship from user/account context to tenant, team, application, group, or other scope. |
| `Membership.kind` | Role label / grant-like fact | owned local label | Role-like string is not a final authorization decision. |
| `ProfileValue` | Profile | owned | Scoped presentation or preference value. |
| `Catalog` / `AttributeDefinition` | Profile schema / governance metadata | owned | Attribute ownership, sensitivity, visibility, and mutability metadata. |
| `AuthorizationRequest` | Authorization request | emitted/consumed | Sent to authorization port; policy decision remains external. |
| `AuthorizationDecision` | Authorization decision | consumed/reference | Enforced locally; decision authority remains external. |
| `AuditRecord` | Evidence Source | owned local evidence, exportable | Local operational evidence; durable platform audit is external. |
| `OutboxEvent` | Lifecycle event / evidence source | owned local event | Integration event queue for downstream systems. |
## Relationship Mapping
| Relationship | Source | Target | Implementation source |
| --- | --- | --- | --- |
| `identity_link` | Identity Record | User | `ExternalIdentity.user_id` |
| `belongs_to_tenant` | User or Account | Tenant | `TenantAccount` and tenant-scoped operations |
| `authenticates_as` | Account | Authenticated Subject | normalized IAM claims |
| `evaluated_as` | Authenticated Subject | Authorization Principal | `identity_context` principal projection |
| `member_of` | User | Team / Group / Scope | `Membership(scope_type, scope_id)` |
| `role_label` | Membership Relationship | Role | `Membership.kind` |
| `scoped_to` | Access Grant or grant-like fact | Tenant / Scope | `Membership.tenant` and operation tenant |
| `governed_by` | Grant-like fact / decision | Policy reference | external policy/control resolver gap |
| `implemented_by` | Policy reference | Control reference | external policy/control resolver gap |
| `evidenced_by` | Identity-domain claim or grant-like fact | Evidence Source | local audit or external evidence exporter |
| `creates_task` | Evidence gap / integration gap | lifecycle task reference | lifecycle task sink gap |
## Read Model
`UserEngineService.identity_context(...)` returns the current implementation
surface for canon-facing identity context. It includes:
- the normalized actor;
- local user and account records;
- external identity links;
- tenant and optional application scope;
- membership facts;
- entity references for User, Account, Actor, Authenticated Subject,
Authorization Principal, Tenant, Identity Record, Scoped Identifier, Team,
Scope, Membership Relationship, and Role;
- relationship references for identity link, tenant scope, authentication,
principal evaluation, membership, role label, and grant-like scope;
- access grant-like references derived from memberships;
- evidence references derived from audit records;
- an optional effective profile projection;
- explicit gaps when evidence is missing.
## Current Gaps
- `Natural Person` is not modeled directly. A user record may represent a human
profile holder, but that relationship is not currently asserted.
- `Synonymity Assertion` is implicit in identity linking. Strong/weak link
confidence, verification method, privacy scope, and revocation state are not
first-class yet.
- `Organization`, `Customer`, `Vendor`, and `Legal Entity` are referenced only
through tenant, team, owner, or external system identifiers.
- `AccessReview`, `Policy`, `Control`, `Exception`, and lifecycle `Task`
references depend on external NetKingdom systems or future adapter
implementations.
- `Access Grant` is currently an exported grant-like reference derived from
memberships. It is not a durable authorization grant table.
- Evidence references currently derive from local audit records unless an
`EvidenceReferenceExporter` is supplied.
## Validation Hooks
The first executable checks live in `tests/test_identity_canon_alignment.py`.
They verify that:
- user, account, authenticated subject, authorization principal, tenant, team,
membership, and grant-like references remain distinct;
- service accounts project into service principals without becoming natural
persons;
- cross-tenant identity context is denied without platform scope;
- claims-enrichment context can be read without implying token issuance
ownership.

View File

@@ -10,9 +10,32 @@ HTTP or RPC adapters should preserve these operation names:
- `resolve_tenant_context`, `set_tenant_account_status`, `add_membership`,
`tenant_diagnostics`
- `register_application`, `publish_catalog`
- `set_profile_value`, `effective_profile`, `projection`
- `set_profile_value`, `effective_profile`, `projection`, `identity_context`
- `audit_records`, `outbox_events`
## 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.
The method keeps these concepts distinct:
- user-engine `User` record;
- operational `Account`;
- external `Identity Record` and scoped issuer/subject identifier;
- `Actor` from verified claims;
- `Authenticated Subject` projected from issuer and subject;
- `Authorization Principal` projected for policy evaluation;
- `Tenant`, `Team`, `Scope`, `Membership Relationship`, and `Role` references.
Evidence references are currently derived from local audit records. External
policy, control, access-review, exception, and lifecycle task references belong
to adapter contracts and remain non-owned unless a later workplan assigns
source-of-truth responsibility to user-engine.
## Error Taxonomy
- `ValidationError`: caller supplied an invalid shape, state transition, or
@@ -42,6 +65,9 @@ Every mutating service operation appends an audit record and outbox event with
the same correlation id and resolved tenant. Authorization denials are audited
without emitting outbox events.
Local audit records may be exported as identity-canon `Evidence Source`
references. Durable platform audit custody remains outside user-engine.
## Migration Contract
The isolated store exposes `SCHEMA_VERSION = 0001_initial` and a `migrate`

View File

@@ -0,0 +1,62 @@
# Evidence Gap Examples
Status: candidate
Updated: 2026-06-05
`user-engine` should not pretend missing review or governance material exists.
When identity-domain context lacks evidence, policy, control, review, or task
references, the gap must be explicit and handoff-ready.
## Gap Shape
```yaml
gap_id: evidence:no-audit-records
subject:
concept: Account
identifier: acct_example
scope: tenant:acme
reason: No local audit or external evidence reference supports this identity-domain claim.
proposed_disposition: create_or_link_lifecycle_task
owner: user-engine adapter boundary
```
## Privileged Membership Without External Review
```yaml
gap_id: review:tenant-admin-membership
subject:
concept: Access Grant
identifier: mem_example
scope: tenant:acme
reason: Tenant admin membership has local audit evidence but no external access review reference.
proposed_disposition: link AccessReview through EvidenceReferenceExporter or create review task through LifecycleTaskSink.
```
## Policy Or Control Reference Missing
```yaml
gap_id: control:tenant-isolation-reference
subject:
concept: Membership Relationship
identifier: mem_example
scope: tenant:acme
reason: Membership is tenant-scoped, but no external policy/control reference was supplied.
proposed_disposition: resolve policy and control through PolicyControlReferenceResolver.
```
## Lifecycle Task Handoff
```yaml
task_reference:
concept: Task
identifier: task_from_lifecycle_sink
source_gap: review:tenant-admin-membership
summary: Review tenant-admin membership for tenant:acme.
evidence:
- concept: Evidence Source
identifier: aud_example
```
These examples are intentionally adapter-neutral. The task, review, policy, and
control source of truth belongs to the surrounding NetKingdom systems unless a
future workplan assigns one of those responsibilities to `user-engine`.

View File

@@ -0,0 +1,55 @@
# Identity Domain Naming Decision
Status: candidate
Decision date: 2026-06-05
## Context
`user-engine` is moving from a narrow user/profile service toward a NetKingdom
identity-domain integration layer. That raises the question of whether the
repository should be renamed to `identity-engine`, `identity-domain-engine`, or
`organization-engine`.
## Decision
Keep the repository named `user-engine` for USER-WP-0007 implementation.
Treat `identity-engine` or `identity-domain-engine` as future candidate names
only after the canon interface card, mapping export, identity-context read
model, NetKingdom adapter contracts, and conformance tests are established and
reviewed.
Do not use `organization-engine` for this scope.
## Rationale
- `user-engine` is already the implemented package, module, docs, and workplan
identity.
- The current implementation still owns user-domain facts: users, accounts,
identity links, profiles, memberships, catalogs, projections, audit, and
events.
- `identity-engine` could imply ownership of the full identity provider,
credential lifecycle, federation, MFA, token issuance, and IAM substrate unless
the boundary is already well proven.
- `organization-engine` would imply ownership of organization, HR, authority,
responsibility, reporting, legal entity, customer, vendor, and community
modeling. Those are adjacent canon concepts, not this repo's current source of
truth.
## Rename Criteria
Revisit the name when all of the following are true:
- consumers primarily use the identity-context API rather than only user/profile
APIs;
- NetKingdom IAM, authorization, audit, and evidence adapters are stable;
- identity-canon mappings are validated by executable scenarios;
- docs consistently describe the repo as an identity-domain facade and not as an
identity provider;
- downstream consumers would be misled by the old `user-engine` name.
## Consequences
- Existing imports, package names, workplans, and docs remain stable.
- The intent and interface card carry the strategic direction explicitly.
- Naming pressure becomes a tracked decision rather than hidden scope drift.

View File

@@ -23,6 +23,9 @@ See also the cross-repo assessment in the net-kingdom repo:
| `MembershipFactExporter` | outbound | Export user-engine-owned membership facts as read models for authorization systems. |
| `EventOutbox` | outbound | Publish durable lifecycle/profile/catalog/membership events after commit. |
| `AuditWriter` | local/outbound | Persist local audit and provide redacted summaries for platform audit sinks. |
| `EvidenceReferenceExporter` | outbound | Export local audit/review material as identity-canon evidence references without owning the platform audit system. |
| `PolicyControlReferenceResolver` | outbound | Resolve policy, control, review, exception, or waiver references for identity-domain traces. |
| `LifecycleTaskSink` | outbound | Create or link lifecycle, review, remediation, or integration-gap work in a task system. |
| `SecretProvider` | inbound | Load runtime secrets through environment/local config in standalone mode and scoped secret providers in platform mode. |
## Source-Of-Truth Rules
@@ -33,6 +36,11 @@ See also the cross-repo assessment in the net-kingdom repo:
- Identity providers are the source of truth for authentication-time subject,
issuer, assurance, coarse role, and group claims.
- Authorization systems decide whether an actor may perform an action.
- Governance, security, and authorization systems own policy, control, review,
exception, and waiver definitions. user-engine can reference them in identity
context traces.
- Task systems own lifecycle work queues. user-engine can create or link
identity-domain work through an explicit task sink.
- Event sinks and audit stores consume user-engine events; they do not become
profile stores.
@@ -64,6 +72,41 @@ context:
The domain layer should depend on this port, not on a concrete authorization
client.
## Identity Context Shape
Every domain-facing identity context should be reducible to:
```yaml
actor:
issuer: string
subject: string
tenant: string
principal_type: human | service | agent
user:
user_id: string
account:
account_id: string
status: string
identity_links:
- issuer: string
subject: string
tenant: string
application_id: string | null
memberships:
- scope_type: string
scope_id: string
kind: string
canon_refs:
entities: object
relationships: object
grant_like_refs: object
evidence_refs: object
gaps: [string]
```
The context is a facade over NetKingdom IAM and security infrastructure. It
does not issue tokens, decide authorization policy, or own credentials.
## Projection Types
- `self_service`: current-user view and allowed mutations.

View File

@@ -14,6 +14,7 @@ projection, audit, and event behavior testable without a UI.
| two_applications | Catalog ownership and projections prevent application data leakage. |
| sensitive_redaction | Sensitive values are redacted in runtime and claims-enrichment projections. |
| audit_event_replay | Mutations carry audit records, outbox events, and correlation ids. |
| identity_canon_context | Actor, user, account, authenticated subject, authorization principal, tenant, membership, grant-like facts, and evidence references stay distinguishable. |
## Fixture Actors

View File

@@ -11,6 +11,8 @@ from user_engine.domain.models import (
AuthorizationDecision,
AuthorizationEffect,
AuthorizationRequest,
CanonEntityReference,
CanonRelationshipReference,
Catalog,
CatalogLifecycle,
ExternalIdentity,
@@ -41,6 +43,8 @@ __all__ = [
"AuthorizationDecision",
"AuthorizationEffect",
"AuthorizationRequest",
"CanonEntityReference",
"CanonRelationshipReference",
"Catalog",
"CatalogLifecycle",
"ExternalIdentity",

View File

@@ -99,6 +99,27 @@ class AuthorizationEffect(StrEnum):
NOT_APPLICABLE = "not_applicable"
@dataclass(frozen=True)
class CanonEntityReference:
concept: str
identifier: str
scope: str | None = None
source_system: str = "user-engine"
local_type: str | None = None
evidence_id: str | None = None
@dataclass(frozen=True)
class CanonRelationshipReference:
relationship_type: str
source: CanonEntityReference
target: CanonEntityReference
scope: str | None = None
source_system: str = "user-engine"
evidence_id: str | None = None
lifecycle_state: str = "active"
@dataclass(frozen=True)
class Actor:
issuer: str

View File

@@ -15,6 +15,7 @@ from user_engine.domain import (
AuditRecord,
AuthorizationDecision,
AuthorizationRequest,
CanonEntityReference,
Membership,
OutboxEvent,
)
@@ -76,6 +77,37 @@ class AuditWriter(Protocol):
"""Persist an audit record."""
class EvidenceReferenceExporter(Protocol):
"""Export audit/review material as identity-canon evidence references."""
def export(
self, audit_records: Iterable[AuditRecord]
) -> tuple[CanonEntityReference, ...]:
"""Return evidence references without owning the platform audit sink."""
class PolicyControlReferenceResolver(Protocol):
"""Resolve policy/control references for identity-domain traces."""
def references_for(
self, request: AuthorizationRequest, decision: AuthorizationDecision
) -> Mapping[str, CanonEntityReference]:
"""Return policy, control, review, or exception references when known."""
class LifecycleTaskSink(Protocol):
"""Handoff identity-domain gaps or lifecycle work to a task system."""
def create_or_link(
self,
*,
summary: str,
subject: CanonEntityReference,
evidence: Iterable[CanonEntityReference] = (),
) -> CanonEntityReference:
"""Return the task reference created or linked by the downstream system."""
class SecretProvider(Protocol):
"""Load runtime secret material from the active environment."""

View File

@@ -15,6 +15,8 @@ from user_engine.domain import (
AttributeDefinition,
AuditRecord,
AuthorizationRequest,
CanonEntityReference,
CanonRelationshipReference,
Catalog,
CatalogLifecycle,
ExternalIdentity,
@@ -89,6 +91,23 @@ class Projection:
redactions: Mapping[str, str]
@dataclass(frozen=True)
class IdentityContext:
actor: Actor
user: User
account: Account
identities: tuple[ExternalIdentity, ...]
tenant: str
application_id: str | None
memberships: tuple[Membership, ...]
entity_refs: Mapping[str, CanonEntityReference]
relationship_refs: tuple[CanonRelationshipReference, ...]
grant_like_refs: tuple[CanonEntityReference, ...]
evidence_refs: tuple[CanonEntityReference, ...]
profile: EffectiveProfile | None = None
gaps: tuple[str, ...] = ()
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
@@ -643,6 +662,89 @@ class UserEngineService:
redactions=redactions,
)
def identity_context(
self,
actor: Actor,
*,
user_id: str | None = None,
tenant: str | None = None,
application_id: str | None = None,
include_profile: bool = False,
correlation_id: str | None = None,
) -> IdentityContext:
tenant_context = self.resolve_tenant_context(actor, tenant)
identity = self.store.find_identity(*actor.identity_key)
if user_id is None:
if identity is None:
raise NotFoundError("identity link not found")
user_id = identity.user_id
user = self._require_user(user_id)
account = self._require_account(user_id)
correlation_id = correlation_id or new_id("corr")
self._authorize(
actor,
action="identity_context.read",
resource_type="user-engine:identity-context",
resource_id=user_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=application_id,
target_user_id=user_id,
context={"include_profile": include_profile},
)
identities = tuple(
identity
for identity in self.store.identities.values()
if identity.user_id == user.user_id
)
memberships = self.store.memberships_for_user(
user.user_id, tenant=tenant_context.tenant
)
evidence_refs = self._identity_evidence_refs(
user,
account,
memberships,
tenant_context.tenant,
)
entity_refs = self._identity_entity_refs(
actor,
user,
account,
identities,
memberships,
tenant_context.tenant,
application_id,
)
relationship_refs, grant_like_refs = self._identity_relationship_refs(
entity_refs,
memberships,
tenant_context.tenant,
evidence_refs[0].identifier if evidence_refs else None,
)
profile = (
self._resolve_effective_profile(
user.user_id, application_id, tenant_context.tenant
)
if include_profile
else None
)
gaps = () if evidence_refs else ("evidence:no-audit-records",)
return IdentityContext(
actor=actor,
user=user,
account=account,
identities=identities,
tenant=tenant_context.tenant,
application_id=application_id,
memberships=memberships,
entity_refs=entity_refs,
relationship_refs=relationship_refs,
grant_like_refs=grant_like_refs,
evidence_refs=evidence_refs,
profile=profile,
gaps=gaps,
)
def tenant_diagnostics(
self,
actor: Actor,
@@ -737,6 +839,216 @@ class UserEngineService:
context["actor_subject"] = actor.subject
return context
def _identity_entity_refs(
self,
actor: Actor,
user: User,
account: Account,
identities: tuple[ExternalIdentity, ...],
memberships: tuple[Membership, ...],
tenant: str,
application_id: str | None,
) -> Mapping[str, CanonEntityReference]:
actor_identifier = f"{actor.issuer}:{actor.subject}"
refs: dict[str, CanonEntityReference] = {
"actor": CanonEntityReference(
concept="Actor",
identifier=actor_identifier,
scope=actor.tenant,
source_system=actor.issuer,
local_type=actor.principal_type.value,
),
"user": CanonEntityReference(
concept="User",
identifier=user.user_id,
scope=tenant,
local_type="user-engine-user-record",
),
"account": CanonEntityReference(
concept="Account",
identifier=account.account_id,
scope=tenant,
local_type=account.status.value,
),
"authenticated_subject": CanonEntityReference(
concept="Authenticated Subject",
identifier=actor_identifier,
scope=actor.tenant,
source_system=actor.issuer,
local_type="issuer-subject",
),
"authorization_principal": CanonEntityReference(
concept="Authorization Principal",
identifier=f"{tenant}:{actor_identifier}",
scope=tenant,
local_type=actor.principal_type.value,
),
"tenant": CanonEntityReference(
concept="Tenant",
identifier=tenant,
scope=tenant,
local_type="isolation-scope",
),
}
if application_id is not None:
refs["application_scope"] = CanonEntityReference(
concept="Scope",
identifier=application_id,
scope=tenant,
local_type="application",
)
for identity in identities:
refs[f"identity_record:{identity.identity_id}"] = CanonEntityReference(
concept="Identity Record",
identifier=identity.identity_id,
scope=identity.issuer,
source_system=identity.issuer,
local_type=identity.provider,
)
refs[f"identifier:{identity.identity_id}"] = CanonEntityReference(
concept="Scoped Identifier",
identifier=f"{identity.issuer}:{identity.subject}",
scope=identity.issuer,
source_system=identity.issuer,
local_type="issuer-subject",
)
for membership in memberships:
scope_key = f"{membership.scope_type}:{membership.scope_id}"
refs.setdefault(
scope_key,
CanonEntityReference(
concept=_scope_concept(membership.scope_type),
identifier=membership.scope_id,
scope=tenant,
local_type=membership.scope_type,
),
)
refs[f"membership:{membership.membership_id}"] = CanonEntityReference(
concept="Membership Relationship",
identifier=membership.membership_id,
scope=tenant,
local_type=membership.kind,
)
refs[f"role:{membership.membership_id}"] = CanonEntityReference(
concept="Role",
identifier=f"{membership.scope_id}:{membership.kind}",
scope=membership.scope_id,
local_type=membership.kind,
)
return refs
def _identity_relationship_refs(
self,
entity_refs: Mapping[str, CanonEntityReference],
memberships: tuple[Membership, ...],
tenant: str,
evidence_id: str | None,
) -> tuple[tuple[CanonRelationshipReference, ...], tuple[CanonEntityReference, ...]]:
account_ref = entity_refs["account"]
subject_ref = entity_refs["authenticated_subject"]
principal_ref = entity_refs["authorization_principal"]
user_ref = entity_refs["user"]
tenant_ref = entity_refs["tenant"]
relationships = [
CanonRelationshipReference(
relationship_type="belongs_to_tenant",
source=user_ref,
target=tenant_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="authenticates_as",
source=account_ref,
target=subject_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="evaluated_as",
source=subject_ref,
target=principal_ref,
scope=tenant,
evidence_id=evidence_id,
),
]
grant_like_refs: list[CanonEntityReference] = []
for key, ref in entity_refs.items():
if key.startswith("identity_record:"):
relationships.append(
CanonRelationshipReference(
relationship_type="identity_link",
source=ref,
target=user_ref,
scope=ref.scope,
evidence_id=evidence_id,
)
)
for membership in memberships:
membership_ref = entity_refs[f"membership:{membership.membership_id}"]
role_ref = entity_refs[f"role:{membership.membership_id}"]
scope_ref = entity_refs[f"{membership.scope_type}:{membership.scope_id}"]
grant_ref = CanonEntityReference(
concept="Access Grant",
identifier=membership.membership_id,
scope=tenant,
local_type=membership.kind,
evidence_id=evidence_id,
)
grant_like_refs.append(grant_ref)
relationships.extend(
(
CanonRelationshipReference(
relationship_type="member_of",
source=user_ref,
target=scope_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="role_label",
source=membership_ref,
target=role_ref,
scope=tenant,
evidence_id=evidence_id,
),
CanonRelationshipReference(
relationship_type="scoped_to",
source=grant_ref,
target=tenant_ref,
scope=tenant,
evidence_id=evidence_id,
),
)
)
return tuple(relationships), tuple(grant_like_refs)
def _identity_evidence_refs(
self,
user: User,
account: Account,
memberships: tuple[Membership, ...],
tenant: str,
) -> tuple[CanonEntityReference, ...]:
membership_ids = {membership.membership_id for membership in memberships}
subjects = {user.user_id, account.account_id, *membership_ids}
evidence = []
for record in self.store.audit_records:
if record.tenant != tenant:
continue
if record.subject not in subjects and record.summary != "membership.added":
continue
evidence.append(
CanonEntityReference(
concept="Evidence Source",
identifier=record.audit_id,
scope=tenant,
source_system="user-engine:audit",
local_type=record.summary or record.action,
)
)
return tuple(evidence)
def _session(self, actor: Actor, user: User, account: Account) -> UserSession:
identities = tuple(
identity
@@ -1089,6 +1401,16 @@ def _optional_claim(actor: Actor, key: str) -> str | None:
return str(value)
def _scope_concept(scope_type: str) -> str:
concepts = {
"team": "Team",
"tenant": "Tenant",
"application": "Scope",
"group": "Group",
}
return concepts.get(scope_type, "Scope")
def _visible_in_projection(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:

View File

@@ -0,0 +1,159 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore, LocalAuthorizationCheckPort
from user_engine.domain import AccountStatus, ProjectionType
from user_engine.errors import AuthorizationDenied
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import (
FixtureIdentityClaimsAdapter,
human_actor_claims,
sample_application,
sample_application_binding,
sample_catalog,
)
from user_engine.testing.scenarios import service_claims
class IdentityCanonAlignmentTests(unittest.TestCase):
def test_identity_context_separates_user_account_subject_and_principal(self):
service, _, _ = _service()
session = _bootstrap(service, _claims(subject="ada-admin", tenant="tenant:acme"))
service.add_membership(
session.actor,
session.user.user_id,
tenant="tenant:acme",
scope_type="team",
scope_id="team:platform",
kind="tenant-admin",
correlation_id="corr-acme-admin",
)
context = service.identity_context(
session.actor,
application_id="app.demo",
include_profile=True,
correlation_id="corr-context",
)
self.assertEqual(context.entity_refs["user"].concept, "User")
self.assertEqual(context.entity_refs["account"].concept, "Account")
self.assertEqual(
context.entity_refs["authenticated_subject"].concept,
"Authenticated Subject",
)
self.assertEqual(
context.entity_refs["authorization_principal"].concept,
"Authorization Principal",
)
self.assertNotEqual(
context.entity_refs["account"].identifier,
context.entity_refs["authenticated_subject"].identifier,
)
self.assertEqual(context.entity_refs["team:team:platform"].concept, "Team")
self.assertEqual(context.profile.values["demo.display_density"], "comfortable")
self.assertFalse(context.gaps)
relationship_types = {
relationship.relationship_type for relationship in context.relationship_refs
}
self.assertIn("authenticates_as", relationship_types)
self.assertIn("evaluated_as", relationship_types)
self.assertIn("member_of", relationship_types)
self.assertIn("scoped_to", relationship_types)
self.assertEqual(context.grant_like_refs[0].concept, "Access Grant")
self.assertEqual(context.grant_like_refs[0].local_type, "tenant-admin")
self.assertTrue(context.evidence_refs)
def test_identity_context_handles_service_account_projection(self):
service, _, _ = _service()
session = _bootstrap(service, service_claims())
context = service.identity_context(
session.actor,
correlation_id="corr-service-context",
)
self.assertEqual(context.entity_refs["actor"].local_type, "service")
self.assertEqual(context.entity_refs["account"].concept, "Account")
self.assertEqual(
context.entity_refs["authorization_principal"].local_type,
"service",
)
def test_small_saas_tenant_context_denies_globex_without_scope(self):
service, _, _ = _service()
session = _bootstrap(service, _claims(subject="ada-admin", tenant="tenant:acme"))
service.set_tenant_account_status(
session.actor,
session.user.user_id,
AccountStatus.ACTIVE,
tenant="tenant:acme",
correlation_id="corr-acme-account",
)
with self.assertRaises(AuthorizationDenied):
service.identity_context(
session.actor,
tenant="tenant:globex",
correlation_id="corr-globex-context",
)
def test_identity_context_can_be_read_as_claims_enrichment_input(self):
service, _, authz = _service()
session = _bootstrap(service, _claims(subject="claim-user", tenant="tenant:acme"))
projection = service.projection(
session.actor,
session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
application_id="app.demo",
tenant="tenant:acme",
correlation_id="corr-claims",
)
context = service.identity_context(
session.actor,
application_id="app.demo",
include_profile=True,
correlation_id="corr-claims-context",
)
self.assertEqual(projection.values["demo.display_density"], "comfortable")
self.assertEqual(context.application_id, "app.demo")
self.assertIn("identity_context.read", [request.action for request in authz.requests])
def _service():
store = InMemoryUserEngineStore()
authz = LocalAuthorizationCheckPort()
service = UserEngineService(
store=store,
identity_adapter=FixtureIdentityClaimsAdapter(),
authorization=authz,
)
return service, store, authz
def _bootstrap(service: UserEngineService, claims: dict[str, object]):
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(*, subject: str, tenant: str) -> dict[str, object]:
claims = human_actor_claims(subject=subject, tenant=tenant)
claims["roles"] = ["tenant-admin"]
return claims
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Identity Domain Canon Alignment"
domain: netkingdom
repo: user-engine
status: proposed
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: high
@@ -59,7 +59,7 @@ exists for that context?
```task
id: USER-WP-0007-T1
status: todo
status: done
priority: high
state_hub_task_id: "09bf2de5-0dab-4c21-845a-ff7dbde4cbd8"
```
@@ -73,7 +73,7 @@ work.
```task
id: USER-WP-0007-T2
status: todo
status: done
priority: high
state_hub_task_id: "8d10eaf7-12ac-4a7c-bf90-ded6fc59eeb4"
```
@@ -86,7 +86,7 @@ models and service operations to canon concepts and relationships including
```task
id: USER-WP-0007-T3
status: todo
status: done
priority: high
state_hub_task_id: "c3839ae6-5d82-4cfd-819a-79b3bdf6efa6"
```
@@ -98,7 +98,7 @@ without requiring consumers to know IAM provider details.
```task
id: USER-WP-0007-T4
status: todo
status: done
priority: high
state_hub_task_id: "2d29ceec-2d0b-4753-82cc-3fd87a252ba1"
```
@@ -110,7 +110,7 @@ Membership, and Grant or grant-like membership facts.
```task
id: USER-WP-0007-T5
status: todo
status: done
priority: medium
state_hub_task_id: "1ad927a2-eca7-4904-a666-600617cb7519"
```
@@ -122,7 +122,7 @@ audit reference export, policy/control references, and lifecycle task handoff.
```task
id: USER-WP-0007-T6
status: todo
status: done
priority: medium
state_hub_task_id: "cb0dbd53-fb9a-4b84-87d8-e7cc3ce4ab40"
```
@@ -134,7 +134,7 @@ review, approval, exception, remediation, or explicit evidence gaps.
```task
id: USER-WP-0007-T7
status: todo
status: done
priority: medium
state_hub_task_id: "973bf3c5-e407-4441-a2e6-c9c8d5e55135"
```
@@ -146,7 +146,7 @@ tracked work rather than silent scope drift.
```task
id: USER-WP-0007-T8
status: todo
status: done
priority: low
state_hub_task_id: "e6285175-7b71-4a57-9183-dab8274f19a6"
```
@@ -186,3 +186,28 @@ organization responsibilities.
- small-SaaS canon conformance tests
- evidence-gap and lifecycle task examples
- naming decision record
## Implementation Notes
Implemented on 2026-06-05:
- Added `CanonEntityReference` and `CanonRelationshipReference` domain shapes.
- Added `UserEngineService.identity_context(...)` as the first canon-facing
identity-domain read model.
- Added adapter contracts for evidence export, policy/control references, and
lifecycle task handoff.
- Added `docs/canon-interface-card.yaml`, `docs/canon-mapping.md`,
`docs/evidence-gap-examples.md`, and
`docs/identity-domain-naming-decision.md`.
- Updated scope, contracts, NetKingdom integration docs, README, and scenarios.
- Added executable identity-canon alignment tests for distinct user/account/
subject/principal context, service-account projection, small-SaaS tenant
isolation, and claims-enrichment context.
Verification:
```text
make test
Ran 35 tests in 0.175s
OK
```