Implement family dataspace onboarding

This commit is contained in:
2026-06-05 18:51:47 +02:00
parent af6d82038e
commit 531c2193a4
13 changed files with 1400 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

@@ -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
```