Implement typed effective identity relationships

This commit is contained in:
2026-08-02 14:44:42 +02:00
parent 820ea5eeab
commit 6f6c45f6e2
13 changed files with 1901 additions and 3 deletions
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import or_
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import (
IdentityRelationshipDecisionRef,
IdentityRelationshipRef,
IdmRelationshipDirectory,
TypedGroupMembershipResolutionRef,
TypedGroupRef,
)
from govoplan_core.db.session import get_database
from govoplan_core.security.time import ensure_aware_utc, utc_now
from govoplan_idm.backend.db.models import (
IdmIdentityRelationship,
IdmTypedGroup,
)
def typed_group_ref(item: IdmTypedGroup) -> TypedGroupRef:
return TypedGroupRef(
id=item.id,
tenant_id=item.tenant_id,
key=item.key,
name=item.name,
group_type=item.group_type,
description=item.description,
status=item.status, # type: ignore[arg-type]
source_provider=item.source_provider,
source_resource_type=item.source_resource_type,
source_resource_id=item.source_resource_id,
source_revision=item.source_revision,
properties=dict(item.properties),
provenance=dict(item.provenance),
revision=item.revision,
)
def identity_relationship_ref(
item: IdmIdentityRelationship,
) -> IdentityRelationshipRef:
return IdentityRelationshipRef(
id=item.id,
tenant_id=item.tenant_id,
relationship_kind=item.relationship_kind,
subject_identity_id=item.subject_identity_id,
target_group_id=item.target_group_id,
related_identity_id=item.related_identity_id,
role=item.role,
valid_from=item.valid_from,
valid_until=item.valid_until,
status=item.status, # type: ignore[arg-type]
revoked_at=item.revoked_at,
revoked_by=item.revoked_by,
revocation_reason=item.revocation_reason,
source_provider=item.source_provider,
source_resource_type=item.source_resource_type,
source_resource_id=item.source_resource_id,
source_revision=item.source_revision,
properties=dict(item.properties),
provenance=dict(item.provenance),
revision=item.revision,
)
class SqlIdmRelationshipDirectory(IdmRelationshipDirectory):
def __init__(self, *, identities: IdentityDirectory) -> None:
self._identities = identities
def get_typed_group(
self,
group_id: str,
*,
tenant_id: str | None = None,
) -> TypedGroupRef | None:
with get_database().session() as session:
item = session.get(IdmTypedGroup, group_id)
if item is None:
return None
if tenant_id is not None and item.tenant_id != tenant_id:
raise ValueError("Typed group belongs to another tenant.")
return typed_group_ref(item)
def list_typed_groups(
self,
*,
tenant_id: str,
query: str | None = None,
group_types: Sequence[str] = (),
include_inactive: bool = False,
limit: int = 100,
) -> tuple[TypedGroupRef, ...]:
if limit < 1 or limit > 1000:
raise ValueError("Typed-group limit must be between 1 and 1000.")
with get_database().session() as session:
statement = session.query(IdmTypedGroup).filter(
IdmTypedGroup.tenant_id == tenant_id
)
if not include_inactive:
statement = statement.filter(IdmTypedGroup.status == "active")
if group_types:
statement = statement.filter(
IdmTypedGroup.group_type.in_(tuple(dict.fromkeys(group_types)))
)
if query and query.strip():
pattern = f"%{query.strip()}%"
statement = statement.filter(
or_(
IdmTypedGroup.name.ilike(pattern),
IdmTypedGroup.key.ilike(pattern),
IdmTypedGroup.description.ilike(pattern),
)
)
items = (
statement.order_by(
IdmTypedGroup.name.asc(),
IdmTypedGroup.id.asc(),
)
.limit(limit)
.all()
)
return tuple(typed_group_ref(item) for item in items)
def identity_relationships_for_identity(
self,
identity_id: str,
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> tuple[IdentityRelationshipRef, ...]:
return tuple(
self.identity_relationships_for_identities(
(identity_id,),
tenant_id=tenant_id,
effective_at=effective_at,
relationship_kinds=relationship_kinds,
).get(identity_id, ())
)
def identity_relationships_for_identities(
self,
identity_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> dict[str, tuple[IdentityRelationshipRef, ...]]:
requested = tuple(dict.fromkeys(identity_ids))
result: dict[str, list[IdentityRelationshipRef]] = {
identity_id: [] for identity_id in requested
}
if not requested:
return {}
with get_database().session() as session:
items = self._effective_relationships(
session,
tenant_id=tenant_id,
effective_at=effective_at or utc_now(),
identity_ids=requested,
relationship_kinds=relationship_kinds,
)
for item in items:
result[item.subject_identity_id].append(
identity_relationship_ref(item)
)
return {key: tuple(value) for key, value in result.items()}
def identity_relationships_for_group(
self,
group_id: str,
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> tuple[IdentityRelationshipRef, ...]:
return tuple(
self.identity_relationships_for_groups(
(group_id,),
tenant_id=tenant_id,
effective_at=effective_at,
relationship_kinds=relationship_kinds,
).get(group_id, ())
)
def identity_relationships_for_groups(
self,
group_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = (),
) -> dict[str, tuple[IdentityRelationshipRef, ...]]:
requested = tuple(dict.fromkeys(group_ids))
result: dict[str, list[IdentityRelationshipRef]] = {
group_id: [] for group_id in requested
}
if not requested:
return {}
with get_database().session() as session:
self._validate_group_tenants(session, requested, tenant_id)
items = self._effective_relationships(
session,
tenant_id=tenant_id,
effective_at=effective_at or utc_now(),
group_ids=requested,
relationship_kinds=relationship_kinds,
)
for item in items:
if item.target_group_id is not None:
result[item.target_group_id].append(
identity_relationship_ref(item)
)
return {key: tuple(value) for key, value in result.items()}
def resolve_typed_group_memberships(
self,
group_ids: Sequence[str],
*,
tenant_id: str,
effective_at: datetime | None = None,
relationship_kinds: Sequence[str] = ("member",),
) -> dict[str, TypedGroupMembershipResolutionRef]:
requested = tuple(dict.fromkeys(group_ids))
if not requested:
return {}
moment = ensure_aware_utc(effective_at) or utc_now()
with get_database().session() as session:
groups = self._validate_group_tenants(session, requested, tenant_id)
items = (
session.query(IdmIdentityRelationship)
.filter(
IdmIdentityRelationship.tenant_id == tenant_id,
IdmIdentityRelationship.target_group_id.in_(requested),
)
.order_by(
IdmIdentityRelationship.created_at.asc(),
IdmIdentityRelationship.id.asc(),
)
)
if relationship_kinds:
items = items.filter(
IdmIdentityRelationship.relationship_kind.in_(
tuple(dict.fromkeys(relationship_kinds))
)
)
rows = items.all()
identities = {
identity_id: self._identities.get_identity(identity_id)
for identity_id in dict.fromkeys(
item.subject_identity_id for item in rows
)
}
decisions: dict[str, list[IdentityRelationshipDecisionRef]] = {
group_id: [] for group_id in requested
}
for item in rows:
group = groups[item.target_group_id or ""]
identity = identities[item.subject_identity_id]
included, code, explanation = _membership_decision(
item,
group=group,
identity_status=identity.status if identity is not None else None,
effective_at=moment,
)
decisions[group.id].append(
IdentityRelationshipDecisionRef(
relationship=identity_relationship_ref(item),
included=included,
code=code,
explanation=explanation,
identity_status=(
identity.status if identity is not None else None
),
)
)
return {
group_id: TypedGroupMembershipResolutionRef(
group=typed_group_ref(groups[group_id]),
effective_at=moment,
decisions=tuple(decisions[group_id]),
)
for group_id in requested
if group_id in groups
}
@staticmethod
def _validate_group_tenants(session, group_ids, tenant_id):
groups: dict[str, IdmTypedGroup] = {}
for group_id in group_ids:
item = session.get(IdmTypedGroup, group_id)
if item is None:
continue
if item.tenant_id != tenant_id:
raise ValueError("Typed group belongs to another tenant.")
groups[item.id] = item
return groups
@staticmethod
def _effective_relationships(
session,
*,
tenant_id: str,
effective_at: datetime,
identity_ids: Sequence[str] = (),
group_ids: Sequence[str] = (),
relationship_kinds: Sequence[str] = (),
) -> tuple[IdmIdentityRelationship, ...]:
query = session.query(IdmIdentityRelationship).filter(
IdmIdentityRelationship.tenant_id == tenant_id,
IdmIdentityRelationship.status == "active",
or_(
IdmIdentityRelationship.valid_from.is_(None),
IdmIdentityRelationship.valid_from <= effective_at,
),
or_(
IdmIdentityRelationship.valid_until.is_(None),
IdmIdentityRelationship.valid_until > effective_at,
),
)
if identity_ids:
query = query.filter(
IdmIdentityRelationship.subject_identity_id.in_(identity_ids)
)
if group_ids:
query = query.filter(
IdmIdentityRelationship.target_group_id.in_(group_ids)
)
if relationship_kinds:
query = query.filter(
IdmIdentityRelationship.relationship_kind.in_(
tuple(dict.fromkeys(relationship_kinds))
)
)
return tuple(
query.order_by(
IdmIdentityRelationship.created_at.asc(),
IdmIdentityRelationship.id.asc(),
).all()
)
def _membership_decision(
item: IdmIdentityRelationship,
*,
group: IdmTypedGroup,
identity_status: str | None,
effective_at: datetime,
) -> tuple[bool, str, str]:
if group.status != "active":
return False, "group.inactive", "The typed group is inactive."
if item.status == "revoked":
return False, "relationship.revoked", "The relationship was revoked."
if item.status != "active":
return False, "relationship.inactive", "The relationship is not active."
valid_from = ensure_aware_utc(item.valid_from)
if valid_from is not None and valid_from > effective_at:
return (
False,
"relationship.not_yet_effective",
"The relationship is not effective yet.",
)
valid_until = ensure_aware_utc(item.valid_until)
if valid_until is not None and valid_until <= effective_at:
return False, "relationship.expired", "The relationship has expired."
if identity_status is None:
return False, "identity.missing", "The related identity no longer exists."
if identity_status != "active":
return (
False,
"identity.not_active",
f"The related identity lifecycle status is {identity_status}.",
)
return True, "relationship.effective", "The relationship is effective."
__all__ = [
"SqlIdmRelationshipDirectory",
"identity_relationship_ref",
"typed_group_ref",
]