generated from coulomb/repo-seed
Compare commits
2 Commits
475016b883
...
531c2193a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 531c2193a4 | |||
| af6d82038e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
120
docs/family-dataspace-onboarding.md
Normal file
120
docs/family-dataspace-onboarding.md
Normal 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.
|
||||
262
docs/postgres-durable-store-consumer-requirements.md
Normal file
262
docs/postgres-durable-store-consumer-requirements.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -26,6 +26,7 @@ SCENARIO_MATRIX = (
|
||||
"two_applications",
|
||||
"sensitive_redaction",
|
||||
"audit_event_replay",
|
||||
"family_dataspace_onboarding",
|
||||
)
|
||||
|
||||
|
||||
|
||||
196
tests/test_family_dataspace_onboarding.py
Normal file
196
tests/test_family_dataspace_onboarding.py
Normal 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()
|
||||
@@ -44,6 +44,7 @@ class IntegratedScenarioTests(unittest.TestCase):
|
||||
"two_applications",
|
||||
"sensitive_redaction",
|
||||
"audit_event_replay",
|
||||
"family_dataspace_onboarding",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
208
workplans/USER-WP-0008-family-dataspace-onboarding.md
Normal file
208
workplans/USER-WP-0008-family-dataspace-onboarding.md
Normal 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
|
||||
```
|
||||
135
workplans/USER-WP-0009-postgres-durable-store-requirements.md
Normal file
135
workplans/USER-WP-0009-postgres-durable-store-requirements.md
Normal 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.
|
||||
Reference in New Issue
Block a user