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`,
|
See `docs/development.md`, `docs/configuration.md`, `docs/contracts.md`,
|
||||||
`docs/canon-mapping.md`, `docs/canon-interface-card.yaml`,
|
`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/operability.md`, `docs/release.md`, `docs/ui-contracts.md`,
|
||||||
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
`docs/identity-domain-naming-decision.md`, and `docs/final-assessment.md`
|
||||||
for implementation boundaries, contracts, canon mappings, examples, and release
|
for implementation boundaries, contracts, canon mappings, examples, and release
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ HTTP or RPC adapters should preserve these operation names:
|
|||||||
`tenant_diagnostics`
|
`tenant_diagnostics`
|
||||||
- `register_application`, `publish_catalog`
|
- `register_application`, `publish_catalog`
|
||||||
- `set_profile_value`, `effective_profile`, `projection`, `identity_context`
|
- `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`
|
- `audit_records`, `outbox_events`
|
||||||
|
|
||||||
## Identity Context Contract
|
## 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
|
to adapter contracts and remain non-owned unless a later workplan assigns
|
||||||
source-of-truth responsibility to user-engine.
|
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
|
## Error Taxonomy
|
||||||
|
|
||||||
- `ValidationError`: caller supplied an invalid shape, state transition, or
|
- `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():
|
for event in service.outbox_events():
|
||||||
print(event.event_type, event.aggregate_id, event.correlation_id)
|
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. |
|
| sensitive_redaction | Sensitive values are redacted in runtime and claims-enrichment projections. |
|
||||||
| audit_event_replay | Mutations carry audit records, outbox events, and correlation ids. |
|
| 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. |
|
| 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
|
## Fixture Actors
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from user_engine.domain import (
|
|||||||
AuthorizationRequest,
|
AuthorizationRequest,
|
||||||
Catalog,
|
Catalog,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
FamilyInvitation,
|
||||||
Membership,
|
Membership,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
ProfileScope,
|
ProfileScope,
|
||||||
@@ -44,6 +45,7 @@ class InMemoryUserEngineStore:
|
|||||||
applications: dict[str, Application] = field(default_factory=dict)
|
applications: dict[str, Application] = field(default_factory=dict)
|
||||||
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
|
||||||
catalogs: dict[str, Catalog] = field(default_factory=dict)
|
catalogs: dict[str, Catalog] = field(default_factory=dict)
|
||||||
|
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
|
||||||
profile_values: dict[
|
profile_values: dict[
|
||||||
tuple[str, str, ProfileScope, str | None], ProfileValue
|
tuple[str, str, ProfileScope, str | None], ProfileValue
|
||||||
] = field(default_factory=dict)
|
] = field(default_factory=dict)
|
||||||
@@ -82,6 +84,19 @@ class InMemoryUserEngineStore:
|
|||||||
def save_catalog(self, catalog: Catalog) -> None:
|
def save_catalog(self, catalog: Catalog) -> None:
|
||||||
self.catalogs[catalog.catalog_id] = catalog
|
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:
|
def save_profile_value(self, value: ProfileValue) -> None:
|
||||||
self.profile_values[
|
self.profile_values[
|
||||||
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
(value.user_id, value.attribute_key, value.scope, value.scope_id)
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ from user_engine.domain.models import (
|
|||||||
Catalog,
|
Catalog,
|
||||||
CatalogLifecycle,
|
CatalogLifecycle,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
FamilyDataspaceRequest,
|
||||||
|
FamilyInvitation,
|
||||||
|
FamilyMemberSpec,
|
||||||
|
FamilyRole,
|
||||||
|
InvitationStatus,
|
||||||
ManagementMode,
|
ManagementMode,
|
||||||
Membership,
|
Membership,
|
||||||
Mutability,
|
Mutability,
|
||||||
@@ -48,6 +53,11 @@ __all__ = [
|
|||||||
"Catalog",
|
"Catalog",
|
||||||
"CatalogLifecycle",
|
"CatalogLifecycle",
|
||||||
"ExternalIdentity",
|
"ExternalIdentity",
|
||||||
|
"FamilyDataspaceRequest",
|
||||||
|
"FamilyInvitation",
|
||||||
|
"FamilyMemberSpec",
|
||||||
|
"FamilyRole",
|
||||||
|
"InvitationStatus",
|
||||||
"ManagementMode",
|
"ManagementMode",
|
||||||
"Membership",
|
"Membership",
|
||||||
"Mutability",
|
"Mutability",
|
||||||
|
|||||||
@@ -45,6 +45,20 @@ class ManagementMode(StrEnum):
|
|||||||
SERVICE_MANAGED = "service_managed"
|
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):
|
class ProfileScope(StrEnum):
|
||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
TENANT = "tenant"
|
TENANT = "tenant"
|
||||||
@@ -252,6 +266,53 @@ class Membership:
|
|||||||
created_at: datetime = field(default_factory=utc_now)
|
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)
|
@dataclass(frozen=True)
|
||||||
class AuthorizationRequest:
|
class AuthorizationRequest:
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from user_engine.domain import (
|
|||||||
Catalog,
|
Catalog,
|
||||||
CatalogLifecycle,
|
CatalogLifecycle,
|
||||||
ExternalIdentity,
|
ExternalIdentity,
|
||||||
|
FamilyDataspaceRequest,
|
||||||
|
FamilyInvitation,
|
||||||
|
FamilyMemberSpec,
|
||||||
|
FamilyRole,
|
||||||
|
InvitationStatus,
|
||||||
Membership,
|
Membership,
|
||||||
Mutability,
|
Mutability,
|
||||||
OutboxEvent,
|
OutboxEvent,
|
||||||
@@ -31,6 +36,7 @@ from user_engine.domain import (
|
|||||||
User,
|
User,
|
||||||
Visibility,
|
Visibility,
|
||||||
new_id,
|
new_id,
|
||||||
|
utc_now,
|
||||||
)
|
)
|
||||||
from user_engine.errors import (
|
from user_engine.errors import (
|
||||||
AuthorizationDenied,
|
AuthorizationDenied,
|
||||||
@@ -108,6 +114,37 @@ class IdentityContext:
|
|||||||
gaps: tuple[str, ...] = ()
|
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)
|
@dataclass(frozen=True)
|
||||||
class TenantDiagnostics:
|
class TenantDiagnostics:
|
||||||
tenant: str
|
tenant: str
|
||||||
@@ -745,6 +782,431 @@ class UserEngineService:
|
|||||||
gaps=gaps,
|
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(
|
def tenant_diagnostics(
|
||||||
self,
|
self,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@@ -811,6 +1273,7 @@ class UserEngineService:
|
|||||||
"memberships": len(self.store.memberships),
|
"memberships": len(self.store.memberships),
|
||||||
"applications": len(self.store.applications),
|
"applications": len(self.store.applications),
|
||||||
"catalogs": len(self.store.catalogs),
|
"catalogs": len(self.store.catalogs),
|
||||||
|
"family_invitations": len(self.store.family_invitations),
|
||||||
"profile_values": len(self.store.profile_values),
|
"profile_values": len(self.store.profile_values),
|
||||||
"audit_records": len(self.store.audit_records),
|
"audit_records": len(self.store.audit_records),
|
||||||
"pending_outbox_events": len(self.store.outbox_events),
|
"pending_outbox_events": len(self.store.outbox_events),
|
||||||
@@ -839,6 +1302,242 @@ class UserEngineService:
|
|||||||
context["actor_subject"] = actor.subject
|
context["actor_subject"] = actor.subject
|
||||||
return context
|
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(
|
def _identity_entity_refs(
|
||||||
self,
|
self,
|
||||||
actor: Actor,
|
actor: Actor,
|
||||||
@@ -1406,11 +2105,39 @@ def _scope_concept(scope_type: str) -> str:
|
|||||||
"team": "Team",
|
"team": "Team",
|
||||||
"tenant": "Tenant",
|
"tenant": "Tenant",
|
||||||
"application": "Scope",
|
"application": "Scope",
|
||||||
|
"family": "Group",
|
||||||
"group": "Group",
|
"group": "Group",
|
||||||
}
|
}
|
||||||
return concepts.get(scope_type, "Scope")
|
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(
|
def _visible_in_projection(
|
||||||
definition: AttributeDefinition, projection_type: ProjectionType
|
definition: AttributeDefinition, projection_type: ProjectionType
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ SCENARIO_MATRIX = (
|
|||||||
"two_applications",
|
"two_applications",
|
||||||
"sensitive_redaction",
|
"sensitive_redaction",
|
||||||
"audit_event_replay",
|
"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",
|
"two_applications",
|
||||||
"sensitive_redaction",
|
"sensitive_redaction",
|
||||||
"audit_event_replay",
|
"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