570 lines
19 KiB
Python
570 lines
19 KiB
Python
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"]
|