generated from coulomb/repo-seed
Implement family dataspace onboarding
This commit is contained in:
@@ -8,7 +8,8 @@ make test
|
||||
|
||||
See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
|
||||
`docs/canon-mapping.md`, `docs/canon-interface-card.yaml`,
|
||||
`docs/evidence-gap-examples.md`, `docs/examples.md`, `docs/scenarios.md`,
|
||||
`docs/evidence-gap-examples.md`, `docs/family-dataspace-onboarding.md`,
|
||||
`docs/examples.md`, `docs/scenarios.md`,
|
||||
`docs/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
|
||||
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
||||
for implementation boundaries, contracts, canon mappings, examples, and release
|
||||
|
||||
@@ -11,6 +11,9 @@ HTTP or RPC adapters should preserve these operation names:
|
||||
`tenant_diagnostics`
|
||||
- `register_application`, `publish_catalog`
|
||||
- `set_profile_value`, `effective_profile`, `projection`, `identity_context`
|
||||
- `onboard_family_dataspace`, `invite_family_member`,
|
||||
`resend_family_invitation`, `revoke_family_invitation`,
|
||||
`accept_family_invitation`
|
||||
- `audit_records`, `outbox_events`
|
||||
|
||||
## Identity Context Contract
|
||||
@@ -36,6 +39,24 @@ policy, control, access-review, exception, and lifecycle task references belong
|
||||
to adapter contracts and remain non-owned unless a later workplan assigns
|
||||
source-of-truth responsibility to user-engine.
|
||||
|
||||
## Family Dataspace Onboarding Contract
|
||||
|
||||
`onboard_family_dataspace` is a convenience facade for personal-family
|
||||
identity-domain setup. It composes existing user, account, tenant-account,
|
||||
membership, application, catalog, profile, audit, outbox, projection, and
|
||||
identity-context operations.
|
||||
|
||||
The facade represents a family as a NetKingdom tenant plus a `family` scope. It
|
||||
does not provision the tenant, issue SSO tokens, own credentials, or implement
|
||||
the protected dataspace runtime. Family roles are scoped membership facts such
|
||||
as `owner`, `adult`, `child`, `guest`, and `delegated-caretaker`; authorization
|
||||
systems decide how those facts affect access.
|
||||
|
||||
Invitation acceptance requires already-verified claims. user-engine stores
|
||||
local invitation lifecycle, links the verified external identity, activates
|
||||
account state, and returns both `identity_context` and a
|
||||
`CLAIMS_ENRICHMENT` projection for SSO adapters.
|
||||
|
||||
## Error Taxonomy
|
||||
|
||||
- `ValidationError`: caller supplied an invalid shape, state transition, or
|
||||
|
||||
@@ -66,3 +66,40 @@ operation. Outbox consumers should treat `event_id` as the delivery id and
|
||||
for event in service.outbox_events():
|
||||
print(event.event_type, event.aggregate_id, event.correlation_id)
|
||||
```
|
||||
|
||||
## Onboard A Family Dataspace
|
||||
|
||||
```python
|
||||
from user_engine.domain import FamilyDataspaceRequest, FamilyMemberSpec, FamilyRole
|
||||
|
||||
owner = service.me(owner_claims, correlation_id="corr-owner")
|
||||
onboarding = service.onboard_family_dataspace(
|
||||
owner.actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
accepted = service.accept_family_invitation(
|
||||
child_claims,
|
||||
onboarding.invitations[0].invitation.invitation_id,
|
||||
correlation_id="corr-child-accept",
|
||||
)
|
||||
```
|
||||
|
||||
`accepted.identity_context` is the canon-facing context for the SSO adapter.
|
||||
`accepted.claims_projection` is the application-visible profile projection for
|
||||
the personal dataspace.
|
||||
|
||||
120
docs/family-dataspace-onboarding.md
Normal file
120
docs/family-dataspace-onboarding.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Family Dataspace Onboarding
|
||||
|
||||
Status: implemented MVP facade
|
||||
Date: 2026-06-05
|
||||
Related workplan: USER-WP-0008
|
||||
|
||||
## Purpose
|
||||
|
||||
Family dataspace onboarding is the first concrete convenience use case for
|
||||
`user-engine` as a NetKingdom identity-domain integration layer. It lets a
|
||||
consumer represent a family as a tenant-scoped identity context, invite family
|
||||
members, bind a personal dataspace application, and produce SSO-ready identity
|
||||
context without making callers sequence low-level user, profile, membership,
|
||||
application, audit, and projection operations themselves.
|
||||
|
||||
## Model
|
||||
|
||||
| Use-case concept | user-engine representation | Source of truth |
|
||||
| --- | --- | --- |
|
||||
| Family | NetKingdom tenant plus `family` membership scope | NetKingdom tenant/organization infrastructure |
|
||||
| Family owner | `User`, `Account`, active `TenantAccount`, `family:owner` membership | user-engine for local facts |
|
||||
| Family member | invited `User`, `Account`, `TenantAccount`, `FamilyInvitation` | user-engine for local lifecycle |
|
||||
| SSO identity | linked `ExternalIdentity` from verified `(issuer, subject)` | NetKingdom IAM for authentication |
|
||||
| Family role | scoped `Membership.kind` such as `owner`, `adult`, `child`, `guest` | user-engine fact, authorization consumes it |
|
||||
| Personal dataspace | registered `Application` with `ApplicationBinding` | user-engine binding, external runtime owns app |
|
||||
| SSO claims input | `identity_context` plus `CLAIMS_ENRICHMENT` projection | user-engine read model, NetKingdom IAM consumes it |
|
||||
|
||||
## Public Flow
|
||||
|
||||
1. Resolve the owner through `me(...)` or pass an already-normalized actor.
|
||||
2. Call `onboard_family_dataspace(...)` with a `FamilyDataspaceRequest`.
|
||||
3. user-engine ensures the owner exists, registers the dataspace application,
|
||||
publishes a minimal dataspace catalog, assigns owner membership, creates
|
||||
pending member invitations, and returns identity context plus a
|
||||
claims-enrichment projection for SSO.
|
||||
4. Invited members accept through `accept_family_invitation(...)` using
|
||||
verified NetKingdom claims. user-engine links the external identity,
|
||||
activates account state, records audit/outbox events, and returns SSO-ready
|
||||
context for the member.
|
||||
5. Pending invitations can be resent or revoked through
|
||||
`resend_family_invitation(...)` and `revoke_family_invitation(...)`.
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from user_engine.domain import FamilyDataspaceRequest, FamilyMemberSpec, FamilyRole
|
||||
|
||||
owner = service.me(owner_claims, correlation_id="corr-owner")
|
||||
onboarding = service.onboard_family_dataspace(
|
||||
owner.actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
member = service.accept_family_invitation(
|
||||
member_claims,
|
||||
onboarding.invitations[0].invitation.invitation_id,
|
||||
correlation_id="corr-member-accept",
|
||||
)
|
||||
```
|
||||
|
||||
`onboarding.identity_context` and `member.identity_context` contain the
|
||||
canon-facing actor, user, account, authenticated subject, authorization
|
||||
principal, tenant, family group, membership, grant-like, and evidence
|
||||
references. `claims_projection` contains application-visible profile values
|
||||
such as the family display name and member display name.
|
||||
|
||||
## Boundary
|
||||
|
||||
user-engine does not issue tokens, manage credentials, run MFA, provision the
|
||||
family tenant, or implement the personal dataspace runtime. Those remain
|
||||
NetKingdom IAM, tenant, security, and application responsibilities.
|
||||
|
||||
Family roles are exported as scoped membership facts. The authorization port
|
||||
decides whether those facts allow an action.
|
||||
|
||||
Invitation tokens and proofing are deliberately adapter-owned. The MVP
|
||||
invitation record tracks local lifecycle state and assumes NetKingdom IAM has
|
||||
already verified claims before acceptance.
|
||||
|
||||
## Audit And Events
|
||||
|
||||
The facade emits high-level events in addition to the lower-level events from
|
||||
the operations it composes:
|
||||
|
||||
- `family_dataspace.onboarded`
|
||||
- `family_member.invited`
|
||||
- `family_invitation.resent`
|
||||
- `family_invitation.revoked`
|
||||
- `family_invitation.accepted`
|
||||
|
||||
Lower-level events such as `user.created`, `tenant_account.status_changed`,
|
||||
`membership.added`, `identity.linked`, `application.registered`,
|
||||
`catalog.published`, and `profile.value_set` remain visible for replay and
|
||||
traceability.
|
||||
|
||||
## Current MVP Limits
|
||||
|
||||
- Invitations are stored in the current store boundary and need durable-store
|
||||
backing before production use.
|
||||
- Invitation delivery, one-time token material, and proofing are external
|
||||
adapter responsibilities.
|
||||
- Membership revocation and historical role lifecycle are not yet fully
|
||||
modeled beyond invitation revoke and account status changes.
|
||||
- The default dataspace catalog is intentionally minimal and should evolve with
|
||||
real dataspace claims requirements.
|
||||
@@ -15,6 +15,7 @@ projection, audit, and event behavior testable without a UI.
|
||||
| sensitive_redaction | Sensitive values are redacted in runtime and claims-enrichment projections. |
|
||||
| audit_event_replay | Mutations carry audit records, outbox events, and correlation ids. |
|
||||
| identity_canon_context | Actor, user, account, authenticated subject, authorization principal, tenant, membership, grant-like facts, and evidence references stay distinguishable. |
|
||||
| family_dataspace_onboarding | A family tenant can register a personal dataspace, invite members, accept SSO identities, project claims context, and deny cross-family access. |
|
||||
|
||||
## Fixture Actors
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from user_engine.domain import (
|
||||
AuthorizationRequest,
|
||||
Catalog,
|
||||
ExternalIdentity,
|
||||
FamilyInvitation,
|
||||
Membership,
|
||||
OutboxEvent,
|
||||
ProfileScope,
|
||||
@@ -44,6 +45,7 @@ class InMemoryUserEngineStore:
|
||||
applications: dict[str, Application] = field(default_factory=dict)
|
||||
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
||||
catalogs: dict[str, Catalog] = field(default_factory=dict)
|
||||
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
|
||||
profile_values: dict[
|
||||
tuple[str, str, ProfileScope, str | None], ProfileValue
|
||||
] = field(default_factory=dict)
|
||||
@@ -82,6 +84,19 @@ class InMemoryUserEngineStore:
|
||||
def save_catalog(self, catalog: Catalog) -> None:
|
||||
self.catalogs[catalog.catalog_id] = catalog
|
||||
|
||||
def save_family_invitation(self, invitation: FamilyInvitation) -> None:
|
||||
self.family_invitations[invitation.invitation_id] = invitation
|
||||
|
||||
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
|
||||
return self.family_invitations.get(invitation_id)
|
||||
|
||||
def family_invitations_for_user(self, user_id: str) -> tuple[FamilyInvitation, ...]:
|
||||
return tuple(
|
||||
invitation
|
||||
for invitation in self.family_invitations.values()
|
||||
if invitation.user_id == user_id
|
||||
)
|
||||
|
||||
def save_profile_value(self, value: ProfileValue) -> None:
|
||||
self.profile_values[
|
||||
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
||||
|
||||
@@ -16,6 +16,11 @@ from user_engine.domain.models import (
|
||||
Catalog,
|
||||
CatalogLifecycle,
|
||||
ExternalIdentity,
|
||||
FamilyDataspaceRequest,
|
||||
FamilyInvitation,
|
||||
FamilyMemberSpec,
|
||||
FamilyRole,
|
||||
InvitationStatus,
|
||||
ManagementMode,
|
||||
Membership,
|
||||
Mutability,
|
||||
@@ -48,6 +53,11 @@ __all__ = [
|
||||
"Catalog",
|
||||
"CatalogLifecycle",
|
||||
"ExternalIdentity",
|
||||
"FamilyDataspaceRequest",
|
||||
"FamilyInvitation",
|
||||
"FamilyMemberSpec",
|
||||
"FamilyRole",
|
||||
"InvitationStatus",
|
||||
"ManagementMode",
|
||||
"Membership",
|
||||
"Mutability",
|
||||
|
||||
@@ -45,6 +45,20 @@ class ManagementMode(StrEnum):
|
||||
SERVICE_MANAGED = "service_managed"
|
||||
|
||||
|
||||
class FamilyRole(StrEnum):
|
||||
OWNER = "owner"
|
||||
ADULT = "adult"
|
||||
CHILD = "child"
|
||||
GUEST = "guest"
|
||||
DELEGATED_CARETAKER = "delegated-caretaker"
|
||||
|
||||
|
||||
class InvitationStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
ACCEPTED = "accepted"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class ProfileScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
TENANT = "tenant"
|
||||
@@ -252,6 +266,53 @@ class Membership:
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyMemberSpec:
|
||||
primary_email: str
|
||||
role: FamilyRole | str = FamilyRole.ADULT
|
||||
display_name: str | None = None
|
||||
issuer: str | None = None
|
||||
subject: str | None = None
|
||||
provider: str | None = None
|
||||
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyDataspaceRequest:
|
||||
tenant: str
|
||||
family_scope_id: str
|
||||
family_display_name: str
|
||||
application_id: str = "app.personal-dataspace"
|
||||
application_display_name: str = "Personal Dataspace"
|
||||
oidc_client_id: str | None = None
|
||||
protected_system_id: str | None = None
|
||||
catalog_namespace: str = "dataspace"
|
||||
event_source: str | None = None
|
||||
deployment_ref: str | None = None
|
||||
member_specs: tuple[FamilyMemberSpec, ...] = ()
|
||||
owner_profile_defaults: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyInvitation:
|
||||
invitation_id: str
|
||||
tenant: str
|
||||
family_scope_id: str
|
||||
application_id: str
|
||||
user_id: str
|
||||
primary_email: str
|
||||
role: str
|
||||
status: InvitationStatus = InvitationStatus.PENDING
|
||||
invited_by: str | None = None
|
||||
correlation_id: str | None = None
|
||||
resend_count: int = 0
|
||||
last_sent_correlation_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
accepted_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorizationRequest:
|
||||
actor: Actor
|
||||
|
||||
@@ -20,6 +20,11 @@ from user_engine.domain import (
|
||||
Catalog,
|
||||
CatalogLifecycle,
|
||||
ExternalIdentity,
|
||||
FamilyDataspaceRequest,
|
||||
FamilyInvitation,
|
||||
FamilyMemberSpec,
|
||||
FamilyRole,
|
||||
InvitationStatus,
|
||||
Membership,
|
||||
Mutability,
|
||||
OutboxEvent,
|
||||
@@ -31,6 +36,7 @@ from user_engine.domain import (
|
||||
User,
|
||||
Visibility,
|
||||
new_id,
|
||||
utc_now,
|
||||
)
|
||||
from user_engine.errors import (
|
||||
AuthorizationDenied,
|
||||
@@ -108,6 +114,37 @@ class IdentityContext:
|
||||
gaps: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyMemberInvitation:
|
||||
user: User
|
||||
tenant_account: TenantAccount
|
||||
membership: Membership
|
||||
invitation: FamilyInvitation
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyDataspaceOnboarding:
|
||||
tenant: str
|
||||
family_scope_id: str
|
||||
family_display_name: str
|
||||
owner_session: UserSession
|
||||
owner_membership: Membership
|
||||
application: Application
|
||||
binding: ApplicationBinding
|
||||
catalog: Catalog
|
||||
invitations: tuple[FamilyMemberInvitation, ...]
|
||||
identity_context: IdentityContext
|
||||
claims_projection: Projection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyInvitationAcceptance:
|
||||
session: UserSession
|
||||
invitation: FamilyInvitation
|
||||
identity_context: IdentityContext
|
||||
claims_projection: Projection
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantDiagnostics:
|
||||
tenant: str
|
||||
@@ -745,6 +782,431 @@ class UserEngineService:
|
||||
gaps=gaps,
|
||||
)
|
||||
|
||||
def onboard_family_dataspace(
|
||||
self,
|
||||
actor: Actor,
|
||||
request: FamilyDataspaceRequest,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> FamilyDataspaceOnboarding:
|
||||
tenant_context = self.resolve_tenant_context(actor, request.tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="family_dataspace.onboard",
|
||||
resource_type="user-engine:family-dataspace",
|
||||
resource_id=request.family_scope_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
application_id=request.application_id,
|
||||
context={"family_scope_id": request.family_scope_id},
|
||||
)
|
||||
owner_session = self._ensure_actor_session(actor, correlation_id)
|
||||
application, binding = self._ensure_family_dataspace_application(
|
||||
actor, request, correlation_id
|
||||
)
|
||||
catalog = self._ensure_family_dataspace_catalog(
|
||||
actor, request, correlation_id
|
||||
)
|
||||
owner_membership = self._ensure_membership(
|
||||
actor,
|
||||
owner_session.user.user_id,
|
||||
tenant=tenant_context.tenant,
|
||||
scope_type="family",
|
||||
scope_id=request.family_scope_id,
|
||||
kind=FamilyRole.OWNER.value,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
owner_defaults = dict(request.owner_profile_defaults)
|
||||
owner_defaults.setdefault(
|
||||
"member_display_name",
|
||||
owner_session.user.display_name
|
||||
or actor.preferred_username
|
||||
or owner_session.user.primary_email
|
||||
or actor.subject,
|
||||
)
|
||||
self._apply_family_profile_defaults(
|
||||
actor,
|
||||
owner_session.user.user_id,
|
||||
tenant=tenant_context.tenant,
|
||||
application_id=request.application_id,
|
||||
catalog_namespace=request.catalog_namespace,
|
||||
values=owner_defaults,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
invitations = tuple(
|
||||
self.invite_family_member(
|
||||
actor,
|
||||
tenant=tenant_context.tenant,
|
||||
family_scope_id=request.family_scope_id,
|
||||
application_id=request.application_id,
|
||||
catalog_namespace=request.catalog_namespace,
|
||||
member=member,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
for member in request.member_specs
|
||||
)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="family_dataspace.onboard",
|
||||
subject=request.family_scope_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="family_dataspace.onboarded",
|
||||
aggregate_id=request.family_scope_id,
|
||||
payload={
|
||||
"tenant": tenant_context.tenant,
|
||||
"family_scope_id": request.family_scope_id,
|
||||
"application_id": request.application_id,
|
||||
"member_invitation_count": len(invitations),
|
||||
},
|
||||
application_id=request.application_id,
|
||||
)
|
||||
identity_context = self.identity_context(
|
||||
actor,
|
||||
user_id=owner_session.user.user_id,
|
||||
tenant=tenant_context.tenant,
|
||||
application_id=request.application_id,
|
||||
include_profile=True,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
claims_projection = self.projection(
|
||||
actor,
|
||||
owner_session.user.user_id,
|
||||
ProjectionType.CLAIMS_ENRICHMENT,
|
||||
tenant=tenant_context.tenant,
|
||||
application_id=request.application_id,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return FamilyDataspaceOnboarding(
|
||||
tenant=tenant_context.tenant,
|
||||
family_scope_id=request.family_scope_id,
|
||||
family_display_name=request.family_display_name,
|
||||
owner_session=owner_session,
|
||||
owner_membership=owner_membership,
|
||||
application=application,
|
||||
binding=binding,
|
||||
catalog=catalog,
|
||||
invitations=invitations,
|
||||
identity_context=identity_context,
|
||||
claims_projection=claims_projection,
|
||||
)
|
||||
|
||||
def invite_family_member(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
tenant: str,
|
||||
family_scope_id: str,
|
||||
application_id: str,
|
||||
member: FamilyMemberSpec,
|
||||
catalog_namespace: str = "dataspace",
|
||||
correlation_id: str | None = None,
|
||||
) -> FamilyMemberInvitation:
|
||||
tenant_context = self.resolve_tenant_context(actor, tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
role = _family_role_value(member.role)
|
||||
if not member.primary_email:
|
||||
raise ValidationError("family member primary_email is required")
|
||||
if member.issuer and member.subject:
|
||||
existing = self.store.find_identity(member.issuer, member.subject)
|
||||
if existing is not None:
|
||||
raise ConflictError("external identity is already linked")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="family_member.invite",
|
||||
resource_type="user-engine:family-invitation",
|
||||
resource_id=member.primary_email,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
application_id=application_id,
|
||||
context={
|
||||
"family_scope_id": family_scope_id,
|
||||
"role": role,
|
||||
},
|
||||
)
|
||||
user = self.create_user(
|
||||
actor,
|
||||
display_name=member.display_name,
|
||||
primary_email=member.primary_email,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
tenant_account = self.set_tenant_account_status(
|
||||
actor,
|
||||
user.user_id,
|
||||
AccountStatus.INVITED,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
membership = self._ensure_membership(
|
||||
actor,
|
||||
user.user_id,
|
||||
tenant=tenant_context.tenant,
|
||||
scope_type="family",
|
||||
scope_id=family_scope_id,
|
||||
kind=role,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
profile_defaults = dict(member.profile_defaults)
|
||||
if member.display_name:
|
||||
profile_defaults.setdefault("member_display_name", member.display_name)
|
||||
self._apply_family_profile_defaults(
|
||||
actor,
|
||||
user.user_id,
|
||||
tenant=tenant_context.tenant,
|
||||
application_id=application_id,
|
||||
catalog_namespace=catalog_namespace,
|
||||
values=profile_defaults,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if member.issuer and member.subject:
|
||||
self.link_identity(
|
||||
actor,
|
||||
user.user_id,
|
||||
issuer=member.issuer,
|
||||
subject=member.subject,
|
||||
provider=member.provider,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
invitation = FamilyInvitation(
|
||||
invitation_id=new_id("inv"),
|
||||
tenant=tenant_context.tenant,
|
||||
family_scope_id=family_scope_id,
|
||||
application_id=application_id,
|
||||
user_id=user.user_id,
|
||||
primary_email=member.primary_email,
|
||||
role=role,
|
||||
invited_by=actor.subject,
|
||||
correlation_id=correlation_id,
|
||||
last_sent_correlation_id=correlation_id,
|
||||
)
|
||||
self.store.save_family_invitation(invitation)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="family_member.invite",
|
||||
subject=invitation.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="family_member.invited",
|
||||
aggregate_id=invitation.invitation_id,
|
||||
payload={
|
||||
"invitation_id": invitation.invitation_id,
|
||||
"user_id": user.user_id,
|
||||
"tenant": tenant_context.tenant,
|
||||
"family_scope_id": family_scope_id,
|
||||
"application_id": application_id,
|
||||
"role": role,
|
||||
},
|
||||
application_id=application_id,
|
||||
)
|
||||
return FamilyMemberInvitation(
|
||||
user=user,
|
||||
tenant_account=tenant_account,
|
||||
membership=membership,
|
||||
invitation=invitation,
|
||||
)
|
||||
|
||||
def resend_family_invitation(
|
||||
self,
|
||||
actor: Actor,
|
||||
invitation_id: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> FamilyInvitation:
|
||||
invitation = self._require_family_invitation(invitation_id)
|
||||
if invitation.status != InvitationStatus.PENDING:
|
||||
raise ValidationError("only pending invitations can be resent")
|
||||
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="family_invitation.resend",
|
||||
resource_type="user-engine:family-invitation",
|
||||
resource_id=invitation.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
application_id=invitation.application_id,
|
||||
target_user_id=invitation.user_id,
|
||||
)
|
||||
updated = replace(
|
||||
invitation,
|
||||
resend_count=invitation.resend_count + 1,
|
||||
last_sent_correlation_id=correlation_id,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
self.store.save_family_invitation(updated)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="family_invitation.resend",
|
||||
subject=updated.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="family_invitation.resent",
|
||||
aggregate_id=updated.invitation_id,
|
||||
payload={
|
||||
"invitation_id": updated.invitation_id,
|
||||
"user_id": updated.user_id,
|
||||
"resend_count": updated.resend_count,
|
||||
},
|
||||
application_id=updated.application_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
def revoke_family_invitation(
|
||||
self,
|
||||
actor: Actor,
|
||||
invitation_id: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> FamilyInvitation:
|
||||
invitation = self._require_family_invitation(invitation_id)
|
||||
if invitation.status != InvitationStatus.PENDING:
|
||||
raise ValidationError("only pending invitations can be revoked")
|
||||
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="family_invitation.revoke",
|
||||
resource_type="user-engine:family-invitation",
|
||||
resource_id=invitation.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
application_id=invitation.application_id,
|
||||
target_user_id=invitation.user_id,
|
||||
)
|
||||
self.set_tenant_account_status(
|
||||
actor,
|
||||
invitation.user_id,
|
||||
AccountStatus.DISABLED,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
updated = replace(
|
||||
invitation,
|
||||
status=InvitationStatus.REVOKED,
|
||||
updated_at=utc_now(),
|
||||
revoked_at=utc_now(),
|
||||
)
|
||||
self.store.save_family_invitation(updated)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="family_invitation.revoke",
|
||||
subject=updated.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="family_invitation.revoked",
|
||||
aggregate_id=updated.invitation_id,
|
||||
payload={
|
||||
"invitation_id": updated.invitation_id,
|
||||
"user_id": updated.user_id,
|
||||
"status": updated.status,
|
||||
},
|
||||
application_id=updated.application_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
def accept_family_invitation(
|
||||
self,
|
||||
claims: Mapping[str, Any],
|
||||
invitation_id: str,
|
||||
*,
|
||||
correlation_id: str | None = None,
|
||||
) -> FamilyInvitationAcceptance:
|
||||
invitation = self._require_family_invitation(invitation_id)
|
||||
if invitation.status == InvitationStatus.REVOKED:
|
||||
raise ValidationError("revoked invitations cannot be accepted")
|
||||
if invitation.status == InvitationStatus.ACCEPTED:
|
||||
raise ValidationError("invitation is already accepted")
|
||||
actor = self.identity_adapter.normalize(claims)
|
||||
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
|
||||
correlation_id = correlation_id or new_id("corr")
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="family_invitation.accept",
|
||||
resource_type="user-engine:family-invitation",
|
||||
resource_id=invitation.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
application_id=invitation.application_id,
|
||||
target_user_id=invitation.user_id,
|
||||
context={"family_scope_id": invitation.family_scope_id},
|
||||
)
|
||||
self.link_identity(
|
||||
actor,
|
||||
invitation.user_id,
|
||||
issuer=actor.issuer,
|
||||
subject=actor.subject,
|
||||
provider=actor.authorized_party,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
account = self.set_account_status(
|
||||
actor,
|
||||
invitation.user_id,
|
||||
AccountStatus.ACTIVE,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.set_tenant_account_status(
|
||||
actor,
|
||||
invitation.user_id,
|
||||
AccountStatus.ACTIVE,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
accepted_at = utc_now()
|
||||
accepted = replace(
|
||||
invitation,
|
||||
status=InvitationStatus.ACCEPTED,
|
||||
updated_at=accepted_at,
|
||||
accepted_at=accepted_at,
|
||||
)
|
||||
self.store.save_family_invitation(accepted)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="family_invitation.accept",
|
||||
subject=accepted.invitation_id,
|
||||
tenant=tenant_context.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="family_invitation.accepted",
|
||||
aggregate_id=accepted.invitation_id,
|
||||
payload={
|
||||
"invitation_id": accepted.invitation_id,
|
||||
"user_id": accepted.user_id,
|
||||
"tenant": accepted.tenant,
|
||||
"family_scope_id": accepted.family_scope_id,
|
||||
"application_id": accepted.application_id,
|
||||
"status": accepted.status,
|
||||
},
|
||||
application_id=accepted.application_id,
|
||||
)
|
||||
session = self._session(actor, self._require_user(accepted.user_id), account)
|
||||
identity_context = self.identity_context(
|
||||
actor,
|
||||
user_id=accepted.user_id,
|
||||
tenant=tenant_context.tenant,
|
||||
application_id=accepted.application_id,
|
||||
include_profile=True,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
claims_projection = self.projection(
|
||||
actor,
|
||||
accepted.user_id,
|
||||
ProjectionType.CLAIMS_ENRICHMENT,
|
||||
tenant=tenant_context.tenant,
|
||||
application_id=accepted.application_id,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return FamilyInvitationAcceptance(
|
||||
session=session,
|
||||
invitation=accepted,
|
||||
identity_context=identity_context,
|
||||
claims_projection=claims_projection,
|
||||
)
|
||||
|
||||
def tenant_diagnostics(
|
||||
self,
|
||||
actor: Actor,
|
||||
@@ -811,6 +1273,7 @@ class UserEngineService:
|
||||
"memberships": len(self.store.memberships),
|
||||
"applications": len(self.store.applications),
|
||||
"catalogs": len(self.store.catalogs),
|
||||
"family_invitations": len(self.store.family_invitations),
|
||||
"profile_values": len(self.store.profile_values),
|
||||
"audit_records": len(self.store.audit_records),
|
||||
"pending_outbox_events": len(self.store.outbox_events),
|
||||
@@ -839,6 +1302,242 @@ class UserEngineService:
|
||||
context["actor_subject"] = actor.subject
|
||||
return context
|
||||
|
||||
def _ensure_actor_session(
|
||||
self, actor: Actor, correlation_id: str
|
||||
) -> UserSession:
|
||||
identity = self.store.find_identity(*actor.identity_key)
|
||||
if identity is not None:
|
||||
user = self._require_user(identity.user_id)
|
||||
account = self._require_account(user.user_id)
|
||||
return self._session(actor, user, account)
|
||||
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="me.read",
|
||||
resource_type="user-engine:me",
|
||||
resource_id=actor.subject,
|
||||
tenant=actor.tenant,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
user = User(
|
||||
display_name=actor.preferred_username,
|
||||
primary_email=_optional_claim(actor, "email"),
|
||||
)
|
||||
account = Account(
|
||||
account_id=new_id("acct"),
|
||||
user_id=user.user_id,
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
|
||||
external_identity = ExternalIdentity(
|
||||
identity_id=new_id("idn"),
|
||||
user_id=user.user_id,
|
||||
issuer=actor.issuer,
|
||||
subject=actor.subject,
|
||||
provider=actor.authorized_party,
|
||||
)
|
||||
self.store.save_user(user)
|
||||
self.store.save_account(account)
|
||||
self.store.save_tenant_account(tenant_account)
|
||||
self.store.save_identity(external_identity)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="user.create_from_identity",
|
||||
subject=user.user_id,
|
||||
tenant=actor.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="user.created",
|
||||
aggregate_id=user.user_id,
|
||||
payload={
|
||||
"user_id": user.user_id,
|
||||
"account_id": account.account_id,
|
||||
"identity": {"issuer": actor.issuer, "subject": actor.subject},
|
||||
},
|
||||
)
|
||||
return self._session(actor, user, account)
|
||||
|
||||
def _ensure_family_dataspace_application(
|
||||
self,
|
||||
actor: Actor,
|
||||
request: FamilyDataspaceRequest,
|
||||
correlation_id: str,
|
||||
) -> tuple[Application, ApplicationBinding]:
|
||||
binding = _family_dataspace_binding(request)
|
||||
application = self.store.applications.get(request.application_id)
|
||||
if application is not None:
|
||||
if request.application_id not in self.store.bindings:
|
||||
decision = self._authorize(
|
||||
actor,
|
||||
action="application.bind",
|
||||
resource_type="user-engine:application",
|
||||
resource_id=request.application_id,
|
||||
tenant=request.tenant,
|
||||
correlation_id=correlation_id,
|
||||
application_id=request.application_id,
|
||||
)
|
||||
self.store.save_binding(binding)
|
||||
self._record_mutation(
|
||||
actor,
|
||||
action="application.bind",
|
||||
subject=request.application_id,
|
||||
tenant=request.tenant,
|
||||
correlation_id=correlation_id,
|
||||
decision_id=decision.decision_id,
|
||||
event_type="application.bound",
|
||||
aggregate_id=request.application_id,
|
||||
payload={
|
||||
"application_id": request.application_id,
|
||||
"catalog_namespaces": binding.catalog_namespaces,
|
||||
},
|
||||
application_id=request.application_id,
|
||||
)
|
||||
return application, self.store.bindings[request.application_id]
|
||||
|
||||
application = Application(
|
||||
application_id=request.application_id,
|
||||
display_name=request.application_display_name,
|
||||
owner=request.family_scope_id,
|
||||
allowed_profile_scopes=(
|
||||
ProfileScope.GLOBAL,
|
||||
ProfileScope.TENANT,
|
||||
ProfileScope.APPLICATION,
|
||||
ProfileScope.MEMBERSHIP,
|
||||
),
|
||||
allowed_projection_types=(
|
||||
ProjectionType.APPLICATION_RUNTIME,
|
||||
ProjectionType.CLAIMS_ENRICHMENT,
|
||||
),
|
||||
)
|
||||
return (
|
||||
self.register_application(
|
||||
actor,
|
||||
application,
|
||||
binding=binding,
|
||||
correlation_id=correlation_id,
|
||||
),
|
||||
binding,
|
||||
)
|
||||
|
||||
def _ensure_family_dataspace_catalog(
|
||||
self,
|
||||
actor: Actor,
|
||||
request: FamilyDataspaceRequest,
|
||||
correlation_id: str,
|
||||
) -> Catalog:
|
||||
catalog_id = f"{request.application_id}.profile"
|
||||
existing = self.store.catalogs.get(catalog_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
catalog = Catalog(
|
||||
catalog_id=catalog_id,
|
||||
namespace=request.catalog_namespace,
|
||||
version="0.1.0",
|
||||
owning_application_id=request.application_id,
|
||||
lifecycle=CatalogLifecycle.ACTIVE,
|
||||
attributes=(
|
||||
AttributeDefinition(
|
||||
key=f"{request.catalog_namespace}.family_display_name",
|
||||
value_type="string",
|
||||
scope=ProfileScope.TENANT,
|
||||
sensitivity=Sensitivity.PERSONAL,
|
||||
visibility=(
|
||||
Visibility.USER,
|
||||
Visibility.ADMIN,
|
||||
Visibility.APPLICATION,
|
||||
),
|
||||
mutability=(Mutability.ADMIN,),
|
||||
default=request.family_display_name,
|
||||
),
|
||||
AttributeDefinition(
|
||||
key=f"{request.catalog_namespace}.member_display_name",
|
||||
value_type="string",
|
||||
scope=ProfileScope.APPLICATION,
|
||||
sensitivity=Sensitivity.PERSONAL,
|
||||
visibility=(
|
||||
Visibility.USER,
|
||||
Visibility.ADMIN,
|
||||
Visibility.APPLICATION,
|
||||
),
|
||||
mutability=(Mutability.USER, Mutability.ADMIN),
|
||||
),
|
||||
AttributeDefinition(
|
||||
key=f"{request.catalog_namespace}.home_view",
|
||||
value_type="string",
|
||||
scope=ProfileScope.APPLICATION,
|
||||
sensitivity=Sensitivity.INTERNAL,
|
||||
visibility=(Visibility.USER, Visibility.APPLICATION),
|
||||
mutability=(Mutability.USER,),
|
||||
default="family",
|
||||
validation={"enum": ["family", "personal"]},
|
||||
),
|
||||
),
|
||||
)
|
||||
return self.publish_catalog(
|
||||
actor,
|
||||
catalog,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _ensure_membership(
|
||||
self,
|
||||
actor: Actor,
|
||||
user_id: str,
|
||||
*,
|
||||
tenant: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
kind: str,
|
||||
correlation_id: str,
|
||||
) -> Membership:
|
||||
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
|
||||
if (
|
||||
membership.scope_type == scope_type
|
||||
and membership.scope_id == scope_id
|
||||
and membership.kind == kind
|
||||
):
|
||||
return membership
|
||||
return self.add_membership(
|
||||
actor,
|
||||
user_id,
|
||||
tenant=tenant,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
kind=kind,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _apply_family_profile_defaults(
|
||||
self,
|
||||
actor: Actor,
|
||||
user_id: str,
|
||||
*,
|
||||
tenant: str,
|
||||
application_id: str,
|
||||
catalog_namespace: str,
|
||||
values: Mapping[str, Any],
|
||||
correlation_id: str,
|
||||
) -> None:
|
||||
for key, value in values.items():
|
||||
self.set_profile_value(
|
||||
actor,
|
||||
user_id,
|
||||
_family_profile_key(catalog_namespace, key),
|
||||
value,
|
||||
scope=ProfileScope.APPLICATION,
|
||||
scope_id=application_id,
|
||||
tenant=tenant,
|
||||
application_id=application_id,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _require_family_invitation(self, invitation_id: str) -> FamilyInvitation:
|
||||
invitation = self.store.family_invitation(invitation_id)
|
||||
if invitation is None:
|
||||
raise NotFoundError("family invitation not found")
|
||||
return invitation
|
||||
|
||||
def _identity_entity_refs(
|
||||
self,
|
||||
actor: Actor,
|
||||
@@ -1406,11 +2105,39 @@ def _scope_concept(scope_type: str) -> str:
|
||||
"team": "Team",
|
||||
"tenant": "Tenant",
|
||||
"application": "Scope",
|
||||
"family": "Group",
|
||||
"group": "Group",
|
||||
}
|
||||
return concepts.get(scope_type, "Scope")
|
||||
|
||||
|
||||
def _family_dataspace_binding(
|
||||
request: FamilyDataspaceRequest,
|
||||
) -> ApplicationBinding:
|
||||
event_source = request.event_source or request.application_id
|
||||
return ApplicationBinding(
|
||||
application_id=request.application_id,
|
||||
oidc_client_id=request.oidc_client_id,
|
||||
protected_system_id=request.protected_system_id,
|
||||
catalog_namespaces=(request.catalog_namespace,),
|
||||
event_source=event_source,
|
||||
deployment_ref=request.deployment_ref,
|
||||
)
|
||||
|
||||
|
||||
def _family_profile_key(catalog_namespace: str, key: str) -> str:
|
||||
if key.startswith(f"{catalog_namespace}."):
|
||||
return key
|
||||
return f"{catalog_namespace}.{key}"
|
||||
|
||||
|
||||
def _family_role_value(role: FamilyRole | str) -> str:
|
||||
try:
|
||||
return FamilyRole(str(role)).value
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"unsupported family role: {role}") from exc
|
||||
|
||||
|
||||
def _visible_in_projection(
|
||||
definition: AttributeDefinition, projection_type: ProjectionType
|
||||
) -> bool:
|
||||
|
||||
@@ -26,6 +26,7 @@ SCENARIO_MATRIX = (
|
||||
"two_applications",
|
||||
"sensitive_redaction",
|
||||
"audit_event_replay",
|
||||
"family_dataspace_onboarding",
|
||||
)
|
||||
|
||||
|
||||
|
||||
196
tests/test_family_dataspace_onboarding.py
Normal file
196
tests/test_family_dataspace_onboarding.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import unittest
|
||||
|
||||
from user_engine.adapters.local import InMemoryUserEngineStore
|
||||
from user_engine.domain import (
|
||||
AccountStatus,
|
||||
FamilyDataspaceRequest,
|
||||
FamilyMemberSpec,
|
||||
FamilyRole,
|
||||
InvitationStatus,
|
||||
)
|
||||
from user_engine.errors import AuthorizationDenied, ValidationError
|
||||
from user_engine.service import UserEngineService
|
||||
from user_engine.testing.fixtures import human_actor_claims
|
||||
from user_engine.testing.scenarios import (
|
||||
ScenarioAuthorizationHarness,
|
||||
StrictFixtureIdentityClaimsAdapter,
|
||||
)
|
||||
|
||||
|
||||
class FamilyDataspaceOnboardingTests(unittest.TestCase):
|
||||
def test_onboarding_creates_family_scope_dataspace_app_and_invitation(self):
|
||||
service, store, authz = _service()
|
||||
session = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
|
||||
onboarding = service.onboard_family_dataspace(
|
||||
session.actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
self.assertEqual(onboarding.tenant, "tenant:worsch-family")
|
||||
self.assertEqual(onboarding.binding.oidc_client_id, "personal-dataspace-client")
|
||||
self.assertEqual(onboarding.catalog.namespace, "dataspace")
|
||||
self.assertEqual(onboarding.owner_membership.kind, FamilyRole.OWNER.value)
|
||||
self.assertEqual(onboarding.invitations[0].invitation.status, InvitationStatus.PENDING)
|
||||
self.assertEqual(onboarding.invitations[0].tenant_account.status, AccountStatus.INVITED)
|
||||
self.assertEqual(onboarding.identity_context.entity_refs["family:family:worsch"].concept, "Group")
|
||||
self.assertEqual(
|
||||
onboarding.claims_projection.values["dataspace.family_display_name"],
|
||||
"Worsch Family",
|
||||
)
|
||||
self.assertIn("family_dataspace.onboarded", _event_types(service))
|
||||
self.assertIn("family_member.invited", _event_types(service))
|
||||
self.assertIn("family_dataspace.onboard", [request.action for request in authz.requests])
|
||||
self.assertEqual(len(store.family_invitations), 1)
|
||||
|
||||
def test_member_acceptance_links_sso_identity_and_returns_dataspace_context(self):
|
||||
service, _, _ = _service()
|
||||
owner = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
onboarding = _onboard_family(service, owner.actor)
|
||||
invitation = onboarding.invitations[0].invitation
|
||||
|
||||
acceptance = service.accept_family_invitation(
|
||||
_member_claims(subject="child-sso"),
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-accept",
|
||||
)
|
||||
|
||||
self.assertEqual(acceptance.invitation.status, InvitationStatus.ACCEPTED)
|
||||
self.assertEqual(acceptance.session.user.user_id, invitation.user_id)
|
||||
self.assertEqual(acceptance.session.account.status, AccountStatus.ACTIVE)
|
||||
self.assertEqual(
|
||||
service.store.tenant_account("tenant:worsch-family", invitation.user_id).status,
|
||||
AccountStatus.ACTIVE,
|
||||
)
|
||||
self.assertEqual(
|
||||
service.store.find_identity(
|
||||
"https://issuer.example.test",
|
||||
"child-sso",
|
||||
).user_id,
|
||||
invitation.user_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
acceptance.claims_projection.values["dataspace.member_display_name"],
|
||||
"Child Member",
|
||||
)
|
||||
self.assertEqual(acceptance.identity_context.memberships[0].kind, FamilyRole.CHILD.value)
|
||||
self.assertIn("family_invitation.accepted", _event_types(service))
|
||||
|
||||
def test_revoked_invitation_cannot_be_accepted(self):
|
||||
service, _, _ = _service()
|
||||
owner = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
onboarding = _onboard_family(service, owner.actor)
|
||||
invitation = onboarding.invitations[0].invitation
|
||||
|
||||
resent = service.resend_family_invitation(
|
||||
owner.actor,
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-resend",
|
||||
)
|
||||
revoked = service.revoke_family_invitation(
|
||||
owner.actor,
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-revoke",
|
||||
)
|
||||
|
||||
self.assertEqual(resent.resend_count, 1)
|
||||
self.assertEqual(revoked.status, InvitationStatus.REVOKED)
|
||||
self.assertEqual(
|
||||
service.store.tenant_account("tenant:worsch-family", invitation.user_id).status,
|
||||
AccountStatus.DISABLED,
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
service.accept_family_invitation(
|
||||
_member_claims(subject="revoked-child"),
|
||||
invitation.invitation_id,
|
||||
correlation_id="corr-revoked-accept",
|
||||
)
|
||||
|
||||
def test_cross_tenant_invitation_acceptance_is_denied(self):
|
||||
service, _, _ = _service()
|
||||
owner = service.me(_owner_claims(), correlation_id="corr-owner")
|
||||
onboarding = _onboard_family(service, owner.actor)
|
||||
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
service.accept_family_invitation(
|
||||
_member_claims(subject="wrong-tenant", tenant="tenant:other-family"),
|
||||
onboarding.invitations[0].invitation.invitation_id,
|
||||
correlation_id="corr-wrong-tenant",
|
||||
)
|
||||
|
||||
|
||||
def _service():
|
||||
store = InMemoryUserEngineStore()
|
||||
service = UserEngineService(
|
||||
store=store,
|
||||
identity_adapter=StrictFixtureIdentityClaimsAdapter(),
|
||||
authorization=ScenarioAuthorizationHarness(),
|
||||
)
|
||||
return service, store, service.authorization
|
||||
|
||||
|
||||
def _onboard_family(service: UserEngineService, actor):
|
||||
return service.onboard_family_dataspace(
|
||||
actor,
|
||||
FamilyDataspaceRequest(
|
||||
tenant="tenant:worsch-family",
|
||||
family_scope_id="family:worsch",
|
||||
family_display_name="Worsch Family",
|
||||
application_id="app.personal-dataspace",
|
||||
oidc_client_id="personal-dataspace-client",
|
||||
protected_system_id="dataspace.personal.worsch",
|
||||
member_specs=(
|
||||
FamilyMemberSpec(
|
||||
primary_email="child@example.test",
|
||||
display_name="Child Member",
|
||||
role=FamilyRole.CHILD,
|
||||
),
|
||||
),
|
||||
),
|
||||
correlation_id="corr-family-onboard",
|
||||
)
|
||||
|
||||
|
||||
def _owner_claims() -> dict[str, object]:
|
||||
claims = human_actor_claims(
|
||||
subject="family-owner",
|
||||
tenant="tenant:worsch-family",
|
||||
)
|
||||
claims["roles"] = ["tenant-admin"]
|
||||
claims["preferred_username"] = "family.owner"
|
||||
claims["email"] = "owner@example.test"
|
||||
return claims
|
||||
|
||||
|
||||
def _member_claims(
|
||||
*,
|
||||
subject: str,
|
||||
tenant: str = "tenant:worsch-family",
|
||||
) -> dict[str, object]:
|
||||
claims = human_actor_claims(subject=subject, tenant=tenant)
|
||||
claims["preferred_username"] = subject
|
||||
claims["email"] = f"{subject}@example.test"
|
||||
return claims
|
||||
|
||||
|
||||
def _event_types(service: UserEngineService) -> list[str]:
|
||||
return [event.event_type for event in service.outbox_events()]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,6 +44,7 @@ class IntegratedScenarioTests(unittest.TestCase):
|
||||
"two_applications",
|
||||
"sensitive_redaction",
|
||||
"audit_event_replay",
|
||||
"family_dataspace_onboarding",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
208
workplans/USER-WP-0008-family-dataspace-onboarding.md
Normal file
208
workplans/USER-WP-0008-family-dataspace-onboarding.md
Normal file
@@ -0,0 +1,208 @@
|
||||
---
|
||||
id: USER-WP-0008
|
||||
type: workplan
|
||||
title: "Family Dataspace Onboarding"
|
||||
domain: netkingdom
|
||||
repo: user-engine
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: netkingdom
|
||||
planning_priority: high
|
||||
planning_order: 8
|
||||
created: "2026-06-05"
|
||||
updated: "2026-06-05"
|
||||
depends_on:
|
||||
- USER-WP-0007
|
||||
---
|
||||
|
||||
# USER-WP-0008 - Family Dataspace Onboarding
|
||||
|
||||
## Goal
|
||||
|
||||
Make `user-engine` convenient for a personal-family use case: represent a
|
||||
family as a NetKingdom identity-domain scope, onboard family members into that
|
||||
scope, register a personal dataspace as a protected application, and provide
|
||||
SSO-ready identity context and profile projections without exposing consumers
|
||||
to IAM, authorization, profile, catalog, audit, or evidence implementation
|
||||
details.
|
||||
|
||||
The intended consumer experience is:
|
||||
|
||||
```text
|
||||
Create a family space, invite members, assign family roles, bind a personal
|
||||
dataspace application, and let NetKingdom SSO receive the claims/profile
|
||||
context it needs.
|
||||
```
|
||||
|
||||
## Scope Direction
|
||||
|
||||
`user-engine` should orchestrate domain-facing setup and read models. It should
|
||||
not provision the NetKingdom tenant, issue credentials, own the identity
|
||||
provider, or become the protected dataspace runtime. The family scope is a
|
||||
tenant or tenant-backed organization reference owned by NetKingdom
|
||||
infrastructure; user-engine manages local users, accounts, identity links,
|
||||
memberships, profile values, application bindings, projections, audit, and
|
||||
canon-facing identity context for that scope.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not issue SSO tokens, sessions, passwords, passkeys, or MFA challenges.
|
||||
- Do not provision the underlying NetKingdom tenant or organization authority.
|
||||
- Do not become the personal dataspace storage/runtime implementation.
|
||||
- Do not implement a production UI as part of the first onboarding slice.
|
||||
- Do not hard-code family relationship policy into authorization decisions;
|
||||
export facts and consume NetKingdom authorization outcomes.
|
||||
- Do not implement the durable Postgres store in this workplan.
|
||||
|
||||
## Tasks
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T1
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Define the family dataspace vocabulary and mapping. Cover family tenant,
|
||||
family/member scopes, owner, adult, child, guest, delegated caretaker, personal
|
||||
dataspace application, SSO claims enrichment, and identity-canon references.
|
||||
Mark which facts are owned by user-engine and which remain owned by NetKingdom
|
||||
IAM, tenant, policy, audit, or dataspace systems.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T2
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Design and implement a headless onboarding facade that composes existing
|
||||
service operations into a convenient use-case API. The facade should accept a
|
||||
NetKingdom-provided family tenant reference, owner actor, dataspace application
|
||||
binding, initial member descriptors, role assignments, and profile defaults.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T3
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Add member invitation and acceptance support. Cover pre-created users,
|
||||
tenant-account lifecycle, invitation status, identity-link acceptance,
|
||||
resend/revoke behavior, and audit/event records. Keep invitation tokens and
|
||||
identity proofing delegated to NetKingdom IAM or a dedicated invite adapter.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T4
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Register the personal dataspace application through `register_application`,
|
||||
bind it to external SSO/protected-system identifiers, and publish a minimal
|
||||
profile catalog for dataspace-specific claims, preferences, and visibility
|
||||
rules.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T5
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Implement family membership templates and fact export. Support owner, adult,
|
||||
child, guest, and delegated roles as scoped memberships while preserving tenant
|
||||
boundaries and authorization-port decisions for privileged actions.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T6
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Expose SSO-ready context for the personal dataspace. Use `identity_context`
|
||||
and claims-enrichment projections to provide subject, principal, account,
|
||||
family tenant, role/membership, profile, evidence, and explicit gap references
|
||||
to the NetKingdom SSO adapter.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T7
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Add lifecycle, audit, evidence, and outbox behavior for onboarding. Every
|
||||
family/member/application/profile mutation should produce correlated audit and
|
||||
outbox records, and privileged role grants should be traceable through evidence
|
||||
or explicit evidence-gap references.
|
||||
|
||||
```task
|
||||
id: USER-WP-0008-T8
|
||||
status: done
|
||||
priority: medium
|
||||
```
|
||||
|
||||
Add scenario tests and examples for the complete family dataspace flow. Cover
|
||||
owner setup, member invitation, accepted SSO identity link, child/guest
|
||||
membership, dataspace claims enrichment, tenant isolation, and denied
|
||||
cross-family access.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A consumer can onboard a family dataspace through one headless facade or a
|
||||
small number of purpose-built commands instead of manually sequencing low
|
||||
level service calls.
|
||||
- The family scope is represented as a NetKingdom tenant or tenant-backed
|
||||
organization reference, not as a user-engine-owned organization authority.
|
||||
- Family members have distinct users, accounts, tenant accounts, external
|
||||
identities, and scoped memberships.
|
||||
- The personal dataspace is registered as an application with a binding to
|
||||
SSO/protected-system identifiers and a minimal catalog for dataspace profile
|
||||
values.
|
||||
- NetKingdom SSO can consume claims-enrichment projection or identity-context
|
||||
output without knowing user-engine persistence details.
|
||||
- Owner/adult/child/guest behavior is represented as membership facts and
|
||||
authorization context, not embedded as final policy decisions in user-engine.
|
||||
- Audit, outbox, evidence references, and lifecycle gaps exist for onboarding
|
||||
and role changes.
|
||||
- Scenario tests prove happy-path onboarding, SSO context generation, and
|
||||
tenant isolation.
|
||||
|
||||
## Expected Outputs
|
||||
|
||||
- Family dataspace vocabulary and mapping notes.
|
||||
- Headless onboarding facade or command contract.
|
||||
- Invitation and member lifecycle model.
|
||||
- Personal dataspace application/catalog example.
|
||||
- Family membership templates and fact export behavior.
|
||||
- Claims-enrichment and `identity_context` examples for SSO adapters.
|
||||
- Scenario tests and documentation for the end-to-end use case.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Implemented on 2026-06-05:
|
||||
|
||||
- Added family-domain roles, invitation status, member specs, onboarding
|
||||
request, and invitation records.
|
||||
- Added local invitation persistence to the isolated store boundary.
|
||||
- Added `UserEngineService.onboard_family_dataspace(...)` as the headless
|
||||
onboarding facade.
|
||||
- Added `invite_family_member`, `resend_family_invitation`,
|
||||
`revoke_family_invitation`, and `accept_family_invitation`.
|
||||
- Registered the personal dataspace as an application with an SSO/protected
|
||||
system binding and a minimal dataspace profile catalog.
|
||||
- Represented family roles as scoped memberships while preserving
|
||||
authorization-port decisions.
|
||||
- Returned `identity_context` and `CLAIMS_ENRICHMENT` projection outputs for
|
||||
SSO adapters.
|
||||
- Added audit/outbox events for high-level family onboarding and invitation
|
||||
lifecycle actions.
|
||||
- Added `docs/family-dataspace-onboarding.md`, examples, contract updates, and
|
||||
scenario documentation.
|
||||
- Added scenario tests for owner onboarding, member acceptance, resend/revoke,
|
||||
SSO identity linking, claims projection, and cross-family denial.
|
||||
|
||||
Verification:
|
||||
|
||||
```text
|
||||
make test
|
||||
Ran 39 tests in 0.119s
|
||||
OK
|
||||
```
|
||||
Reference in New Issue
Block a user