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
@@ -0,0 +1,569 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, require_any_scope
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
IdentityDirectory,
)
from govoplan_core.core.idm import (
CAPABILITY_IDM_RELATIONSHIPS,
IdentityRelationshipDecisionRef,
IdentityRelationshipRef,
IdmRelationshipDirectory,
TypedGroupRef,
)
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session
from govoplan_core.security.time import ensure_aware_utc, utc_now
from govoplan_idm.backend.db.models import (
IdmIdentityRelationship,
IdmTypedGroup,
)
from .schemas import (
IdentityRelationshipCreateRequest,
IdentityRelationshipDecisionItem,
IdentityRelationshipItem,
IdentityRelationshipList,
IdentityRelationshipRevokeRequest,
IdentityRelationshipUpdateRequest,
TypedGroupCreateRequest,
TypedGroupItem,
TypedGroupList,
TypedGroupMembershipResolutionItem,
TypedGroupUpdateRequest,
)
router = APIRouter()
RELATIONSHIP_READ_SCOPES = (
"idm:relationship:read",
"idm:relationship:write",
)
RELATIONSHIP_WRITE_SCOPES = ("idm:relationship:write",)
def _not_found(label: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{label} not found",
)
def _invalid(message: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=message,
)
def _conflict(message: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message)
def _typed_group_item(item: IdmTypedGroup) -> TypedGroupItem:
return TypedGroupItem.model_validate(
{column.name: getattr(item, column.name) for column in item.__table__.columns}
)
def _relationship_item(item: IdmIdentityRelationship) -> IdentityRelationshipItem:
return IdentityRelationshipItem.model_validate(
{column.name: getattr(item, column.name) for column in item.__table__.columns}
)
def _typed_group_ref_item(item: TypedGroupRef) -> TypedGroupItem:
return TypedGroupItem(
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,
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 _relationship_ref_item(
item: IdentityRelationshipRef,
) -> IdentityRelationshipItem:
return IdentityRelationshipItem(
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,
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,
)
def _decision_item(
item: IdentityRelationshipDecisionRef,
) -> IdentityRelationshipDecisionItem:
return IdentityRelationshipDecisionItem(
relationship=_relationship_ref_item(item.relationship),
included=item.included,
code=item.code,
explanation=item.explanation,
identity_status=item.identity_status,
)
def _tenant_row(session: Session, model, item_id: str, tenant_id: str, label: str):
item = session.get(model, item_id)
if item is None or item.tenant_id != tenant_id:
raise _not_found(label)
return item
def _identity_directory() -> IdentityDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Identity directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}",
)
return capability
def _relationship_directory() -> IdmRelationshipDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDM_RELATIONSHIPS):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="IDM relationship directory is unavailable",
)
capability = registry.require_capability(CAPABILITY_IDM_RELATIONSHIPS)
if not isinstance(capability, IdmRelationshipDirectory):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid capability: {CAPABILITY_IDM_RELATIONSHIPS}",
)
return capability
def _ensure_identity(identity_id: str, *, label: str = "Identity") -> None:
if _identity_directory().get_identity(identity_id) is None:
raise _not_found(label)
def _ensure_target_shape(
target_group_id: str | None,
related_identity_id: str | None,
) -> None:
if (target_group_id is None) == (related_identity_id is None):
raise _invalid(
"A relationship must target exactly one typed group or related identity."
)
def _ensure_window(valid_from: datetime | None, valid_until: datetime | None) -> None:
start = ensure_aware_utc(valid_from)
end = ensure_aware_utc(valid_until)
if start is not None and end is not None and end <= start:
raise _invalid("Relationship end must be after its start.")
def _emit_change(
session: Session,
principal: ApiPrincipal,
*,
event_type: str,
resource_type: str,
resource_id: str,
payload: dict[str, object],
subject_identity_id: str | None = None,
) -> None:
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id="idm",
payload=payload,
actor=EventActorRef(type="account", id=principal.account_id),
tenant=EventTenantRef(id=principal.tenant_id),
subject=(
EventObjectRef(type="identity", id=subject_identity_id)
if subject_identity_id is not None
else None
),
resource=EventObjectRef(type=resource_type, id=resource_id),
classification="internal",
),
)
def _commit(session: Session, principal: ApiPrincipal, item, *, resource_type: str):
invalidate_auth_principals(
session,
tenant_id=principal.tenant_id,
source_module="idm",
resource_type=resource_type,
resource_id=item.id,
)
try:
session.commit()
except IntegrityError as exc:
session.rollback()
raise _conflict("The IDM relationship conflicts with existing data.") from exc
session.refresh(item)
return item
@router.get("/typed-groups", response_model=TypedGroupList)
def list_typed_groups(
query: str | None = Query(default=None, max_length=255),
group_type: str | None = Query(default=None, max_length=80),
include_inactive: bool = False,
limit: int = Query(default=100, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
) -> TypedGroupList:
statement = session.query(IdmTypedGroup).filter(
IdmTypedGroup.tenant_id == principal.tenant_id
)
if not include_inactive:
statement = statement.filter(IdmTypedGroup.status == "active")
if group_type:
statement = statement.filter(IdmTypedGroup.group_type == group_type)
if query:
statement = statement.filter(IdmTypedGroup.name.ilike(f"%{query.strip()}%"))
total = statement.count()
rows = (
statement.order_by(IdmTypedGroup.name.asc(), IdmTypedGroup.id.asc())
.limit(limit)
.all()
)
return TypedGroupList(groups=[_typed_group_item(item) for item in rows], total=total)
@router.post(
"/typed-groups",
response_model=TypedGroupItem,
status_code=status.HTTP_201_CREATED,
)
def create_typed_group(
payload: TypedGroupCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
) -> TypedGroupItem:
item = IdmTypedGroup(
tenant_id=principal.tenant_id,
**payload.model_dump(),
status="active",
revision=1,
)
session.add(item)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise _conflict("A typed group with this type and key already exists.") from exc
_emit_change(
session,
principal,
event_type="idm.typed_group.created.v1",
resource_type="typed_group",
resource_id=item.id,
payload={"group_type": item.group_type, "key": item.key, "revision": 1},
)
return _typed_group_item(
_commit(session, principal, item, resource_type="typed_group")
)
@router.patch("/typed-groups/{group_id}", response_model=TypedGroupItem)
def update_typed_group(
group_id: str,
payload: TypedGroupUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
) -> TypedGroupItem:
item = _tenant_row(
session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group"
)
if item.revision != payload.base_revision:
raise _conflict("The typed group changed since it was loaded.")
values = payload.model_dump(exclude_unset=True, exclude={"base_revision"})
for key, value in values.items():
setattr(item, key, value)
item.revision += 1
session.flush()
_emit_change(
session,
principal,
event_type="idm.typed_group.changed.v1",
resource_type="typed_group",
resource_id=item.id,
payload={"group_type": item.group_type, "key": item.key, "revision": item.revision},
)
return _typed_group_item(
_commit(session, principal, item, resource_type="typed_group")
)
@router.get("/relationships", response_model=IdentityRelationshipList)
def list_identity_relationships(
identity_id: str | None = Query(default=None, max_length=36),
group_id: str | None = Query(default=None, max_length=36),
relationship_kind: str | None = Query(default=None, max_length=80),
include_revoked: bool = False,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
) -> IdentityRelationshipList:
statement = session.query(IdmIdentityRelationship).filter(
IdmIdentityRelationship.tenant_id == principal.tenant_id
)
if identity_id:
statement = statement.filter(
IdmIdentityRelationship.subject_identity_id == identity_id
)
if group_id:
statement = statement.filter(IdmIdentityRelationship.target_group_id == group_id)
if relationship_kind:
statement = statement.filter(
IdmIdentityRelationship.relationship_kind == relationship_kind
)
if not include_revoked:
statement = statement.filter(IdmIdentityRelationship.status == "active")
total = statement.count()
rows = (
statement.order_by(
IdmIdentityRelationship.created_at.asc(),
IdmIdentityRelationship.id.asc(),
)
.limit(limit)
.all()
)
return IdentityRelationshipList(
relationships=[_relationship_item(item) for item in rows], total=total
)
@router.post(
"/relationships",
response_model=IdentityRelationshipItem,
status_code=status.HTTP_201_CREATED,
)
def create_identity_relationship(
payload: IdentityRelationshipCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
) -> IdentityRelationshipItem:
_ensure_target_shape(payload.target_group_id, payload.related_identity_id)
_ensure_window(payload.valid_from, payload.valid_until)
_ensure_identity(payload.subject_identity_id)
if payload.related_identity_id:
_ensure_identity(payload.related_identity_id, label="Related identity")
if payload.target_group_id:
_tenant_row(
session,
IdmTypedGroup,
payload.target_group_id,
principal.tenant_id,
"Typed group",
)
item = IdmIdentityRelationship(
tenant_id=principal.tenant_id,
**payload.model_dump(),
status="active",
revision=1,
)
session.add(item)
session.flush()
_emit_change(
session,
principal,
event_type="idm.relationship.created.v1",
resource_type="identity_relationship",
resource_id=item.id,
subject_identity_id=item.subject_identity_id,
payload={
"relationship_kind": item.relationship_kind,
"target_group_id": item.target_group_id,
"related_identity_id": item.related_identity_id,
"revision": 1,
},
)
return _relationship_item(
_commit(session, principal, item, resource_type="identity_relationship")
)
@router.patch("/relationships/{relationship_id}", response_model=IdentityRelationshipItem)
def update_identity_relationship(
relationship_id: str,
payload: IdentityRelationshipUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
) -> IdentityRelationshipItem:
item = _tenant_row(
session,
IdmIdentityRelationship,
relationship_id,
principal.tenant_id,
"Identity relationship",
)
if item.status == "revoked":
raise _conflict("A revoked relationship cannot be changed.")
if item.revision != payload.base_revision:
raise _conflict("The identity relationship changed since it was loaded.")
values = payload.model_dump(exclude_unset=True, exclude={"base_revision"})
if values.get("target_group_id") is not None:
values["related_identity_id"] = None
if values.get("related_identity_id") is not None:
values["target_group_id"] = None
target_group_id = values.get("target_group_id", item.target_group_id)
related_identity_id = values.get("related_identity_id", item.related_identity_id)
_ensure_target_shape(target_group_id, related_identity_id)
valid_from = values.get("valid_from", item.valid_from)
valid_until = values.get("valid_until", item.valid_until)
_ensure_window(valid_from, valid_until)
if target_group_id:
_tenant_row(
session,
IdmTypedGroup,
target_group_id,
principal.tenant_id,
"Typed group",
)
if related_identity_id:
_ensure_identity(related_identity_id, label="Related identity")
for key, value in values.items():
setattr(item, key, value)
item.revision += 1
if item.valid_until is None or ensure_aware_utc(item.valid_until) > utc_now():
item.expired_event_at = None
session.flush()
_emit_change(
session,
principal,
event_type="idm.relationship.changed.v1",
resource_type="identity_relationship",
resource_id=item.id,
subject_identity_id=item.subject_identity_id,
payload={"relationship_kind": item.relationship_kind, "revision": item.revision},
)
return _relationship_item(
_commit(session, principal, item, resource_type="identity_relationship")
)
@router.post(
"/relationships/{relationship_id}/revoke",
response_model=IdentityRelationshipItem,
)
def revoke_identity_relationship(
relationship_id: str,
payload: IdentityRelationshipRevokeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_WRITE_SCOPES)),
) -> IdentityRelationshipItem:
item = _tenant_row(
session,
IdmIdentityRelationship,
relationship_id,
principal.tenant_id,
"Identity relationship",
)
if item.revision != payload.base_revision:
raise _conflict("The identity relationship changed since it was loaded.")
if item.status == "revoked":
return _relationship_item(item)
item.status = "revoked"
item.revoked_at = utc_now()
item.revoked_by = principal.account_id
item.revocation_reason = payload.reason
item.revision += 1
session.flush()
_emit_change(
session,
principal,
event_type="idm.relationship.revoked.v1",
resource_type="identity_relationship",
resource_id=item.id,
subject_identity_id=item.subject_identity_id,
payload={
"relationship_kind": item.relationship_kind,
"reason": payload.reason,
"revision": item.revision,
},
)
return _relationship_item(
_commit(session, principal, item, resource_type="identity_relationship")
)
@router.get(
"/typed-groups/{group_id}/memberships",
response_model=TypedGroupMembershipResolutionItem,
)
def resolve_typed_group_memberships(
group_id: str,
effective_at: datetime | None = None,
relationship_kind: list[str] = Query(default=["member"]),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope(*RELATIONSHIP_READ_SCOPES)),
) -> TypedGroupMembershipResolutionItem:
_tenant_row(
session, IdmTypedGroup, group_id, principal.tenant_id, "Typed group"
)
resolved = _relationship_directory().resolve_typed_group_memberships(
(group_id,),
tenant_id=principal.tenant_id,
effective_at=effective_at,
relationship_kinds=tuple(relationship_kind),
)[group_id]
return TypedGroupMembershipResolutionItem(
group=_typed_group_ref_item(resolved.group),
effective_at=resolved.effective_at,
decisions=[_decision_item(item) for item in resolved.decisions],
identity_ids=list(resolved.identity_ids),
)
__all__ = ["router"]
@@ -759,6 +759,8 @@ def _identity_candidate(identity: IdentityRef) -> OrganizationIdentityCandidate:
from .function_changes import router as function_changes_router # noqa: E402
from .relationships import router as relationships_router # noqa: E402
router.include_router(function_changes_router)
router.include_router(relationships_router)
+135
View File
@@ -107,6 +107,141 @@ class IdmSettingsUpdateRequest(BaseModel):
settings: dict[str, Any] | None = None
class TypedGroupItem(BaseModel):
id: str
tenant_id: str
key: str
name: str
group_type: str
description: str | None = None
status: Literal["active", "inactive"]
source_provider: str
source_resource_type: str | None = None
source_resource_id: str | None = None
source_revision: str | None = None
properties: dict[str, Any]
provenance: dict[str, Any]
revision: int
created_at: datetime | None = None
updated_at: datetime | None = None
class TypedGroupList(BaseModel):
groups: list[TypedGroupItem]
total: int
class TypedGroupCreateRequest(BaseModel):
key: str = Field(min_length=1, max_length=120)
name: str = Field(min_length=1, max_length=255)
group_type: str = Field(min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=8_000)
source_provider: str = Field(default="local", min_length=1, max_length=80)
source_resource_type: str | None = Field(default=None, max_length=120)
source_resource_id: str | None = Field(default=None, max_length=255)
source_revision: str | None = Field(default=None, max_length=255)
properties: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class TypedGroupUpdateRequest(BaseModel):
base_revision: int = Field(ge=1)
key: str | None = Field(default=None, min_length=1, max_length=120)
name: str | None = Field(default=None, min_length=1, max_length=255)
group_type: str | None = Field(default=None, min_length=1, max_length=80)
description: str | None = Field(default=None, max_length=8_000)
status: Literal["active", "inactive"] | None = None
source_provider: str | None = Field(default=None, min_length=1, max_length=80)
source_resource_type: str | None = Field(default=None, max_length=120)
source_resource_id: str | None = Field(default=None, max_length=255)
source_revision: str | None = Field(default=None, max_length=255)
properties: dict[str, Any] | None = None
provenance: dict[str, Any] | None = None
class IdentityRelationshipItem(BaseModel):
id: str
tenant_id: str
relationship_kind: str
subject_identity_id: str
target_group_id: str | None = None
related_identity_id: str | None = None
role: str | None = None
valid_from: datetime | None = None
valid_until: datetime | None = None
status: Literal["active", "revoked"]
revoked_at: datetime | None = None
revoked_by: str | None = None
revocation_reason: str | None = None
expired_event_at: datetime | None = None
source_provider: str
source_resource_type: str | None = None
source_resource_id: str | None = None
source_revision: str | None = None
properties: dict[str, Any]
provenance: dict[str, Any]
revision: int
created_at: datetime | None = None
updated_at: datetime | None = None
class IdentityRelationshipList(BaseModel):
relationships: list[IdentityRelationshipItem]
total: int
class IdentityRelationshipCreateRequest(BaseModel):
relationship_kind: str = Field(min_length=1, max_length=80)
subject_identity_id: str = Field(min_length=1, max_length=36)
target_group_id: str | None = Field(default=None, max_length=36)
related_identity_id: str | None = Field(default=None, max_length=36)
role: str | None = Field(default=None, max_length=120)
valid_from: datetime | None = None
valid_until: datetime | None = None
source_provider: str = Field(default="local", min_length=1, max_length=80)
source_resource_type: str | None = Field(default=None, max_length=120)
source_resource_id: str | None = Field(default=None, max_length=255)
source_revision: str | None = Field(default=None, max_length=255)
properties: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class IdentityRelationshipUpdateRequest(BaseModel):
base_revision: int = Field(ge=1)
relationship_kind: str | None = Field(default=None, min_length=1, max_length=80)
target_group_id: str | None = Field(default=None, max_length=36)
related_identity_id: str | None = Field(default=None, max_length=36)
role: str | None = Field(default=None, max_length=120)
valid_from: datetime | None = None
valid_until: datetime | None = None
source_provider: str | None = Field(default=None, min_length=1, max_length=80)
source_resource_type: str | None = Field(default=None, max_length=120)
source_resource_id: str | None = Field(default=None, max_length=255)
source_revision: str | None = Field(default=None, max_length=255)
properties: dict[str, Any] | None = None
provenance: dict[str, Any] | None = None
class IdentityRelationshipRevokeRequest(BaseModel):
base_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=8_000)
class IdentityRelationshipDecisionItem(BaseModel):
relationship: IdentityRelationshipItem
included: bool
code: str
explanation: str
identity_status: str | None = None
class TypedGroupMembershipResolutionItem(BaseModel):
group: TypedGroupItem
effective_at: datetime
decisions: list[IdentityRelationshipDecisionItem]
identity_ids: list[str]
FunctionAssignmentChangeKind = Literal["request", "grant"]
FunctionAssignmentChangeAction = Literal[
"approve",
@@ -22,6 +22,7 @@ from govoplan_idm.backend.assignment_events import emit_assignment_event
from govoplan_idm.backend.db.models import (
IdmFunctionAssignmentChange,
IdmFunctionAssignmentChangeEvent,
IdmIdentityRelationship,
IdmOrganizationFunctionAssignment,
)
from govoplan_idm.backend.function_assignment_changes import OPEN_STATES
@@ -109,14 +110,102 @@ class SqlIdmAssignmentLifecycle:
effective_at=now,
limit=limit,
)
expired_relationship_ids = self._expire_relationships(
session,
tenant_id=tenant_id,
effective_at=now,
limit=limit,
)
return {
"selected": len(candidates),
"expired": len(expired_ids),
"assignment_ids": expired_ids,
"expired_changes": len(expired_change_ids),
"change_ids": expired_change_ids,
"expired_relationships": len(expired_relationship_ids),
"relationship_ids": expired_relationship_ids,
}
@staticmethod
def _expire_relationships(
session: Session,
*,
tenant_id: str | None,
effective_at: datetime,
limit: int,
) -> list[str]:
query = session.query(IdmIdentityRelationship).filter(
IdmIdentityRelationship.status == "active",
IdmIdentityRelationship.valid_until.is_not(None),
IdmIdentityRelationship.valid_until <= effective_at,
IdmIdentityRelationship.expired_event_at.is_(None),
)
if tenant_id is not None:
query = query.filter(IdmIdentityRelationship.tenant_id == tenant_id)
candidates = (
query.order_by(
IdmIdentityRelationship.valid_until.asc(),
IdmIdentityRelationship.id.asc(),
)
.limit(limit)
.all()
)
expired_ids: list[str] = []
for item in candidates:
claimed = (
session.query(IdmIdentityRelationship)
.filter(
IdmIdentityRelationship.id == item.id,
IdmIdentityRelationship.status == "active",
IdmIdentityRelationship.valid_until.is_not(None),
IdmIdentityRelationship.valid_until <= effective_at,
IdmIdentityRelationship.expired_event_at.is_(None),
)
.update(
{
IdmIdentityRelationship.expired_event_at: effective_at,
IdmIdentityRelationship.revision: (
IdmIdentityRelationship.revision + 1
),
},
synchronize_session=False,
)
)
if claimed != 1:
continue
session.refresh(item)
emit_platform_event(
session,
PlatformEvent(
type="idm.relationship.expired.v1",
module_id="idm",
payload={
"relationship_kind": item.relationship_kind,
"target_group_id": item.target_group_id,
"related_identity_id": item.related_identity_id,
"revision": item.revision,
},
actor=EventActorRef(type="system"),
tenant=EventTenantRef(id=item.tenant_id),
subject=EventObjectRef(
type="identity", id=item.subject_identity_id
),
resource=EventObjectRef(
type="identity_relationship", id=item.id
),
classification="internal",
),
)
invalidate_auth_principals(
session,
tenant_id=item.tenant_id,
source_module="idm",
resource_type="identity_relationship_expiry",
resource_id=item.id,
)
expired_ids.append(item.id)
return expired_ids
def _expire_open_changes(
self,
session: Session,
+115
View File
@@ -6,6 +6,7 @@ from typing import Any
from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Index,
@@ -60,6 +61,118 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class IdmTypedGroup(Base, TimestampMixin):
__tablename__ = "idm_typed_groups"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"group_type",
"key",
name="uq_idm_typed_groups_tenant_type_key",
),
Index(
"ix_idm_typed_groups_tenant_status_name",
"tenant_id",
"status",
"name",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
key: Mapped[str] = mapped_column(String(120), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
group_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False)
source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
@property
def strong_etag(self) -> str:
return strong_resource_etag("idm_typed_group", self.id, self.revision)
class IdmIdentityRelationship(Base, TimestampMixin):
__tablename__ = "idm_identity_relationships"
__table_args__ = (
CheckConstraint(
"((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR "
"(target_group_id IS NULL AND related_identity_id IS NOT NULL))",
name="ck_idm_relationship_exactly_one_target",
),
CheckConstraint(
"valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from",
name="ck_idm_relationship_valid_window",
),
Index(
"ix_idm_relationships_tenant_subject_effective",
"tenant_id",
"subject_identity_id",
"status",
"valid_from",
"valid_until",
),
Index(
"ix_idm_relationships_tenant_group_effective",
"tenant_id",
"target_group_id",
"status",
"valid_from",
"valid_until",
),
Index(
"ix_idm_relationships_expiry_due",
"status",
"expired_event_at",
"valid_until",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
relationship_kind: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
subject_identity_id: Mapped[str] = mapped_column(
ForeignKey("identity_identities.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
target_group_id: Mapped[str | None] = mapped_column(
ForeignKey("idm_typed_groups.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
related_identity_id: Mapped[str | None] = mapped_column(
ForeignKey("identity_identities.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
role: Mapped[str | None] = mapped_column(String(120), nullable=True)
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
revoked_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
revocation_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
expired_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_provider: Mapped[str] = mapped_column(String(80), default="local", nullable=False)
source_resource_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
source_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
source_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
properties: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
@property
def strong_etag(self) -> str:
return strong_resource_etag("idm_identity_relationship", self.id, self.revision)
class IdmTenantSettings(Base, TimestampMixin):
__tablename__ = "idm_tenant_settings"
@@ -243,7 +356,9 @@ class IdmFunctionAssignmentChangeEvent(Base):
__all__ = [
"IdmFunctionAssignmentChange",
"IdmFunctionAssignmentChangeEvent",
"IdmIdentityRelationship",
"IdmOrganizationFunctionAssignment",
"IdmTenantSettings",
"IdmTypedGroup",
"new_uuid",
]
+50 -3
View File
@@ -15,6 +15,7 @@ from govoplan_core.core.idm import (
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_IDM_RELATIONSHIPS,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.policy import (
@@ -58,6 +59,8 @@ IDM_READ_SCOPES = (
"idm:function_grant:create",
"idm:function_change:decide",
"idm:function_change:admin",
"idm:relationship:read",
"idm:relationship:write",
"organizations:function:assign",
)
@@ -127,6 +130,16 @@ PERMISSIONS = (
"Recover function assignment changes",
"Inspect and recover blocked or failed function assignment workflows.",
),
_permission(
"idm:relationship:read",
"View typed identity relationships",
"View typed groups and effective-dated identity relationships.",
),
_permission(
"idm:relationship:write",
"Manage typed identity relationships",
"Create, change, revoke, and synchronize typed groups and identity relationships.",
),
)
ROLE_TEMPLATES = (
@@ -145,6 +158,8 @@ ROLE_TEMPLATES = (
"idm:function_grant:create",
"idm:function_change:decide",
"idm:function_change:admin",
"idm:relationship:read",
"idm:relationship:write",
),
),
RoleTemplate(
@@ -187,6 +202,15 @@ def _assignment_lifecycle(context: ModuleContext) -> object:
return SqlIdmAssignmentLifecycle(registry=context.registry)
def _relationship_directory(context: ModuleContext) -> object:
from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory
identities = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(identities, IdentityDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
return SqlIdmRelationshipDirectory(identities=identities)
manifest = ModuleManifest(
id="idm",
name="IDM",
@@ -220,6 +244,10 @@ manifest = ModuleManifest(
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
version=MODULE_VERSION,
),
ModuleInterfaceProvider(
name=CAPABILITY_IDM_RELATIONSHIPS,
version="1.0.0",
),
ModuleInterfaceProvider(
name="idm.function_assignment_changes",
version="1.0.0",
@@ -255,6 +283,8 @@ manifest = ModuleManifest(
idm_models.IdmTenantSettings,
idm_models.IdmFunctionAssignmentChange,
idm_models.IdmFunctionAssignmentChangeEvent,
idm_models.IdmTypedGroup,
idm_models.IdmIdentityRelationship,
label="IDM",
),
),
@@ -262,6 +292,7 @@ manifest = ModuleManifest(
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
CAPABILITY_IDM_DIRECTORY: _idm_directory,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
},
workflow_definitions=function_assignment_workflow_definitions(
module_version=MODULE_VERSION,
@@ -298,6 +329,22 @@ manifest = ModuleManifest(
related_modules=("identity", "organizations", "access", "audit", "policy"),
order=27,
),
DocumentationTopic(
id="idm.reference.typed-relationships",
title="Typed groups and effective relationships",
summary="IDM keeps business group membership separate from identity lifecycle status.",
body=(
"Typed groups and identity relationships are tenant-scoped, effective-dated facts. "
"Current, future, expired, and revoked links remain explainable, including external "
"directory source revisions and provenance. Consumers such as Distribution Lists use "
"the IDM relationship capability and never infer application permissions from membership."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("tenant_admin", "operator", "module_admin"),
related_modules=("identity", "organizations", "dist_lists"),
order=28,
),
DocumentationTopic(
id="idm.workflow.assign-function-to-identity",
title="Assign an organization function to an identity",
@@ -322,10 +369,10 @@ manifest = ModuleManifest(
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
test_ref="tests/test_assignment_workflow.py",
known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",),
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request"),
owned_concepts=("function assignment", "assignment delegation", "acting-for assignment", "assignment request", "typed group", "identity relationship"),
non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"),
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",),
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",),
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
operations_docs=("README.md",),
),
)
@@ -0,0 +1,157 @@
"""Add typed IDM groups and effective-dated relationships.
Revision ID: b1c2d3e4f5a6
Revises: a0b1c2d3e4f5
Create Date: 2026-08-02 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b1c2d3e4f5a6"
down_revision = "a0b1c2d3e4f5"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"idm_typed_groups",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("key", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("group_type", sa.String(length=80), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("source_provider", sa.String(length=80), nullable=False),
sa.Column("source_resource_type", sa.String(length=120), nullable=True),
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
sa.Column("source_revision", sa.String(length=255), nullable=True),
sa.Column("properties", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_typed_groups")),
sa.UniqueConstraint(
"tenant_id",
"group_type",
"key",
name="uq_idm_typed_groups_tenant_type_key",
),
)
op.create_index(
op.f("ix_idm_typed_groups_tenant_id"),
"idm_typed_groups",
["tenant_id"],
unique=False,
)
op.create_index(
op.f("ix_idm_typed_groups_group_type"),
"idm_typed_groups",
["group_type"],
unique=False,
)
op.create_index(
"ix_idm_typed_groups_tenant_status_name",
"idm_typed_groups",
["tenant_id", "status", "name"],
unique=False,
)
op.create_table(
"idm_identity_relationships",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("relationship_kind", sa.String(length=80), nullable=False),
sa.Column("subject_identity_id", sa.String(length=36), nullable=False),
sa.Column("target_group_id", sa.String(length=36), nullable=True),
sa.Column("related_identity_id", sa.String(length=36), nullable=True),
sa.Column("role", sa.String(length=120), nullable=True),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_by", sa.String(length=36), nullable=True),
sa.Column("revocation_reason", sa.Text(), nullable=True),
sa.Column("expired_event_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_provider", sa.String(length=80), nullable=False),
sa.Column("source_resource_type", sa.String(length=120), nullable=True),
sa.Column("source_resource_id", sa.String(length=255), nullable=True),
sa.Column("source_revision", sa.String(length=255), nullable=True),
sa.Column("properties", sa.JSON(), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint(
"((target_group_id IS NOT NULL AND related_identity_id IS NULL) OR "
"(target_group_id IS NULL AND related_identity_id IS NOT NULL))",
name="ck_idm_relationship_exactly_one_target",
),
sa.CheckConstraint(
"valid_until IS NULL OR valid_from IS NULL OR valid_until > valid_from",
name="ck_idm_relationship_valid_window",
),
sa.ForeignKeyConstraint(
["subject_identity_id"],
["identity_identities.id"],
name=op.f(
"fk_idm_identity_relationships_subject_identity_id_identity_identities"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["related_identity_id"],
["identity_identities.id"],
name=op.f(
"fk_idm_identity_relationships_related_identity_id_identity_identities"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["target_group_id"],
["idm_typed_groups.id"],
name=op.f(
"fk_idm_identity_relationships_target_group_id_idm_typed_groups"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id", name=op.f("pk_idm_identity_relationships")
),
)
for name, columns in (
("ix_idm_identity_relationships_tenant_id", ["tenant_id"]),
("ix_idm_identity_relationships_relationship_kind", ["relationship_kind"]),
("ix_idm_identity_relationships_subject_identity_id", ["subject_identity_id"]),
("ix_idm_identity_relationships_target_group_id", ["target_group_id"]),
("ix_idm_identity_relationships_related_identity_id", ["related_identity_id"]),
(
"ix_idm_relationships_tenant_subject_effective",
["tenant_id", "subject_identity_id", "status", "valid_from", "valid_until"],
),
(
"ix_idm_relationships_tenant_group_effective",
["tenant_id", "target_group_id", "status", "valid_from", "valid_until"],
),
(
"ix_idm_relationships_expiry_due",
["status", "expired_event_at", "valid_until"],
),
):
op.create_index(
name,
"idm_identity_relationships",
columns,
unique=False,
)
def downgrade() -> None:
op.drop_table("idm_identity_relationships")
op.drop_table("idm_typed_groups")
+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",
]