Compare commits

...

2 Commits

Author SHA1 Message Date
531c2193a4 Implement family dataspace onboarding 2026-06-05 18:51:47 +02:00
af6d82038e Add Postgres durable store requirements 2026-06-05 18:50:36 +02:00
15 changed files with 1797 additions and 1 deletions

View File

@@ -8,7 +8,8 @@ make test
See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
`docs/canon-mapping.md`, `docs/canon-interface-card.yaml`,
`docs/evidence-gap-examples.md`, `docs/examples.md`, `docs/scenarios.md`,
`docs/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.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

View File

@@ -11,6 +11,9 @@ HTTP or RPC adapters should preserve these operation names:
`tenant_diagnostics`
- `register_application`, `publish_catalog`
- `set_profile_value`, `effective_profile`, `projection`, `identity_context`
- `onboard_family_dataspace`, `invite_family_member`,
`resend_family_invitation`, `revoke_family_invitation`,
`accept_family_invitation`
- `audit_records`, `outbox_events`
## Identity Context Contract
@@ -36,6 +39,24 @@ 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.
## Family Dataspace Onboarding Contract
`onboard_family_dataspace` is a convenience facade for personal-family
identity-domain setup. It composes existing user, account, tenant-account,
membership, application, catalog, profile, audit, outbox, projection, and
identity-context operations.
The facade represents a family as a NetKingdom tenant plus a `family` scope. It
does not provision the tenant, issue SSO tokens, own credentials, or implement
the protected dataspace runtime. Family roles are scoped membership facts such
as `owner`, `adult`, `child`, `guest`, and `delegated-caretaker`; authorization
systems decide how those facts affect access.
Invitation acceptance requires already-verified claims. user-engine stores
local invitation lifecycle, links the verified external identity, activates
account state, and returns both `identity_context` and a
`CLAIMS_ENRICHMENT` projection for SSO adapters.
## Error Taxonomy
- `ValidationError`: caller supplied an invalid shape, state transition, or

View File

@@ -66,3 +66,40 @@ operation. Outbox consumers should treat `event_id` as the delivery id and
for event in service.outbox_events():
print(event.event_type, event.aggregate_id, event.correlation_id)
```
## Onboard A Family Dataspace
```python
from user_engine.domain import FamilyDataspaceRequest, FamilyMemberSpec, FamilyRole
owner = service.me(owner_claims, correlation_id="corr-owner")
onboarding = service.onboard_family_dataspace(
owner.actor,
FamilyDataspaceRequest(
tenant="tenant:worsch-family",
family_scope_id="family:worsch",
family_display_name="Worsch Family",
application_id="app.personal-dataspace",
oidc_client_id="personal-dataspace-client",
protected_system_id="dataspace.personal.worsch",
member_specs=(
FamilyMemberSpec(
primary_email="child@example.test",
display_name="Child Member",
role=FamilyRole.CHILD,
),
),
),
correlation_id="corr-family-onboard",
)
accepted = service.accept_family_invitation(
child_claims,
onboarding.invitations[0].invitation.invitation_id,
correlation_id="corr-child-accept",
)
```
`accepted.identity_context` is the canon-facing context for the SSO adapter.
`accepted.claims_projection` is the application-visible profile projection for
the personal dataspace.

View File

@@ -0,0 +1,120 @@
# Family Dataspace Onboarding
Status: implemented MVP facade
Date: 2026-06-05
Related workplan: USER-WP-0008
## Purpose
Family dataspace onboarding is the first concrete convenience use case for
`user-engine` as a NetKingdom identity-domain integration layer. It lets a
consumer represent a family as a tenant-scoped identity context, invite family
members, bind a personal dataspace application, and produce SSO-ready identity
context without making callers sequence low-level user, profile, membership,
application, audit, and projection operations themselves.
## Model
| Use-case concept | user-engine representation | Source of truth |
| --- | --- | --- |
| Family | NetKingdom tenant plus `family` membership scope | NetKingdom tenant/organization infrastructure |
| Family owner | `User`, `Account`, active `TenantAccount`, `family:owner` membership | user-engine for local facts |
| Family member | invited `User`, `Account`, `TenantAccount`, `FamilyInvitation` | user-engine for local lifecycle |
| SSO identity | linked `ExternalIdentity` from verified `(issuer, subject)` | NetKingdom IAM for authentication |
| Family role | scoped `Membership.kind` such as `owner`, `adult`, `child`, `guest` | user-engine fact, authorization consumes it |
| Personal dataspace | registered `Application` with `ApplicationBinding` | user-engine binding, external runtime owns app |
| SSO claims input | `identity_context` plus `CLAIMS_ENRICHMENT` projection | user-engine read model, NetKingdom IAM consumes it |
## Public Flow
1. Resolve the owner through `me(...)` or pass an already-normalized actor.
2. Call `onboard_family_dataspace(...)` with a `FamilyDataspaceRequest`.
3. user-engine ensures the owner exists, registers the dataspace application,
publishes a minimal dataspace catalog, assigns owner membership, creates
pending member invitations, and returns identity context plus a
claims-enrichment projection for SSO.
4. Invited members accept through `accept_family_invitation(...)` using
verified NetKingdom claims. user-engine links the external identity,
activates account state, records audit/outbox events, and returns SSO-ready
context for the member.
5. Pending invitations can be resent or revoked through
`resend_family_invitation(...)` and `revoke_family_invitation(...)`.
## Example
```python
from user_engine.domain import FamilyDataspaceRequest, FamilyMemberSpec, FamilyRole
owner = service.me(owner_claims, correlation_id="corr-owner")
onboarding = service.onboard_family_dataspace(
owner.actor,
FamilyDataspaceRequest(
tenant="tenant:worsch-family",
family_scope_id="family:worsch",
family_display_name="Worsch Family",
application_id="app.personal-dataspace",
oidc_client_id="personal-dataspace-client",
protected_system_id="dataspace.personal.worsch",
member_specs=(
FamilyMemberSpec(
primary_email="child@example.test",
display_name="Child Member",
role=FamilyRole.CHILD,
),
),
),
correlation_id="corr-family-onboard",
)
member = service.accept_family_invitation(
member_claims,
onboarding.invitations[0].invitation.invitation_id,
correlation_id="corr-member-accept",
)
```
`onboarding.identity_context` and `member.identity_context` contain the
canon-facing actor, user, account, authenticated subject, authorization
principal, tenant, family group, membership, grant-like, and evidence
references. `claims_projection` contains application-visible profile values
such as the family display name and member display name.
## Boundary
user-engine does not issue tokens, manage credentials, run MFA, provision the
family tenant, or implement the personal dataspace runtime. Those remain
NetKingdom IAM, tenant, security, and application responsibilities.
Family roles are exported as scoped membership facts. The authorization port
decides whether those facts allow an action.
Invitation tokens and proofing are deliberately adapter-owned. The MVP
invitation record tracks local lifecycle state and assumes NetKingdom IAM has
already verified claims before acceptance.
## Audit And Events
The facade emits high-level events in addition to the lower-level events from
the operations it composes:
- `family_dataspace.onboarded`
- `family_member.invited`
- `family_invitation.resent`
- `family_invitation.revoked`
- `family_invitation.accepted`
Lower-level events such as `user.created`, `tenant_account.status_changed`,
`membership.added`, `identity.linked`, `application.registered`,
`catalog.published`, and `profile.value_set` remain visible for replay and
traceability.
## Current MVP Limits
- Invitations are stored in the current store boundary and need durable-store
backing before production use.
- Invitation delivery, one-time token material, and proofing are external
adapter responsibilities.
- Membership revocation and historical role lifecycle are not yet fully
modeled beyond invitation revoke and account status changes.
- The default dataspace catalog is intentionally minimal and should evolve with
real dataspace claims requirements.

View File

@@ -0,0 +1,262 @@
# Postgres Durable Store Consumer Requirements
Status: requirements
Date: 2026-06-05
Related workplan: USER-WP-0009
## Purpose
This document defines what `user-engine` needs from a durable Postgres-backed
store, from the consumer side. It intentionally does not design or implement
the Postgres provider. The expected direction is that an independent
NetKingdom infrastructure repository provides a tenant-aware, security
integrated Postgres capability, and `user-engine` consumes that capability
through a durable store adapter.
## Consumer Story
As a `user-engine` consumer, I want the service to persist identity-domain
facts durably while keeping NetKingdom security, IAM, secrets, network, tenant
isolation, backup, and operational controls outside the user-engine domain
implementation.
The desired experience is:
```text
NetKingdom gives user-engine a scoped Postgres capability.
user-engine applies or verifies its own schema for its own tables.
service operations keep the same behavior as the isolated MVP.
mutations, audit records, and outbox events commit atomically.
tenant boundaries and security controls are enforced by both adapter logic and
the provided database capability.
```
## Ownership Boundary
### NetKingdom Postgres Provider Owns
- Database or cluster provisioning.
- Tenant isolation primitive, such as database-per-tenant, schema-per-tenant,
row-level security, or another accepted model.
- Roles, credentials, certificate material, TLS requirements, secret rotation,
and credential lease policy.
- Network reachability, firewall rules, service identity admission, and
runtime policy integration.
- Backup, restore, PITR, replication, retention, and disaster recovery
controls.
- Platform-level metrics, logs, traces, alert routing, and operational
runbooks for the database capability.
- Base security posture, hardening, encryption at rest, and administrative
access controls.
### user-engine Owns
- Domain table definitions for its own data.
- Schema version expectations and forward migrations for user-engine tables.
- Store adapter behavior that satisfies the public service contract.
- Transaction boundaries for user-engine mutations.
- Domain constraints, validation, and deterministic query behavior.
- Local audit and outbox table semantics when those records are persisted in
the user-engine store.
- Store conformance tests.
### External Systems Continue To Own
- Identity provider configuration, token issuance, credentials, MFA, and
sessions.
- Authorization policy decisions.
- Platform audit custody and long-term audit archive.
- Secrets authority and secret distribution.
- Organization or tenant authority beyond user-engine references.
## Functional Requirements
### Store Parity
The durable store must satisfy the same behavior currently exercised against
the isolated store:
- Persist users, accounts, tenant accounts, external identities, applications,
application bindings, catalogs, profile values, memberships, audit records,
and outbox events.
- Return stable records by the same logical keys used by
`UserEngineService`.
- Preserve `schema_version`, `ready`, and migration readiness semantics.
- Support the same service-level exceptions for not found, conflict,
validation, and authorization-denied flows.
### Identity And Account Constraints
- `(issuer, subject)` must uniquely identify one external identity link.
- An external identity must not be linked to two different users.
- A user must have at most one primary account record in the current model.
- Tenant account records must be unique by `(tenant, user_id)`.
- Deleted or disabled account states must remain inspectable for audit and
lifecycle decisions.
### Tenant Boundary Requirements
- Every tenant-scoped row must carry an explicit tenant identifier or be
reachable only through an explicit tenant-scoped relationship.
- Queries that resolve tenant-scoped data must require tenant context from the
service layer.
- The adapter must fail closed when tenant context is missing for tenant-bound
operations.
- The provider should make tenant isolation enforceable below application code,
for example through separate databases, schemas, RLS policies, or scoped
database roles.
- Platform-level access must be represented as an explicit NetKingdom security
capability, not as a default database superuser path.
### Application And Catalog Requirements
- Application ids must be unique.
- Application bindings must be retrievable by application id.
- Active catalog namespace ownership must not move silently between
applications.
- Catalog versioning must preserve the existing rule that active definitions
cannot downgrade sensitivity or move versions backwards.
- Attribute lookup by key must remain deterministic and efficient enough for
projection generation.
### Profile And Projection Requirements
- Profile values must be unique by user, attribute key, scope, and scope id.
- Effective profile resolution must remain deterministic across global,
tenant, application, and membership scopes.
- Sensitive and secret values must not leak through diagnostics or logs.
- Projection reads should avoid N+1 query patterns for common application
runtime and claims-enrichment use cases.
### Membership Requirements
- Memberships must be queryable by user and tenant.
- Membership facts must carry scope type, scope id, kind, source system,
owning system, and freshness version.
- Privileged memberships should remain traceable to audit records, evidence
references, or explicit evidence gaps.
- The store must support future revoke/update behavior without losing the
ability to inspect historical role changes.
### Audit And Outbox Requirements
- Mutating service operations must commit domain changes, local audit records,
and outbox events atomically.
- Authorization denials must be auditable without emitting outbox events.
- Audit records should be append-only from the service perspective.
- Outbox records must support pending reads and future claim/ack/retry
semantics.
- Outbox event ids must be stable delivery ids, and correlation ids must remain
queryable for cross-system tracing.
### Transaction Requirements
- Each public mutating service operation must run in one transaction.
- Failed validation or authorization must not partially write domain state.
- Store implementation must handle uniqueness races deterministically and map
them to `ConflictError` where appropriate.
- Migration and outbox claiming should use explicit locking strategies that do
not require consumers to understand Postgres internals.
### Migration Requirements
- user-engine owns migrations for its own tables.
- Migrations must be forward-only unless an explicit rollback strategy is
accepted for a release.
- Readiness must report the expected schema version and actual store version.
- Startup behavior must distinguish "store unreachable", "migration required",
"migration in progress", and "ready".
- Destructive migrations require an explicit operator-controlled process.
- The provider may supply the database and schema container, but should not
need to know user-engine domain tables.
## Security Requirements
- Database credentials must come through a NetKingdom secret or identity
mechanism, not literal config files.
- Connections must require TLS when crossing process or host boundaries.
- Credentials should be scoped to the minimum database, schema, tenant, and
operations needed by user-engine.
- Logs, errors, readiness output, and diagnostics must not expose credentials,
connection strings, secret values, sensitive profile data, or full personal
records.
- The adapter must make tenant context explicit and auditable.
- The provider should expose enough security metadata for `identity_context`
evidence or gap references when privileged access or lifecycle work depends
on database-side controls.
## Operability Requirements
- Health checks should report whether the adapter can reach the store.
- Readiness checks should report schema compatibility and migration state.
- Diagnostics should include redacted connection target, schema version, last
migration, pending outbox count, and recent store error class.
- Metrics should cover connection failures, transaction failures, conflicts,
migration duration, query latency, and outbox backlog.
- Backup/restore expectations must be testable from the consumer side through
restore validation or equivalent provider evidence.
- Store failures should produce actionable errors without leaking sensitive
details.
## Provider Interface Expectations
The future provider repository should be able to give user-engine:
- A logical store reference for the NetKingdom environment and tenant scope.
- A secret handle or service identity mechanism for credentials.
- TLS or certificate requirements.
- Tenant isolation metadata that the adapter can record in diagnostics.
- Migration permission policy for user-engine-owned tables.
- Backup and restore evidence or status references.
- Operational contact/runbook references.
`user-engine` should not require:
- Cluster administrator credentials.
- Knowledge of physical cluster topology.
- Direct control over backups, replication, firewall rules, or secret
rotation.
- Provider-specific SQL outside an adapter layer.
## Acceptance Tests For A Future Adapter
A future Postgres adapter should pass conformance tests for:
- Creating a user from verified identity claims and reading it through `me`.
- Preventing duplicate `(issuer, subject)` links across users.
- Creating tenant accounts and denying cross-tenant reads through the service
layer.
- Adding memberships and returning them in `identity_context`.
- Registering an application, publishing a catalog, writing profile values,
and producing application runtime and claims-enrichment projections.
- Redacting sensitive values in non-eligible projections.
- Rolling back all writes when a mutation fails after validation or
authorization.
- Persisting audit records and outbox events atomically with mutations.
- Reporting not-ready state when schema version is missing or incompatible.
- Recovering from restart without losing users, memberships, profiles, audit,
or outbox records.
## Open Questions For The Provider Repository
- Should NetKingdom use database-per-tenant, schema-per-tenant, RLS, or a
hybrid model for user-engine data?
- Who runs user-engine migrations in production: user-engine startup, a
deployment job, or a provider-controlled migration runner?
- How are credential leases issued, renewed, revoked, and audited?
- What backup unit maps to a family or organization dataspace: cluster,
database, schema, tenant row set, or application scope?
- What evidence references can the provider expose for backup status, restore
tests, encryption posture, and access reviews?
- How should local development emulate the provider without weakening the
production contract?
## First Implementation Follow-Ups
After this requirements work is accepted, likely follow-up work should be:
- Define the durable store protocol changes, if any.
- Add a Postgres adapter behind the existing store boundary.
- Add migration files for user-engine tables.
- Add conformance tests that run against both in-memory and Postgres stores.
- Integrate the adapter with the future NetKingdom Postgres provider repo.

View File

@@ -15,6 +15,7 @@ projection, audit, and event behavior testable without a UI.
| 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. |
| family_dataspace_onboarding | A family tenant can register a personal dataspace, invite members, accept SSO identities, project claims context, and deny cross-family access. |
## Fixture Actors

View File

@@ -15,6 +15,7 @@ from user_engine.domain import (
AuthorizationRequest,
Catalog,
ExternalIdentity,
FamilyInvitation,
Membership,
OutboxEvent,
ProfileScope,
@@ -44,6 +45,7 @@ class InMemoryUserEngineStore:
applications: dict[str, Application] = field(default_factory=dict)
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
catalogs: dict[str, Catalog] = field(default_factory=dict)
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue
] = field(default_factory=dict)
@@ -82,6 +84,19 @@ class InMemoryUserEngineStore:
def save_catalog(self, catalog: Catalog) -> None:
self.catalogs[catalog.catalog_id] = catalog
def save_family_invitation(self, invitation: FamilyInvitation) -> None:
self.family_invitations[invitation.invitation_id] = invitation
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
return self.family_invitations.get(invitation_id)
def family_invitations_for_user(self, user_id: str) -> tuple[FamilyInvitation, ...]:
return tuple(
invitation
for invitation in self.family_invitations.values()
if invitation.user_id == user_id
)
def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id)

View File

@@ -16,6 +16,11 @@ from user_engine.domain.models import (
Catalog,
CatalogLifecycle,
ExternalIdentity,
FamilyDataspaceRequest,
FamilyInvitation,
FamilyMemberSpec,
FamilyRole,
InvitationStatus,
ManagementMode,
Membership,
Mutability,
@@ -48,6 +53,11 @@ __all__ = [
"Catalog",
"CatalogLifecycle",
"ExternalIdentity",
"FamilyDataspaceRequest",
"FamilyInvitation",
"FamilyMemberSpec",
"FamilyRole",
"InvitationStatus",
"ManagementMode",
"Membership",
"Mutability",

View File

@@ -45,6 +45,20 @@ class ManagementMode(StrEnum):
SERVICE_MANAGED = "service_managed"
class FamilyRole(StrEnum):
OWNER = "owner"
ADULT = "adult"
CHILD = "child"
GUEST = "guest"
DELEGATED_CARETAKER = "delegated-caretaker"
class InvitationStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
REVOKED = "revoked"
class ProfileScope(StrEnum):
GLOBAL = "global"
TENANT = "tenant"
@@ -252,6 +266,53 @@ class Membership:
created_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class FamilyMemberSpec:
primary_email: str
role: FamilyRole | str = FamilyRole.ADULT
display_name: str | None = None
issuer: str | None = None
subject: str | None = None
provider: str | None = None
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class FamilyDataspaceRequest:
tenant: str
family_scope_id: str
family_display_name: str
application_id: str = "app.personal-dataspace"
application_display_name: str = "Personal Dataspace"
oidc_client_id: str | None = None
protected_system_id: str | None = None
catalog_namespace: str = "dataspace"
event_source: str | None = None
deployment_ref: str | None = None
member_specs: tuple[FamilyMemberSpec, ...] = ()
owner_profile_defaults: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class FamilyInvitation:
invitation_id: str
tenant: str
family_scope_id: str
application_id: str
user_id: str
primary_email: str
role: str
status: InvitationStatus = InvitationStatus.PENDING
invited_by: str | None = None
correlation_id: str | None = None
resend_count: int = 0
last_sent_correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
accepted_at: datetime | None = None
revoked_at: datetime | None = None
@dataclass(frozen=True)
class AuthorizationRequest:
actor: Actor

View File

@@ -20,6 +20,11 @@ from user_engine.domain import (
Catalog,
CatalogLifecycle,
ExternalIdentity,
FamilyDataspaceRequest,
FamilyInvitation,
FamilyMemberSpec,
FamilyRole,
InvitationStatus,
Membership,
Mutability,
OutboxEvent,
@@ -31,6 +36,7 @@ from user_engine.domain import (
User,
Visibility,
new_id,
utc_now,
)
from user_engine.errors import (
AuthorizationDenied,
@@ -108,6 +114,37 @@ class IdentityContext:
gaps: tuple[str, ...] = ()
@dataclass(frozen=True)
class FamilyMemberInvitation:
user: User
tenant_account: TenantAccount
membership: Membership
invitation: FamilyInvitation
@dataclass(frozen=True)
class FamilyDataspaceOnboarding:
tenant: str
family_scope_id: str
family_display_name: str
owner_session: UserSession
owner_membership: Membership
application: Application
binding: ApplicationBinding
catalog: Catalog
invitations: tuple[FamilyMemberInvitation, ...]
identity_context: IdentityContext
claims_projection: Projection
@dataclass(frozen=True)
class FamilyInvitationAcceptance:
session: UserSession
invitation: FamilyInvitation
identity_context: IdentityContext
claims_projection: Projection
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
@@ -745,6 +782,431 @@ class UserEngineService:
gaps=gaps,
)
def onboard_family_dataspace(
self,
actor: Actor,
request: FamilyDataspaceRequest,
*,
correlation_id: str | None = None,
) -> FamilyDataspaceOnboarding:
tenant_context = self.resolve_tenant_context(actor, request.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_dataspace.onboard",
resource_type="user-engine:family-dataspace",
resource_id=request.family_scope_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=request.application_id,
context={"family_scope_id": request.family_scope_id},
)
owner_session = self._ensure_actor_session(actor, correlation_id)
application, binding = self._ensure_family_dataspace_application(
actor, request, correlation_id
)
catalog = self._ensure_family_dataspace_catalog(
actor, request, correlation_id
)
owner_membership = self._ensure_membership(
actor,
owner_session.user.user_id,
tenant=tenant_context.tenant,
scope_type="family",
scope_id=request.family_scope_id,
kind=FamilyRole.OWNER.value,
correlation_id=correlation_id,
)
owner_defaults = dict(request.owner_profile_defaults)
owner_defaults.setdefault(
"member_display_name",
owner_session.user.display_name
or actor.preferred_username
or owner_session.user.primary_email
or actor.subject,
)
self._apply_family_profile_defaults(
actor,
owner_session.user.user_id,
tenant=tenant_context.tenant,
application_id=request.application_id,
catalog_namespace=request.catalog_namespace,
values=owner_defaults,
correlation_id=correlation_id,
)
invitations = tuple(
self.invite_family_member(
actor,
tenant=tenant_context.tenant,
family_scope_id=request.family_scope_id,
application_id=request.application_id,
catalog_namespace=request.catalog_namespace,
member=member,
correlation_id=correlation_id,
)
for member in request.member_specs
)
self._record_mutation(
actor,
action="family_dataspace.onboard",
subject=request.family_scope_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_dataspace.onboarded",
aggregate_id=request.family_scope_id,
payload={
"tenant": tenant_context.tenant,
"family_scope_id": request.family_scope_id,
"application_id": request.application_id,
"member_invitation_count": len(invitations),
},
application_id=request.application_id,
)
identity_context = self.identity_context(
actor,
user_id=owner_session.user.user_id,
tenant=tenant_context.tenant,
application_id=request.application_id,
include_profile=True,
correlation_id=correlation_id,
)
claims_projection = self.projection(
actor,
owner_session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
tenant=tenant_context.tenant,
application_id=request.application_id,
correlation_id=correlation_id,
)
return FamilyDataspaceOnboarding(
tenant=tenant_context.tenant,
family_scope_id=request.family_scope_id,
family_display_name=request.family_display_name,
owner_session=owner_session,
owner_membership=owner_membership,
application=application,
binding=binding,
catalog=catalog,
invitations=invitations,
identity_context=identity_context,
claims_projection=claims_projection,
)
def invite_family_member(
self,
actor: Actor,
*,
tenant: str,
family_scope_id: str,
application_id: str,
member: FamilyMemberSpec,
catalog_namespace: str = "dataspace",
correlation_id: str | None = None,
) -> FamilyMemberInvitation:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
role = _family_role_value(member.role)
if not member.primary_email:
raise ValidationError("family member primary_email is required")
if member.issuer and member.subject:
existing = self.store.find_identity(member.issuer, member.subject)
if existing is not None:
raise ConflictError("external identity is already linked")
decision = self._authorize(
actor,
action="family_member.invite",
resource_type="user-engine:family-invitation",
resource_id=member.primary_email,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=application_id,
context={
"family_scope_id": family_scope_id,
"role": role,
},
)
user = self.create_user(
actor,
display_name=member.display_name,
primary_email=member.primary_email,
correlation_id=correlation_id,
)
tenant_account = self.set_tenant_account_status(
actor,
user.user_id,
AccountStatus.INVITED,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
membership = self._ensure_membership(
actor,
user.user_id,
tenant=tenant_context.tenant,
scope_type="family",
scope_id=family_scope_id,
kind=role,
correlation_id=correlation_id,
)
profile_defaults = dict(member.profile_defaults)
if member.display_name:
profile_defaults.setdefault("member_display_name", member.display_name)
self._apply_family_profile_defaults(
actor,
user.user_id,
tenant=tenant_context.tenant,
application_id=application_id,
catalog_namespace=catalog_namespace,
values=profile_defaults,
correlation_id=correlation_id,
)
if member.issuer and member.subject:
self.link_identity(
actor,
user.user_id,
issuer=member.issuer,
subject=member.subject,
provider=member.provider,
correlation_id=correlation_id,
)
invitation = FamilyInvitation(
invitation_id=new_id("inv"),
tenant=tenant_context.tenant,
family_scope_id=family_scope_id,
application_id=application_id,
user_id=user.user_id,
primary_email=member.primary_email,
role=role,
invited_by=actor.subject,
correlation_id=correlation_id,
last_sent_correlation_id=correlation_id,
)
self.store.save_family_invitation(invitation)
self._record_mutation(
actor,
action="family_member.invite",
subject=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_member.invited",
aggregate_id=invitation.invitation_id,
payload={
"invitation_id": invitation.invitation_id,
"user_id": user.user_id,
"tenant": tenant_context.tenant,
"family_scope_id": family_scope_id,
"application_id": application_id,
"role": role,
},
application_id=application_id,
)
return FamilyMemberInvitation(
user=user,
tenant_account=tenant_account,
membership=membership,
invitation=invitation,
)
def resend_family_invitation(
self,
actor: Actor,
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be resent")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.resend",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
)
updated = replace(
invitation,
resend_count=invitation.resend_count + 1,
last_sent_correlation_id=correlation_id,
updated_at=utc_now(),
)
self.store.save_family_invitation(updated)
self._record_mutation(
actor,
action="family_invitation.resend",
subject=updated.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.resent",
aggregate_id=updated.invitation_id,
payload={
"invitation_id": updated.invitation_id,
"user_id": updated.user_id,
"resend_count": updated.resend_count,
},
application_id=updated.application_id,
)
return updated
def revoke_family_invitation(
self,
actor: Actor,
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be revoked")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.revoke",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
)
self.set_tenant_account_status(
actor,
invitation.user_id,
AccountStatus.DISABLED,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
updated = replace(
invitation,
status=InvitationStatus.REVOKED,
updated_at=utc_now(),
revoked_at=utc_now(),
)
self.store.save_family_invitation(updated)
self._record_mutation(
actor,
action="family_invitation.revoke",
subject=updated.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.revoked",
aggregate_id=updated.invitation_id,
payload={
"invitation_id": updated.invitation_id,
"user_id": updated.user_id,
"status": updated.status,
},
application_id=updated.application_id,
)
return updated
def accept_family_invitation(
self,
claims: Mapping[str, Any],
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitationAcceptance:
invitation = self._require_family_invitation(invitation_id)
if invitation.status == InvitationStatus.REVOKED:
raise ValidationError("revoked invitations cannot be accepted")
if invitation.status == InvitationStatus.ACCEPTED:
raise ValidationError("invitation is already accepted")
actor = self.identity_adapter.normalize(claims)
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.accept",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
context={"family_scope_id": invitation.family_scope_id},
)
self.link_identity(
actor,
invitation.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
correlation_id=correlation_id,
)
account = self.set_account_status(
actor,
invitation.user_id,
AccountStatus.ACTIVE,
correlation_id=correlation_id,
)
self.set_tenant_account_status(
actor,
invitation.user_id,
AccountStatus.ACTIVE,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
accepted_at = utc_now()
accepted = replace(
invitation,
status=InvitationStatus.ACCEPTED,
updated_at=accepted_at,
accepted_at=accepted_at,
)
self.store.save_family_invitation(accepted)
self._record_mutation(
actor,
action="family_invitation.accept",
subject=accepted.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.accepted",
aggregate_id=accepted.invitation_id,
payload={
"invitation_id": accepted.invitation_id,
"user_id": accepted.user_id,
"tenant": accepted.tenant,
"family_scope_id": accepted.family_scope_id,
"application_id": accepted.application_id,
"status": accepted.status,
},
application_id=accepted.application_id,
)
session = self._session(actor, self._require_user(accepted.user_id), account)
identity_context = self.identity_context(
actor,
user_id=accepted.user_id,
tenant=tenant_context.tenant,
application_id=accepted.application_id,
include_profile=True,
correlation_id=correlation_id,
)
claims_projection = self.projection(
actor,
accepted.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
tenant=tenant_context.tenant,
application_id=accepted.application_id,
correlation_id=correlation_id,
)
return FamilyInvitationAcceptance(
session=session,
invitation=accepted,
identity_context=identity_context,
claims_projection=claims_projection,
)
def tenant_diagnostics(
self,
actor: Actor,
@@ -811,6 +1273,7 @@ class UserEngineService:
"memberships": len(self.store.memberships),
"applications": len(self.store.applications),
"catalogs": len(self.store.catalogs),
"family_invitations": len(self.store.family_invitations),
"profile_values": len(self.store.profile_values),
"audit_records": len(self.store.audit_records),
"pending_outbox_events": len(self.store.outbox_events),
@@ -839,6 +1302,242 @@ class UserEngineService:
context["actor_subject"] = actor.subject
return context
def _ensure_actor_session(
self, actor: Actor, correlation_id: str
) -> UserSession:
identity = self.store.find_identity(*actor.identity_key)
if identity is not None:
user = self._require_user(identity.user_id)
account = self._require_account(user.user_id)
return self._session(actor, user, account)
decision = self._authorize(
actor,
action="me.read",
resource_type="user-engine:me",
resource_id=actor.subject,
tenant=actor.tenant,
correlation_id=correlation_id,
)
user = User(
display_name=actor.preferred_username,
primary_email=_optional_claim(actor, "email"),
)
account = Account(
account_id=new_id("acct"),
user_id=user.user_id,
status=AccountStatus.ACTIVE,
)
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
external_identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
)
self.store.save_user(user)
self.store.save_account(account)
self.store.save_tenant_account(tenant_account)
self.store.save_identity(external_identity)
self._record_mutation(
actor,
action="user.create_from_identity",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={
"user_id": user.user_id,
"account_id": account.account_id,
"identity": {"issuer": actor.issuer, "subject": actor.subject},
},
)
return self._session(actor, user, account)
def _ensure_family_dataspace_application(
self,
actor: Actor,
request: FamilyDataspaceRequest,
correlation_id: str,
) -> tuple[Application, ApplicationBinding]:
binding = _family_dataspace_binding(request)
application = self.store.applications.get(request.application_id)
if application is not None:
if request.application_id not in self.store.bindings:
decision = self._authorize(
actor,
action="application.bind",
resource_type="user-engine:application",
resource_id=request.application_id,
tenant=request.tenant,
correlation_id=correlation_id,
application_id=request.application_id,
)
self.store.save_binding(binding)
self._record_mutation(
actor,
action="application.bind",
subject=request.application_id,
tenant=request.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="application.bound",
aggregate_id=request.application_id,
payload={
"application_id": request.application_id,
"catalog_namespaces": binding.catalog_namespaces,
},
application_id=request.application_id,
)
return application, self.store.bindings[request.application_id]
application = Application(
application_id=request.application_id,
display_name=request.application_display_name,
owner=request.family_scope_id,
allowed_profile_scopes=(
ProfileScope.GLOBAL,
ProfileScope.TENANT,
ProfileScope.APPLICATION,
ProfileScope.MEMBERSHIP,
),
allowed_projection_types=(
ProjectionType.APPLICATION_RUNTIME,
ProjectionType.CLAIMS_ENRICHMENT,
),
)
return (
self.register_application(
actor,
application,
binding=binding,
correlation_id=correlation_id,
),
binding,
)
def _ensure_family_dataspace_catalog(
self,
actor: Actor,
request: FamilyDataspaceRequest,
correlation_id: str,
) -> Catalog:
catalog_id = f"{request.application_id}.profile"
existing = self.store.catalogs.get(catalog_id)
if existing is not None:
return existing
catalog = Catalog(
catalog_id=catalog_id,
namespace=request.catalog_namespace,
version="0.1.0",
owning_application_id=request.application_id,
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(
AttributeDefinition(
key=f"{request.catalog_namespace}.family_display_name",
value_type="string",
scope=ProfileScope.TENANT,
sensitivity=Sensitivity.PERSONAL,
visibility=(
Visibility.USER,
Visibility.ADMIN,
Visibility.APPLICATION,
),
mutability=(Mutability.ADMIN,),
default=request.family_display_name,
),
AttributeDefinition(
key=f"{request.catalog_namespace}.member_display_name",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.PERSONAL,
visibility=(
Visibility.USER,
Visibility.ADMIN,
Visibility.APPLICATION,
),
mutability=(Mutability.USER, Mutability.ADMIN),
),
AttributeDefinition(
key=f"{request.catalog_namespace}.home_view",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.INTERNAL,
visibility=(Visibility.USER, Visibility.APPLICATION),
mutability=(Mutability.USER,),
default="family",
validation={"enum": ["family", "personal"]},
),
),
)
return self.publish_catalog(
actor,
catalog,
correlation_id=correlation_id,
)
def _ensure_membership(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
scope_type: str,
scope_id: str,
kind: str,
correlation_id: str,
) -> Membership:
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
if (
membership.scope_type == scope_type
and membership.scope_id == scope_id
and membership.kind == kind
):
return membership
return self.add_membership(
actor,
user_id,
tenant=tenant,
scope_type=scope_type,
scope_id=scope_id,
kind=kind,
correlation_id=correlation_id,
)
def _apply_family_profile_defaults(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
application_id: str,
catalog_namespace: str,
values: Mapping[str, Any],
correlation_id: str,
) -> None:
for key, value in values.items():
self.set_profile_value(
actor,
user_id,
_family_profile_key(catalog_namespace, key),
value,
scope=ProfileScope.APPLICATION,
scope_id=application_id,
tenant=tenant,
application_id=application_id,
correlation_id=correlation_id,
)
def _require_family_invitation(self, invitation_id: str) -> FamilyInvitation:
invitation = self.store.family_invitation(invitation_id)
if invitation is None:
raise NotFoundError("family invitation not found")
return invitation
def _identity_entity_refs(
self,
actor: Actor,
@@ -1406,11 +2105,39 @@ def _scope_concept(scope_type: str) -> str:
"team": "Team",
"tenant": "Tenant",
"application": "Scope",
"family": "Group",
"group": "Group",
}
return concepts.get(scope_type, "Scope")
def _family_dataspace_binding(
request: FamilyDataspaceRequest,
) -> ApplicationBinding:
event_source = request.event_source or request.application_id
return ApplicationBinding(
application_id=request.application_id,
oidc_client_id=request.oidc_client_id,
protected_system_id=request.protected_system_id,
catalog_namespaces=(request.catalog_namespace,),
event_source=event_source,
deployment_ref=request.deployment_ref,
)
def _family_profile_key(catalog_namespace: str, key: str) -> str:
if key.startswith(f"{catalog_namespace}."):
return key
return f"{catalog_namespace}.{key}"
def _family_role_value(role: FamilyRole | str) -> str:
try:
return FamilyRole(str(role)).value
except ValueError as exc:
raise ValidationError(f"unsupported family role: {role}") from exc
def _visible_in_projection(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:

View File

@@ -26,6 +26,7 @@ SCENARIO_MATRIX = (
"two_applications",
"sensitive_redaction",
"audit_event_replay",
"family_dataspace_onboarding",
)

View File

@@ -0,0 +1,196 @@
import unittest
from user_engine.adapters.local import InMemoryUserEngineStore
from user_engine.domain import (
AccountStatus,
FamilyDataspaceRequest,
FamilyMemberSpec,
FamilyRole,
InvitationStatus,
)
from user_engine.errors import AuthorizationDenied, ValidationError
from user_engine.service import UserEngineService
from user_engine.testing.fixtures import human_actor_claims
from user_engine.testing.scenarios import (
ScenarioAuthorizationHarness,
StrictFixtureIdentityClaimsAdapter,
)
class FamilyDataspaceOnboardingTests(unittest.TestCase):
def test_onboarding_creates_family_scope_dataspace_app_and_invitation(self):
service, store, authz = _service()
session = service.me(_owner_claims(), correlation_id="corr-owner")
onboarding = service.onboard_family_dataspace(
session.actor,
FamilyDataspaceRequest(
tenant="tenant:worsch-family",
family_scope_id="family:worsch",
family_display_name="Worsch Family",
application_id="app.personal-dataspace",
oidc_client_id="personal-dataspace-client",
protected_system_id="dataspace.personal.worsch",
member_specs=(
FamilyMemberSpec(
primary_email="child@example.test",
display_name="Child Member",
role=FamilyRole.CHILD,
),
),
),
correlation_id="corr-family-onboard",
)
self.assertEqual(onboarding.tenant, "tenant:worsch-family")
self.assertEqual(onboarding.binding.oidc_client_id, "personal-dataspace-client")
self.assertEqual(onboarding.catalog.namespace, "dataspace")
self.assertEqual(onboarding.owner_membership.kind, FamilyRole.OWNER.value)
self.assertEqual(onboarding.invitations[0].invitation.status, InvitationStatus.PENDING)
self.assertEqual(onboarding.invitations[0].tenant_account.status, AccountStatus.INVITED)
self.assertEqual(onboarding.identity_context.entity_refs["family:family:worsch"].concept, "Group")
self.assertEqual(
onboarding.claims_projection.values["dataspace.family_display_name"],
"Worsch Family",
)
self.assertIn("family_dataspace.onboarded", _event_types(service))
self.assertIn("family_member.invited", _event_types(service))
self.assertIn("family_dataspace.onboard", [request.action for request in authz.requests])
self.assertEqual(len(store.family_invitations), 1)
def test_member_acceptance_links_sso_identity_and_returns_dataspace_context(self):
service, _, _ = _service()
owner = service.me(_owner_claims(), correlation_id="corr-owner")
onboarding = _onboard_family(service, owner.actor)
invitation = onboarding.invitations[0].invitation
acceptance = service.accept_family_invitation(
_member_claims(subject="child-sso"),
invitation.invitation_id,
correlation_id="corr-accept",
)
self.assertEqual(acceptance.invitation.status, InvitationStatus.ACCEPTED)
self.assertEqual(acceptance.session.user.user_id, invitation.user_id)
self.assertEqual(acceptance.session.account.status, AccountStatus.ACTIVE)
self.assertEqual(
service.store.tenant_account("tenant:worsch-family", invitation.user_id).status,
AccountStatus.ACTIVE,
)
self.assertEqual(
service.store.find_identity(
"https://issuer.example.test",
"child-sso",
).user_id,
invitation.user_id,
)
self.assertEqual(
acceptance.claims_projection.values["dataspace.member_display_name"],
"Child Member",
)
self.assertEqual(acceptance.identity_context.memberships[0].kind, FamilyRole.CHILD.value)
self.assertIn("family_invitation.accepted", _event_types(service))
def test_revoked_invitation_cannot_be_accepted(self):
service, _, _ = _service()
owner = service.me(_owner_claims(), correlation_id="corr-owner")
onboarding = _onboard_family(service, owner.actor)
invitation = onboarding.invitations[0].invitation
resent = service.resend_family_invitation(
owner.actor,
invitation.invitation_id,
correlation_id="corr-resend",
)
revoked = service.revoke_family_invitation(
owner.actor,
invitation.invitation_id,
correlation_id="corr-revoke",
)
self.assertEqual(resent.resend_count, 1)
self.assertEqual(revoked.status, InvitationStatus.REVOKED)
self.assertEqual(
service.store.tenant_account("tenant:worsch-family", invitation.user_id).status,
AccountStatus.DISABLED,
)
with self.assertRaises(ValidationError):
service.accept_family_invitation(
_member_claims(subject="revoked-child"),
invitation.invitation_id,
correlation_id="corr-revoked-accept",
)
def test_cross_tenant_invitation_acceptance_is_denied(self):
service, _, _ = _service()
owner = service.me(_owner_claims(), correlation_id="corr-owner")
onboarding = _onboard_family(service, owner.actor)
with self.assertRaises(AuthorizationDenied):
service.accept_family_invitation(
_member_claims(subject="wrong-tenant", tenant="tenant:other-family"),
onboarding.invitations[0].invitation.invitation_id,
correlation_id="corr-wrong-tenant",
)
def _service():
store = InMemoryUserEngineStore()
service = UserEngineService(
store=store,
identity_adapter=StrictFixtureIdentityClaimsAdapter(),
authorization=ScenarioAuthorizationHarness(),
)
return service, store, service.authorization
def _onboard_family(service: UserEngineService, actor):
return service.onboard_family_dataspace(
actor,
FamilyDataspaceRequest(
tenant="tenant:worsch-family",
family_scope_id="family:worsch",
family_display_name="Worsch Family",
application_id="app.personal-dataspace",
oidc_client_id="personal-dataspace-client",
protected_system_id="dataspace.personal.worsch",
member_specs=(
FamilyMemberSpec(
primary_email="child@example.test",
display_name="Child Member",
role=FamilyRole.CHILD,
),
),
),
correlation_id="corr-family-onboard",
)
def _owner_claims() -> dict[str, object]:
claims = human_actor_claims(
subject="family-owner",
tenant="tenant:worsch-family",
)
claims["roles"] = ["tenant-admin"]
claims["preferred_username"] = "family.owner"
claims["email"] = "owner@example.test"
return claims
def _member_claims(
*,
subject: str,
tenant: str = "tenant:worsch-family",
) -> dict[str, object]:
claims = human_actor_claims(subject=subject, tenant=tenant)
claims["preferred_username"] = subject
claims["email"] = f"{subject}@example.test"
return claims
def _event_types(service: UserEngineService) -> list[str]:
return [event.event_type for event in service.outbox_events()]
if __name__ == "__main__":
unittest.main()

View File

@@ -44,6 +44,7 @@ class IntegratedScenarioTests(unittest.TestCase):
"two_applications",
"sensitive_redaction",
"audit_event_replay",
"family_dataspace_onboarding",
},
)

View File

@@ -0,0 +1,208 @@
---
id: USER-WP-0008
type: workplan
title: "Family Dataspace Onboarding"
domain: netkingdom
repo: user-engine
status: finished
owner: codex
topic_slug: netkingdom
planning_priority: high
planning_order: 8
created: "2026-06-05"
updated: "2026-06-05"
depends_on:
- USER-WP-0007
---
# USER-WP-0008 - Family Dataspace Onboarding
## Goal
Make `user-engine` convenient for a personal-family use case: represent a
family as a NetKingdom identity-domain scope, onboard family members into that
scope, register a personal dataspace as a protected application, and provide
SSO-ready identity context and profile projections without exposing consumers
to IAM, authorization, profile, catalog, audit, or evidence implementation
details.
The intended consumer experience is:
```text
Create a family space, invite members, assign family roles, bind a personal
dataspace application, and let NetKingdom SSO receive the claims/profile
context it needs.
```
## Scope Direction
`user-engine` should orchestrate domain-facing setup and read models. It should
not provision the NetKingdom tenant, issue credentials, own the identity
provider, or become the protected dataspace runtime. The family scope is a
tenant or tenant-backed organization reference owned by NetKingdom
infrastructure; user-engine manages local users, accounts, identity links,
memberships, profile values, application bindings, projections, audit, and
canon-facing identity context for that scope.
## Non-Goals
- Do not issue SSO tokens, sessions, passwords, passkeys, or MFA challenges.
- Do not provision the underlying NetKingdom tenant or organization authority.
- Do not become the personal dataspace storage/runtime implementation.
- Do not implement a production UI as part of the first onboarding slice.
- Do not hard-code family relationship policy into authorization decisions;
export facts and consume NetKingdom authorization outcomes.
- Do not implement the durable Postgres store in this workplan.
## Tasks
```task
id: USER-WP-0008-T1
status: done
priority: high
```
Define the family dataspace vocabulary and mapping. Cover family tenant,
family/member scopes, owner, adult, child, guest, delegated caretaker, personal
dataspace application, SSO claims enrichment, and identity-canon references.
Mark which facts are owned by user-engine and which remain owned by NetKingdom
IAM, tenant, policy, audit, or dataspace systems.
```task
id: USER-WP-0008-T2
status: done
priority: high
```
Design and implement a headless onboarding facade that composes existing
service operations into a convenient use-case API. The facade should accept a
NetKingdom-provided family tenant reference, owner actor, dataspace application
binding, initial member descriptors, role assignments, and profile defaults.
```task
id: USER-WP-0008-T3
status: done
priority: high
```
Add member invitation and acceptance support. Cover pre-created users,
tenant-account lifecycle, invitation status, identity-link acceptance,
resend/revoke behavior, and audit/event records. Keep invitation tokens and
identity proofing delegated to NetKingdom IAM or a dedicated invite adapter.
```task
id: USER-WP-0008-T4
status: done
priority: high
```
Register the personal dataspace application through `register_application`,
bind it to external SSO/protected-system identifiers, and publish a minimal
profile catalog for dataspace-specific claims, preferences, and visibility
rules.
```task
id: USER-WP-0008-T5
status: done
priority: high
```
Implement family membership templates and fact export. Support owner, adult,
child, guest, and delegated roles as scoped memberships while preserving tenant
boundaries and authorization-port decisions for privileged actions.
```task
id: USER-WP-0008-T6
status: done
priority: medium
```
Expose SSO-ready context for the personal dataspace. Use `identity_context`
and claims-enrichment projections to provide subject, principal, account,
family tenant, role/membership, profile, evidence, and explicit gap references
to the NetKingdom SSO adapter.
```task
id: USER-WP-0008-T7
status: done
priority: medium
```
Add lifecycle, audit, evidence, and outbox behavior for onboarding. Every
family/member/application/profile mutation should produce correlated audit and
outbox records, and privileged role grants should be traceable through evidence
or explicit evidence-gap references.
```task
id: USER-WP-0008-T8
status: done
priority: medium
```
Add scenario tests and examples for the complete family dataspace flow. Cover
owner setup, member invitation, accepted SSO identity link, child/guest
membership, dataspace claims enrichment, tenant isolation, and denied
cross-family access.
## Acceptance Criteria
- A consumer can onboard a family dataspace through one headless facade or a
small number of purpose-built commands instead of manually sequencing low
level service calls.
- The family scope is represented as a NetKingdom tenant or tenant-backed
organization reference, not as a user-engine-owned organization authority.
- Family members have distinct users, accounts, tenant accounts, external
identities, and scoped memberships.
- The personal dataspace is registered as an application with a binding to
SSO/protected-system identifiers and a minimal catalog for dataspace profile
values.
- NetKingdom SSO can consume claims-enrichment projection or identity-context
output without knowing user-engine persistence details.
- Owner/adult/child/guest behavior is represented as membership facts and
authorization context, not embedded as final policy decisions in user-engine.
- Audit, outbox, evidence references, and lifecycle gaps exist for onboarding
and role changes.
- Scenario tests prove happy-path onboarding, SSO context generation, and
tenant isolation.
## Expected Outputs
- Family dataspace vocabulary and mapping notes.
- Headless onboarding facade or command contract.
- Invitation and member lifecycle model.
- Personal dataspace application/catalog example.
- Family membership templates and fact export behavior.
- Claims-enrichment and `identity_context` examples for SSO adapters.
- Scenario tests and documentation for the end-to-end use case.
## Implementation Notes
Implemented on 2026-06-05:
- Added family-domain roles, invitation status, member specs, onboarding
request, and invitation records.
- Added local invitation persistence to the isolated store boundary.
- Added `UserEngineService.onboard_family_dataspace(...)` as the headless
onboarding facade.
- Added `invite_family_member`, `resend_family_invitation`,
`revoke_family_invitation`, and `accept_family_invitation`.
- Registered the personal dataspace as an application with an SSO/protected
system binding and a minimal dataspace profile catalog.
- Represented family roles as scoped memberships while preserving
authorization-port decisions.
- Returned `identity_context` and `CLAIMS_ENRICHMENT` projection outputs for
SSO adapters.
- Added audit/outbox events for high-level family onboarding and invitation
lifecycle actions.
- Added `docs/family-dataspace-onboarding.md`, examples, contract updates, and
scenario documentation.
- Added scenario tests for owner onboarding, member acceptance, resend/revoke,
SSO identity linking, claims projection, and cross-family denial.
Verification:
```text
make test
Ran 39 tests in 0.119s
OK
```

View File

@@ -0,0 +1,135 @@
---
id: USER-WP-0009
type: workplan
title: "Postgres Durable Store Consumer Requirements"
domain: netkingdom
repo: user-engine
status: proposed
owner: codex
topic_slug: netkingdom
planning_priority: high
planning_order: 9
created: "2026-06-05"
updated: "2026-06-05"
depends_on:
- USER-WP-0007
---
# USER-WP-0009 - Postgres Durable Store Consumer Requirements
## Goal
Define, from the `user-engine` consumer perspective, what a durable
Postgres-backed store must provide before user-engine depends on it in
NetKingdom. This workplan is requirements-only: it should not implement the
Postgres adapter, provision databases, create tenant infrastructure, or choose
the final provider repository design.
## Scope Direction
`user-engine` should be able to consume a NetKingdom-provided, tenant-aware,
security-integrated Postgres capability through an adapter boundary. The
future Postgres/provider repository should own provisioning, credentials,
network policy, tenant isolation primitives, backup/restore, platform
observability, and operational security. `user-engine` should own its domain
schema, migrations for its own tables, store semantics, and conformance tests.
## Non-Goals
- Do not implement a Postgres store adapter in this workplan.
- Do not add database dependencies to the package in this workplan.
- Do not provision Postgres, schemas, roles, credentials, certificates, or
network access from this repository.
- Do not decide the final independent infrastructure repository layout.
- Do not move audit-platform, IAM, secrets, or authorization ownership into
user-engine.
- Do not change the public service surface unless the requirements reveal a
missing durable-store contract.
## Tasks
```task
id: USER-WP-0009-T1
status: todo
priority: high
```
Inventory the current in-memory store behavior and document the durable
persistence semantics user-engine consumers already rely on: users, accounts,
tenant accounts, external identities, applications, bindings, catalogs,
profile values, memberships, audit records, outbox events, readiness, and
schema version reporting.
```task
id: USER-WP-0009-T2
status: todo
priority: high
```
Create a consumer-facing requirements document for a Postgres durable store.
Cover connection handoff, tenant context, schema ownership, migrations,
transactions, isolation, constraints, query behavior, audit/outbox durability,
security, observability, backup/restore expectations, and acceptance tests.
```task
id: USER-WP-0009-T3
status: todo
priority: high
```
Define the boundary between user-engine and the future NetKingdom Postgres
provider repository. Specify which responsibilities belong to the provider,
which belong to the user-engine adapter, and which must remain external IAM,
secrets, authorization, or audit-platform concerns.
```task
id: USER-WP-0009-T4
status: todo
priority: medium
```
Identify required changes, if any, to the existing store protocol or migration
contract so durable implementations can satisfy the same service behavior as
the isolated MVP without leaking Postgres concepts into domain code.
```task
id: USER-WP-0009-T5
status: todo
priority: medium
```
Define conformance scenarios and failure-mode tests the future Postgres store
must pass. Include transaction rollback, duplicate identity prevention,
tenant-boundary enforcement, outbox exactly-once handoff semantics, migration
readiness, and redacted diagnostics.
```task
id: USER-WP-0009-T6
status: todo
priority: medium
```
Record open questions for the independent provider repository, including
tenant isolation model, credential lease model, schema-per-service or
database-per-tenant strategy, migration runner ownership, backup unit, PITR
expectations, encryption, and operational runbooks.
## Acceptance Criteria
- `docs/postgres-durable-store-consumer-requirements.md` exists and is clear
enough for an independent NetKingdom Postgres provider repo to implement
against.
- The document describes user-engine as a consumer of a secure Postgres
capability, not as the owner of Postgres provisioning or platform security.
- Requirements cover domain persistence, transactions, migrations, tenant
isolation, security, audit/outbox durability, operability, and acceptance
tests.
- The provider-repo boundary is explicit and avoids duplicating IAM, secrets,
authorization, audit-platform, or infrastructure ownership.
- No Postgres implementation code is added as part of this workplan.
## Expected Outputs
- `docs/postgres-durable-store-consumer-requirements.md`
- Store-boundary notes suitable for a future provider repo.
- Follow-up implementation workplan inputs for a Postgres adapter.