Implement family dataspace onboarding

This commit is contained in:
2026-06-05 18:51:47 +02:00
parent af6d82038e
commit 531c2193a4
13 changed files with 1400 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ from user_engine.domain import (
AuthorizationRequest,
Catalog,
ExternalIdentity,
FamilyInvitation,
Membership,
OutboxEvent,
ProfileScope,
@@ -44,6 +45,7 @@ class InMemoryUserEngineStore:
applications: dict[str, Application] = field(default_factory=dict)
bindings: dict[str, ApplicationBinding] = field(default_factory=dict)
catalogs: dict[str, Catalog] = field(default_factory=dict)
family_invitations: dict[str, FamilyInvitation] = field(default_factory=dict)
profile_values: dict[
tuple[str, str, ProfileScope, str | None], ProfileValue
] = field(default_factory=dict)
@@ -82,6 +84,19 @@ class InMemoryUserEngineStore:
def save_catalog(self, catalog: Catalog) -> None:
self.catalogs[catalog.catalog_id] = catalog
def save_family_invitation(self, invitation: FamilyInvitation) -> None:
self.family_invitations[invitation.invitation_id] = invitation
def family_invitation(self, invitation_id: str) -> FamilyInvitation | None:
return self.family_invitations.get(invitation_id)
def family_invitations_for_user(self, user_id: str) -> tuple[FamilyInvitation, ...]:
return tuple(
invitation
for invitation in self.family_invitations.values()
if invitation.user_id == user_id
)
def save_profile_value(self, value: ProfileValue) -> None:
self.profile_values[
(value.user_id, value.attribute_key, value.scope, value.scope_id)

View File

@@ -16,6 +16,11 @@ from user_engine.domain.models import (
Catalog,
CatalogLifecycle,
ExternalIdentity,
FamilyDataspaceRequest,
FamilyInvitation,
FamilyMemberSpec,
FamilyRole,
InvitationStatus,
ManagementMode,
Membership,
Mutability,
@@ -48,6 +53,11 @@ __all__ = [
"Catalog",
"CatalogLifecycle",
"ExternalIdentity",
"FamilyDataspaceRequest",
"FamilyInvitation",
"FamilyMemberSpec",
"FamilyRole",
"InvitationStatus",
"ManagementMode",
"Membership",
"Mutability",

View File

@@ -45,6 +45,20 @@ class ManagementMode(StrEnum):
SERVICE_MANAGED = "service_managed"
class FamilyRole(StrEnum):
OWNER = "owner"
ADULT = "adult"
CHILD = "child"
GUEST = "guest"
DELEGATED_CARETAKER = "delegated-caretaker"
class InvitationStatus(StrEnum):
PENDING = "pending"
ACCEPTED = "accepted"
REVOKED = "revoked"
class ProfileScope(StrEnum):
GLOBAL = "global"
TENANT = "tenant"
@@ -252,6 +266,53 @@ class Membership:
created_at: datetime = field(default_factory=utc_now)
@dataclass(frozen=True)
class FamilyMemberSpec:
primary_email: str
role: FamilyRole | str = FamilyRole.ADULT
display_name: str | None = None
issuer: str | None = None
subject: str | None = None
provider: str | None = None
profile_defaults: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class FamilyDataspaceRequest:
tenant: str
family_scope_id: str
family_display_name: str
application_id: str = "app.personal-dataspace"
application_display_name: str = "Personal Dataspace"
oidc_client_id: str | None = None
protected_system_id: str | None = None
catalog_namespace: str = "dataspace"
event_source: str | None = None
deployment_ref: str | None = None
member_specs: tuple[FamilyMemberSpec, ...] = ()
owner_profile_defaults: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class FamilyInvitation:
invitation_id: str
tenant: str
family_scope_id: str
application_id: str
user_id: str
primary_email: str
role: str
status: InvitationStatus = InvitationStatus.PENDING
invited_by: str | None = None
correlation_id: str | None = None
resend_count: int = 0
last_sent_correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now)
accepted_at: datetime | None = None
revoked_at: datetime | None = None
@dataclass(frozen=True)
class AuthorizationRequest:
actor: Actor

View File

@@ -20,6 +20,11 @@ from user_engine.domain import (
Catalog,
CatalogLifecycle,
ExternalIdentity,
FamilyDataspaceRequest,
FamilyInvitation,
FamilyMemberSpec,
FamilyRole,
InvitationStatus,
Membership,
Mutability,
OutboxEvent,
@@ -31,6 +36,7 @@ from user_engine.domain import (
User,
Visibility,
new_id,
utc_now,
)
from user_engine.errors import (
AuthorizationDenied,
@@ -108,6 +114,37 @@ class IdentityContext:
gaps: tuple[str, ...] = ()
@dataclass(frozen=True)
class FamilyMemberInvitation:
user: User
tenant_account: TenantAccount
membership: Membership
invitation: FamilyInvitation
@dataclass(frozen=True)
class FamilyDataspaceOnboarding:
tenant: str
family_scope_id: str
family_display_name: str
owner_session: UserSession
owner_membership: Membership
application: Application
binding: ApplicationBinding
catalog: Catalog
invitations: tuple[FamilyMemberInvitation, ...]
identity_context: IdentityContext
claims_projection: Projection
@dataclass(frozen=True)
class FamilyInvitationAcceptance:
session: UserSession
invitation: FamilyInvitation
identity_context: IdentityContext
claims_projection: Projection
@dataclass(frozen=True)
class TenantDiagnostics:
tenant: str
@@ -745,6 +782,431 @@ class UserEngineService:
gaps=gaps,
)
def onboard_family_dataspace(
self,
actor: Actor,
request: FamilyDataspaceRequest,
*,
correlation_id: str | None = None,
) -> FamilyDataspaceOnboarding:
tenant_context = self.resolve_tenant_context(actor, request.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_dataspace.onboard",
resource_type="user-engine:family-dataspace",
resource_id=request.family_scope_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=request.application_id,
context={"family_scope_id": request.family_scope_id},
)
owner_session = self._ensure_actor_session(actor, correlation_id)
application, binding = self._ensure_family_dataspace_application(
actor, request, correlation_id
)
catalog = self._ensure_family_dataspace_catalog(
actor, request, correlation_id
)
owner_membership = self._ensure_membership(
actor,
owner_session.user.user_id,
tenant=tenant_context.tenant,
scope_type="family",
scope_id=request.family_scope_id,
kind=FamilyRole.OWNER.value,
correlation_id=correlation_id,
)
owner_defaults = dict(request.owner_profile_defaults)
owner_defaults.setdefault(
"member_display_name",
owner_session.user.display_name
or actor.preferred_username
or owner_session.user.primary_email
or actor.subject,
)
self._apply_family_profile_defaults(
actor,
owner_session.user.user_id,
tenant=tenant_context.tenant,
application_id=request.application_id,
catalog_namespace=request.catalog_namespace,
values=owner_defaults,
correlation_id=correlation_id,
)
invitations = tuple(
self.invite_family_member(
actor,
tenant=tenant_context.tenant,
family_scope_id=request.family_scope_id,
application_id=request.application_id,
catalog_namespace=request.catalog_namespace,
member=member,
correlation_id=correlation_id,
)
for member in request.member_specs
)
self._record_mutation(
actor,
action="family_dataspace.onboard",
subject=request.family_scope_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_dataspace.onboarded",
aggregate_id=request.family_scope_id,
payload={
"tenant": tenant_context.tenant,
"family_scope_id": request.family_scope_id,
"application_id": request.application_id,
"member_invitation_count": len(invitations),
},
application_id=request.application_id,
)
identity_context = self.identity_context(
actor,
user_id=owner_session.user.user_id,
tenant=tenant_context.tenant,
application_id=request.application_id,
include_profile=True,
correlation_id=correlation_id,
)
claims_projection = self.projection(
actor,
owner_session.user.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
tenant=tenant_context.tenant,
application_id=request.application_id,
correlation_id=correlation_id,
)
return FamilyDataspaceOnboarding(
tenant=tenant_context.tenant,
family_scope_id=request.family_scope_id,
family_display_name=request.family_display_name,
owner_session=owner_session,
owner_membership=owner_membership,
application=application,
binding=binding,
catalog=catalog,
invitations=invitations,
identity_context=identity_context,
claims_projection=claims_projection,
)
def invite_family_member(
self,
actor: Actor,
*,
tenant: str,
family_scope_id: str,
application_id: str,
member: FamilyMemberSpec,
catalog_namespace: str = "dataspace",
correlation_id: str | None = None,
) -> FamilyMemberInvitation:
tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr")
role = _family_role_value(member.role)
if not member.primary_email:
raise ValidationError("family member primary_email is required")
if member.issuer and member.subject:
existing = self.store.find_identity(member.issuer, member.subject)
if existing is not None:
raise ConflictError("external identity is already linked")
decision = self._authorize(
actor,
action="family_member.invite",
resource_type="user-engine:family-invitation",
resource_id=member.primary_email,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=application_id,
context={
"family_scope_id": family_scope_id,
"role": role,
},
)
user = self.create_user(
actor,
display_name=member.display_name,
primary_email=member.primary_email,
correlation_id=correlation_id,
)
tenant_account = self.set_tenant_account_status(
actor,
user.user_id,
AccountStatus.INVITED,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
membership = self._ensure_membership(
actor,
user.user_id,
tenant=tenant_context.tenant,
scope_type="family",
scope_id=family_scope_id,
kind=role,
correlation_id=correlation_id,
)
profile_defaults = dict(member.profile_defaults)
if member.display_name:
profile_defaults.setdefault("member_display_name", member.display_name)
self._apply_family_profile_defaults(
actor,
user.user_id,
tenant=tenant_context.tenant,
application_id=application_id,
catalog_namespace=catalog_namespace,
values=profile_defaults,
correlation_id=correlation_id,
)
if member.issuer and member.subject:
self.link_identity(
actor,
user.user_id,
issuer=member.issuer,
subject=member.subject,
provider=member.provider,
correlation_id=correlation_id,
)
invitation = FamilyInvitation(
invitation_id=new_id("inv"),
tenant=tenant_context.tenant,
family_scope_id=family_scope_id,
application_id=application_id,
user_id=user.user_id,
primary_email=member.primary_email,
role=role,
invited_by=actor.subject,
correlation_id=correlation_id,
last_sent_correlation_id=correlation_id,
)
self.store.save_family_invitation(invitation)
self._record_mutation(
actor,
action="family_member.invite",
subject=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_member.invited",
aggregate_id=invitation.invitation_id,
payload={
"invitation_id": invitation.invitation_id,
"user_id": user.user_id,
"tenant": tenant_context.tenant,
"family_scope_id": family_scope_id,
"application_id": application_id,
"role": role,
},
application_id=application_id,
)
return FamilyMemberInvitation(
user=user,
tenant_account=tenant_account,
membership=membership,
invitation=invitation,
)
def resend_family_invitation(
self,
actor: Actor,
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be resent")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.resend",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
)
updated = replace(
invitation,
resend_count=invitation.resend_count + 1,
last_sent_correlation_id=correlation_id,
updated_at=utc_now(),
)
self.store.save_family_invitation(updated)
self._record_mutation(
actor,
action="family_invitation.resend",
subject=updated.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.resent",
aggregate_id=updated.invitation_id,
payload={
"invitation_id": updated.invitation_id,
"user_id": updated.user_id,
"resend_count": updated.resend_count,
},
application_id=updated.application_id,
)
return updated
def revoke_family_invitation(
self,
actor: Actor,
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitation:
invitation = self._require_family_invitation(invitation_id)
if invitation.status != InvitationStatus.PENDING:
raise ValidationError("only pending invitations can be revoked")
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.revoke",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
)
self.set_tenant_account_status(
actor,
invitation.user_id,
AccountStatus.DISABLED,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
updated = replace(
invitation,
status=InvitationStatus.REVOKED,
updated_at=utc_now(),
revoked_at=utc_now(),
)
self.store.save_family_invitation(updated)
self._record_mutation(
actor,
action="family_invitation.revoke",
subject=updated.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.revoked",
aggregate_id=updated.invitation_id,
payload={
"invitation_id": updated.invitation_id,
"user_id": updated.user_id,
"status": updated.status,
},
application_id=updated.application_id,
)
return updated
def accept_family_invitation(
self,
claims: Mapping[str, Any],
invitation_id: str,
*,
correlation_id: str | None = None,
) -> FamilyInvitationAcceptance:
invitation = self._require_family_invitation(invitation_id)
if invitation.status == InvitationStatus.REVOKED:
raise ValidationError("revoked invitations cannot be accepted")
if invitation.status == InvitationStatus.ACCEPTED:
raise ValidationError("invitation is already accepted")
actor = self.identity_adapter.normalize(claims)
tenant_context = self.resolve_tenant_context(actor, invitation.tenant)
correlation_id = correlation_id or new_id("corr")
decision = self._authorize(
actor,
action="family_invitation.accept",
resource_type="user-engine:family-invitation",
resource_id=invitation.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
application_id=invitation.application_id,
target_user_id=invitation.user_id,
context={"family_scope_id": invitation.family_scope_id},
)
self.link_identity(
actor,
invitation.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
correlation_id=correlation_id,
)
account = self.set_account_status(
actor,
invitation.user_id,
AccountStatus.ACTIVE,
correlation_id=correlation_id,
)
self.set_tenant_account_status(
actor,
invitation.user_id,
AccountStatus.ACTIVE,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
)
accepted_at = utc_now()
accepted = replace(
invitation,
status=InvitationStatus.ACCEPTED,
updated_at=accepted_at,
accepted_at=accepted_at,
)
self.store.save_family_invitation(accepted)
self._record_mutation(
actor,
action="family_invitation.accept",
subject=accepted.invitation_id,
tenant=tenant_context.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="family_invitation.accepted",
aggregate_id=accepted.invitation_id,
payload={
"invitation_id": accepted.invitation_id,
"user_id": accepted.user_id,
"tenant": accepted.tenant,
"family_scope_id": accepted.family_scope_id,
"application_id": accepted.application_id,
"status": accepted.status,
},
application_id=accepted.application_id,
)
session = self._session(actor, self._require_user(accepted.user_id), account)
identity_context = self.identity_context(
actor,
user_id=accepted.user_id,
tenant=tenant_context.tenant,
application_id=accepted.application_id,
include_profile=True,
correlation_id=correlation_id,
)
claims_projection = self.projection(
actor,
accepted.user_id,
ProjectionType.CLAIMS_ENRICHMENT,
tenant=tenant_context.tenant,
application_id=accepted.application_id,
correlation_id=correlation_id,
)
return FamilyInvitationAcceptance(
session=session,
invitation=accepted,
identity_context=identity_context,
claims_projection=claims_projection,
)
def tenant_diagnostics(
self,
actor: Actor,
@@ -811,6 +1273,7 @@ class UserEngineService:
"memberships": len(self.store.memberships),
"applications": len(self.store.applications),
"catalogs": len(self.store.catalogs),
"family_invitations": len(self.store.family_invitations),
"profile_values": len(self.store.profile_values),
"audit_records": len(self.store.audit_records),
"pending_outbox_events": len(self.store.outbox_events),
@@ -839,6 +1302,242 @@ class UserEngineService:
context["actor_subject"] = actor.subject
return context
def _ensure_actor_session(
self, actor: Actor, correlation_id: str
) -> UserSession:
identity = self.store.find_identity(*actor.identity_key)
if identity is not None:
user = self._require_user(identity.user_id)
account = self._require_account(user.user_id)
return self._session(actor, user, account)
decision = self._authorize(
actor,
action="me.read",
resource_type="user-engine:me",
resource_id=actor.subject,
tenant=actor.tenant,
correlation_id=correlation_id,
)
user = User(
display_name=actor.preferred_username,
primary_email=_optional_claim(actor, "email"),
)
account = Account(
account_id=new_id("acct"),
user_id=user.user_id,
status=AccountStatus.ACTIVE,
)
tenant_account = TenantAccount(user_id=user.user_id, tenant=actor.tenant)
external_identity = ExternalIdentity(
identity_id=new_id("idn"),
user_id=user.user_id,
issuer=actor.issuer,
subject=actor.subject,
provider=actor.authorized_party,
)
self.store.save_user(user)
self.store.save_account(account)
self.store.save_tenant_account(tenant_account)
self.store.save_identity(external_identity)
self._record_mutation(
actor,
action="user.create_from_identity",
subject=user.user_id,
tenant=actor.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="user.created",
aggregate_id=user.user_id,
payload={
"user_id": user.user_id,
"account_id": account.account_id,
"identity": {"issuer": actor.issuer, "subject": actor.subject},
},
)
return self._session(actor, user, account)
def _ensure_family_dataspace_application(
self,
actor: Actor,
request: FamilyDataspaceRequest,
correlation_id: str,
) -> tuple[Application, ApplicationBinding]:
binding = _family_dataspace_binding(request)
application = self.store.applications.get(request.application_id)
if application is not None:
if request.application_id not in self.store.bindings:
decision = self._authorize(
actor,
action="application.bind",
resource_type="user-engine:application",
resource_id=request.application_id,
tenant=request.tenant,
correlation_id=correlation_id,
application_id=request.application_id,
)
self.store.save_binding(binding)
self._record_mutation(
actor,
action="application.bind",
subject=request.application_id,
tenant=request.tenant,
correlation_id=correlation_id,
decision_id=decision.decision_id,
event_type="application.bound",
aggregate_id=request.application_id,
payload={
"application_id": request.application_id,
"catalog_namespaces": binding.catalog_namespaces,
},
application_id=request.application_id,
)
return application, self.store.bindings[request.application_id]
application = Application(
application_id=request.application_id,
display_name=request.application_display_name,
owner=request.family_scope_id,
allowed_profile_scopes=(
ProfileScope.GLOBAL,
ProfileScope.TENANT,
ProfileScope.APPLICATION,
ProfileScope.MEMBERSHIP,
),
allowed_projection_types=(
ProjectionType.APPLICATION_RUNTIME,
ProjectionType.CLAIMS_ENRICHMENT,
),
)
return (
self.register_application(
actor,
application,
binding=binding,
correlation_id=correlation_id,
),
binding,
)
def _ensure_family_dataspace_catalog(
self,
actor: Actor,
request: FamilyDataspaceRequest,
correlation_id: str,
) -> Catalog:
catalog_id = f"{request.application_id}.profile"
existing = self.store.catalogs.get(catalog_id)
if existing is not None:
return existing
catalog = Catalog(
catalog_id=catalog_id,
namespace=request.catalog_namespace,
version="0.1.0",
owning_application_id=request.application_id,
lifecycle=CatalogLifecycle.ACTIVE,
attributes=(
AttributeDefinition(
key=f"{request.catalog_namespace}.family_display_name",
value_type="string",
scope=ProfileScope.TENANT,
sensitivity=Sensitivity.PERSONAL,
visibility=(
Visibility.USER,
Visibility.ADMIN,
Visibility.APPLICATION,
),
mutability=(Mutability.ADMIN,),
default=request.family_display_name,
),
AttributeDefinition(
key=f"{request.catalog_namespace}.member_display_name",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.PERSONAL,
visibility=(
Visibility.USER,
Visibility.ADMIN,
Visibility.APPLICATION,
),
mutability=(Mutability.USER, Mutability.ADMIN),
),
AttributeDefinition(
key=f"{request.catalog_namespace}.home_view",
value_type="string",
scope=ProfileScope.APPLICATION,
sensitivity=Sensitivity.INTERNAL,
visibility=(Visibility.USER, Visibility.APPLICATION),
mutability=(Mutability.USER,),
default="family",
validation={"enum": ["family", "personal"]},
),
),
)
return self.publish_catalog(
actor,
catalog,
correlation_id=correlation_id,
)
def _ensure_membership(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
scope_type: str,
scope_id: str,
kind: str,
correlation_id: str,
) -> Membership:
for membership in self.store.memberships_for_user(user_id, tenant=tenant):
if (
membership.scope_type == scope_type
and membership.scope_id == scope_id
and membership.kind == kind
):
return membership
return self.add_membership(
actor,
user_id,
tenant=tenant,
scope_type=scope_type,
scope_id=scope_id,
kind=kind,
correlation_id=correlation_id,
)
def _apply_family_profile_defaults(
self,
actor: Actor,
user_id: str,
*,
tenant: str,
application_id: str,
catalog_namespace: str,
values: Mapping[str, Any],
correlation_id: str,
) -> None:
for key, value in values.items():
self.set_profile_value(
actor,
user_id,
_family_profile_key(catalog_namespace, key),
value,
scope=ProfileScope.APPLICATION,
scope_id=application_id,
tenant=tenant,
application_id=application_id,
correlation_id=correlation_id,
)
def _require_family_invitation(self, invitation_id: str) -> FamilyInvitation:
invitation = self.store.family_invitation(invitation_id)
if invitation is None:
raise NotFoundError("family invitation not found")
return invitation
def _identity_entity_refs(
self,
actor: Actor,
@@ -1406,11 +2105,39 @@ def _scope_concept(scope_type: str) -> str:
"team": "Team",
"tenant": "Tenant",
"application": "Scope",
"family": "Group",
"group": "Group",
}
return concepts.get(scope_type, "Scope")
def _family_dataspace_binding(
request: FamilyDataspaceRequest,
) -> ApplicationBinding:
event_source = request.event_source or request.application_id
return ApplicationBinding(
application_id=request.application_id,
oidc_client_id=request.oidc_client_id,
protected_system_id=request.protected_system_id,
catalog_namespaces=(request.catalog_namespace,),
event_source=event_source,
deployment_ref=request.deployment_ref,
)
def _family_profile_key(catalog_namespace: str, key: str) -> str:
if key.startswith(f"{catalog_namespace}."):
return key
return f"{catalog_namespace}.{key}"
def _family_role_value(role: FamilyRole | str) -> str:
try:
return FamilyRole(str(role)).value
except ValueError as exc:
raise ValidationError(f"unsupported family role: {role}") from exc
def _visible_in_projection(
definition: AttributeDefinition, projection_type: ProjectionType
) -> bool:

View File

@@ -26,6 +26,7 @@ SCENARIO_MATRIX = (
"two_applications",
"sensitive_redaction",
"audit_event_replay",
"family_dataspace_onboarding",
)