387 lines
14 KiB
Python
387 lines
14 KiB
Python
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",
|
|
]
|