diff --git a/README.md b/README.md index 03323ec..ef4ecbd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/contracts.md b/docs/contracts.md index cdbf654..fd91377 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -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 diff --git a/docs/examples.md b/docs/examples.md index ed91c58..b44ccd7 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -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. diff --git a/docs/family-dataspace-onboarding.md b/docs/family-dataspace-onboarding.md new file mode 100644 index 0000000..fbcffa4 --- /dev/null +++ b/docs/family-dataspace-onboarding.md @@ -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. diff --git a/docs/scenarios.md b/docs/scenarios.md index 7656a60..40e8840 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -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 diff --git a/src/user_engine/adapters/local.py b/src/user_engine/adapters/local.py index b4882c0..9f6b937 100644 --- a/src/user_engine/adapters/local.py +++ b/src/user_engine/adapters/local.py @@ -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) diff --git a/src/user_engine/domain/__init__.py b/src/user_engine/domain/__init__.py index b9e10e1..abdbe4c 100644 --- a/src/user_engine/domain/__init__.py +++ b/src/user_engine/domain/__init__.py @@ -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", diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index c9b8549..021477a 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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 diff --git a/src/user_engine/service.py b/src/user_engine/service.py index dc63b30..35abe11 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -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: diff --git a/src/user_engine/testing/scenarios.py b/src/user_engine/testing/scenarios.py index 0a32c47..13f3f1e 100644 --- a/src/user_engine/testing/scenarios.py +++ b/src/user_engine/testing/scenarios.py @@ -26,6 +26,7 @@ SCENARIO_MATRIX = ( "two_applications", "sensitive_redaction", "audit_event_replay", + "family_dataspace_onboarding", ) diff --git a/tests/test_family_dataspace_onboarding.py b/tests/test_family_dataspace_onboarding.py new file mode 100644 index 0000000..17d869b --- /dev/null +++ b/tests/test_family_dataspace_onboarding.py @@ -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() diff --git a/tests/test_integrated_scenarios.py b/tests/test_integrated_scenarios.py index 8c38f44..e655a72 100644 --- a/tests/test_integrated_scenarios.py +++ b/tests/test_integrated_scenarios.py @@ -44,6 +44,7 @@ class IntegratedScenarioTests(unittest.TestCase): "two_applications", "sensitive_redaction", "audit_event_replay", + "family_dataspace_onboarding", }, ) diff --git a/workplans/USER-WP-0008-family-dataspace-onboarding.md b/workplans/USER-WP-0008-family-dataspace-onboarding.md new file mode 100644 index 0000000..c41ab16 --- /dev/null +++ b/workplans/USER-WP-0008-family-dataspace-onboarding.md @@ -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 +```