generated from coulomb/repo-seed
Implement family dataspace onboarding
This commit is contained in:
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user