diff --git a/README.md b/README.md index 6a285c7..03323ec 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SCOPE.md b/SCOPE.md index 03f704e..c5b3a5e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -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 diff --git a/docs/canon-interface-card.yaml b/docs/canon-interface-card.yaml new file mode 100644 index 0000000..26228fb --- /dev/null +++ b/docs/canon-interface-card.yaml @@ -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. diff --git a/docs/canon-mapping.md b/docs/canon-mapping.md new file mode 100644 index 0000000..4000922 --- /dev/null +++ b/docs/canon-mapping.md @@ -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. diff --git a/docs/contracts.md b/docs/contracts.md index d22d416..cdbf654 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -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` diff --git a/docs/evidence-gap-examples.md b/docs/evidence-gap-examples.md new file mode 100644 index 0000000..4f2450a --- /dev/null +++ b/docs/evidence-gap-examples.md @@ -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`. diff --git a/docs/identity-domain-naming-decision.md b/docs/identity-domain-naming-decision.md new file mode 100644 index 0000000..fa5d9bb --- /dev/null +++ b/docs/identity-domain-naming-decision.md @@ -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. diff --git a/docs/interfaces/netkingdom-integration.md b/docs/interfaces/netkingdom-integration.md index 23827ed..2da30ef 100644 --- a/docs/interfaces/netkingdom-integration.md +++ b/docs/interfaces/netkingdom-integration.md @@ -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. diff --git a/docs/scenarios.md b/docs/scenarios.md index 15ee73c..7656a60 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -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 diff --git a/src/user_engine/domain/__init__.py b/src/user_engine/domain/__init__.py index e956da5..b9e10e1 100644 --- a/src/user_engine/domain/__init__.py +++ b/src/user_engine/domain/__init__.py @@ -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", diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index 96caff1..c9b8549 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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 diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index a84d20e..9f61969 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -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.""" diff --git a/src/user_engine/service.py b/src/user_engine/service.py index fe80cec..dc63b30 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -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: diff --git a/tests/test_identity_canon_alignment.py b/tests/test_identity_canon_alignment.py new file mode 100644 index 0000000..a125b42 --- /dev/null +++ b/tests/test_identity_canon_alignment.py @@ -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() diff --git a/workplans/USER-WP-0007-identity-domain-canon-alignment.md b/workplans/USER-WP-0007-identity-domain-canon-alignment.md index 9ee109e..c7dfb71 100644 --- a/workplans/USER-WP-0007-identity-domain-canon-alignment.md +++ b/workplans/USER-WP-0007-identity-domain-canon-alignment.md @@ -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 +```