Implement typed effective identity relationships
This commit is contained in:
@@ -20,6 +20,7 @@ vendor or protocol.
|
|||||||
- inbound synchronization from external identity-management systems
|
- inbound synchronization from external identity-management systems
|
||||||
- identity lifecycle import, update, disable, and reconciliation jobs
|
- identity lifecycle import, update, disable, and reconciliation jobs
|
||||||
- identity-to-organization-function assignment links inside GovOPlaN
|
- identity-to-organization-function assignment links inside GovOPlaN
|
||||||
|
- typed business groups and effective-dated identity relationship links
|
||||||
- bridge views that combine identity and organization facts, such as identity
|
- bridge views that combine identity and organization facts, such as identity
|
||||||
candidates for organization function assignments
|
candidates for organization function assignments
|
||||||
- mapping external identities, accounts, groups, organizational units,
|
- mapping external identities, accounts, groups, organizational units,
|
||||||
@@ -73,6 +74,12 @@ assignment links:
|
|||||||
- `GET /api/v1/idm/organization-function-assignments`
|
- `GET /api/v1/idm/organization-function-assignments`
|
||||||
- `POST /api/v1/idm/organization-function-assignments`
|
- `POST /api/v1/idm/organization-function-assignments`
|
||||||
- `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}`
|
- `PATCH /api/v1/idm/organization-function-assignments/{assignment_id}`
|
||||||
|
- `GET|POST /api/v1/idm/typed-groups`
|
||||||
|
- `PATCH /api/v1/idm/typed-groups/{group_id}`
|
||||||
|
- `GET|POST /api/v1/idm/relationships`
|
||||||
|
- `PATCH /api/v1/idm/relationships/{relationship_id}`
|
||||||
|
- `POST /api/v1/idm/relationships/{relationship_id}/revoke`
|
||||||
|
- `GET /api/v1/idm/typed-groups/{group_id}/memberships`
|
||||||
|
|
||||||
The candidate endpoint returns searchable identity/account candidates for IDM
|
The candidate endpoint returns searchable identity/account candidates for IDM
|
||||||
assignment forms. Assignment writes validate the identity/account link through
|
assignment forms. Assignment writes validate the identity/account link through
|
||||||
@@ -139,6 +146,10 @@ shared `govoplan.idm.expire_assignments` task also emits the corresponding
|
|||||||
`idm.function_assignment.expired.v1` event. IDM records the event marker in the
|
`idm.function_assignment.expired.v1` event. IDM records the event marker in the
|
||||||
same database transaction, making repeated sweeps idempotent.
|
same database transaction, making repeated sweeps idempotent.
|
||||||
|
|
||||||
|
Typed group and relationship behavior, including effective-time resolution and
|
||||||
|
the provider-neutral capability consumed by Distribution Lists, is documented
|
||||||
|
in [Typed groups and effective-dated relationships](docs/TYPED_RELATIONSHIPS.md).
|
||||||
|
|
||||||
## First Milestone
|
## First Milestone
|
||||||
|
|
||||||
The first useful milestone is a read-only synchronization preview:
|
The first useful milestone is a read-only synchronization preview:
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Typed groups and effective-dated relationships
|
||||||
|
|
||||||
|
IDM owns tenant-scoped business group facts and the effective-dated links from
|
||||||
|
identities to those groups. Identity lifecycle state remains owned by Identity;
|
||||||
|
an active group membership never activates an identity and never grants an
|
||||||
|
application permission.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
The `idm.relationships` capability exposes immutable Core DTOs. Consumers can:
|
||||||
|
|
||||||
|
- list typed groups without importing IDM persistence models;
|
||||||
|
- resolve one or many identities to their current relationships;
|
||||||
|
- resolve one or many groups to current identities at a caller-supplied time;
|
||||||
|
- request a membership decision set that includes future, expired, revoked,
|
||||||
|
inactive-group, and inactive-identity exclusions;
|
||||||
|
- retain source provider, external resource, revision, typed properties, and
|
||||||
|
provenance data in downstream evidence.
|
||||||
|
|
||||||
|
Cross-tenant group references are rejected. The contract carries facts only and
|
||||||
|
does not imply a right, role, or permission.
|
||||||
|
|
||||||
|
## Persistence and lifecycle
|
||||||
|
|
||||||
|
`idm_typed_groups` stores the stable tenant/type/key identity and external source
|
||||||
|
reference. `idm_identity_relationships` stores one identity-to-group or
|
||||||
|
identity-to-identity link, its relationship kind, validity window, source,
|
||||||
|
properties, provenance, and optimistic revision.
|
||||||
|
|
||||||
|
Create, change, revoke, and elapsed-validity transitions emit versioned platform
|
||||||
|
events:
|
||||||
|
|
||||||
|
- `idm.typed_group.created.v1`
|
||||||
|
- `idm.typed_group.changed.v1`
|
||||||
|
- `idm.relationship.created.v1`
|
||||||
|
- `idm.relationship.changed.v1`
|
||||||
|
- `idm.relationship.revoked.v1`
|
||||||
|
- `idm.relationship.expired.v1`
|
||||||
|
|
||||||
|
The existing IDM lifecycle worker claims an elapsed relationship and records its
|
||||||
|
event marker in the same transaction. Repeated or concurrent sweeps therefore do
|
||||||
|
not publish duplicate expiry events.
|
||||||
|
|
||||||
|
## Distribution Lists
|
||||||
|
|
||||||
|
When Distribution Lists is enabled, an `idm_group` entry resolves through this
|
||||||
|
capability. Every effective identity becomes an internal-mail candidate when an
|
||||||
|
active linked account exists. Every rejected relationship remains visible in the
|
||||||
|
expansion evidence with a stable reason code. Distribution Lists stores only the
|
||||||
|
provider reference and frozen expansion evidence, not IDM records.
|
||||||
|
|
||||||
@@ -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 .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(function_changes_router)
|
||||||
|
router.include_router(relationships_router)
|
||||||
|
|||||||
@@ -107,6 +107,141 @@ class IdmSettingsUpdateRequest(BaseModel):
|
|||||||
settings: dict[str, Any] | None = None
|
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"]
|
FunctionAssignmentChangeKind = Literal["request", "grant"]
|
||||||
FunctionAssignmentChangeAction = Literal[
|
FunctionAssignmentChangeAction = Literal[
|
||||||
"approve",
|
"approve",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from govoplan_idm.backend.assignment_events import emit_assignment_event
|
|||||||
from govoplan_idm.backend.db.models import (
|
from govoplan_idm.backend.db.models import (
|
||||||
IdmFunctionAssignmentChange,
|
IdmFunctionAssignmentChange,
|
||||||
IdmFunctionAssignmentChangeEvent,
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmIdentityRelationship,
|
||||||
IdmOrganizationFunctionAssignment,
|
IdmOrganizationFunctionAssignment,
|
||||||
)
|
)
|
||||||
from govoplan_idm.backend.function_assignment_changes import OPEN_STATES
|
from govoplan_idm.backend.function_assignment_changes import OPEN_STATES
|
||||||
@@ -109,14 +110,102 @@ class SqlIdmAssignmentLifecycle:
|
|||||||
effective_at=now,
|
effective_at=now,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
expired_relationship_ids = self._expire_relationships(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
effective_at=now,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"selected": len(candidates),
|
"selected": len(candidates),
|
||||||
"expired": len(expired_ids),
|
"expired": len(expired_ids),
|
||||||
"assignment_ids": expired_ids,
|
"assignment_ids": expired_ids,
|
||||||
"expired_changes": len(expired_change_ids),
|
"expired_changes": len(expired_change_ids),
|
||||||
"change_ids": 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(
|
def _expire_open_changes(
|
||||||
self,
|
self,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
@@ -60,6 +61,118 @@ class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
|
|||||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
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):
|
class IdmTenantSettings(Base, TimestampMixin):
|
||||||
__tablename__ = "idm_tenant_settings"
|
__tablename__ = "idm_tenant_settings"
|
||||||
|
|
||||||
@@ -243,7 +356,9 @@ class IdmFunctionAssignmentChangeEvent(Base):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"IdmFunctionAssignmentChange",
|
"IdmFunctionAssignmentChange",
|
||||||
"IdmFunctionAssignmentChangeEvent",
|
"IdmFunctionAssignmentChangeEvent",
|
||||||
|
"IdmIdentityRelationship",
|
||||||
"IdmOrganizationFunctionAssignment",
|
"IdmOrganizationFunctionAssignment",
|
||||||
"IdmTenantSettings",
|
"IdmTenantSettings",
|
||||||
|
"IdmTypedGroup",
|
||||||
"new_uuid",
|
"new_uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from govoplan_core.core.idm import (
|
|||||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
CAPABILITY_IDM_DIRECTORY,
|
CAPABILITY_IDM_DIRECTORY,
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
from govoplan_core.core.policy import (
|
from govoplan_core.core.policy import (
|
||||||
@@ -58,6 +59,8 @@ IDM_READ_SCOPES = (
|
|||||||
"idm:function_grant:create",
|
"idm:function_grant:create",
|
||||||
"idm:function_change:decide",
|
"idm:function_change:decide",
|
||||||
"idm:function_change:admin",
|
"idm:function_change:admin",
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
"organizations:function:assign",
|
"organizations:function:assign",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,6 +130,16 @@ PERMISSIONS = (
|
|||||||
"Recover function assignment changes",
|
"Recover function assignment changes",
|
||||||
"Inspect and recover blocked or failed function assignment workflows.",
|
"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 = (
|
ROLE_TEMPLATES = (
|
||||||
@@ -145,6 +158,8 @@ ROLE_TEMPLATES = (
|
|||||||
"idm:function_grant:create",
|
"idm:function_grant:create",
|
||||||
"idm:function_change:decide",
|
"idm:function_change:decide",
|
||||||
"idm:function_change:admin",
|
"idm:function_change:admin",
|
||||||
|
"idm:relationship:read",
|
||||||
|
"idm:relationship:write",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
@@ -187,6 +202,15 @@ def _assignment_lifecycle(context: ModuleContext) -> object:
|
|||||||
return SqlIdmAssignmentLifecycle(registry=context.registry)
|
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(
|
manifest = ModuleManifest(
|
||||||
id="idm",
|
id="idm",
|
||||||
name="IDM",
|
name="IDM",
|
||||||
@@ -220,6 +244,10 @@ manifest = ModuleManifest(
|
|||||||
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
name=CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=CAPABILITY_IDM_RELATIONSHIPS,
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
ModuleInterfaceProvider(
|
ModuleInterfaceProvider(
|
||||||
name="idm.function_assignment_changes",
|
name="idm.function_assignment_changes",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -255,6 +283,8 @@ manifest = ModuleManifest(
|
|||||||
idm_models.IdmTenantSettings,
|
idm_models.IdmTenantSettings,
|
||||||
idm_models.IdmFunctionAssignmentChange,
|
idm_models.IdmFunctionAssignmentChange,
|
||||||
idm_models.IdmFunctionAssignmentChangeEvent,
|
idm_models.IdmFunctionAssignmentChangeEvent,
|
||||||
|
idm_models.IdmTypedGroup,
|
||||||
|
idm_models.IdmIdentityRelationship,
|
||||||
label="IDM",
|
label="IDM",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -262,6 +292,7 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
|
CAPABILITY_IDM_ASSIGNMENT_LIFECYCLE: _assignment_lifecycle,
|
||||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS: _idm_directory,
|
||||||
|
CAPABILITY_IDM_RELATIONSHIPS: _relationship_directory,
|
||||||
},
|
},
|
||||||
workflow_definitions=function_assignment_workflow_definitions(
|
workflow_definitions=function_assignment_workflow_definitions(
|
||||||
module_version=MODULE_VERSION,
|
module_version=MODULE_VERSION,
|
||||||
@@ -298,6 +329,22 @@ manifest = ModuleManifest(
|
|||||||
related_modules=("identity", "organizations", "access", "audit", "policy"),
|
related_modules=("identity", "organizations", "access", "audit", "policy"),
|
||||||
order=27,
|
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(
|
DocumentationTopic(
|
||||||
id="idm.workflow.assign-function-to-identity",
|
id="idm.workflow.assign-function-to-identity",
|
||||||
title="Assign an organization function to an identity",
|
title="Assign an organization function to an identity",
|
||||||
@@ -322,10 +369,10 @@ manifest = ModuleManifest(
|
|||||||
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
documentation_ref="docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",
|
||||||
test_ref="tests/test_assignment_workflow.py",
|
test_ref="tests/test_assignment_workflow.py",
|
||||||
known_limits=("External directory provisioning and all authority-specific grant workflows are not reference-ready.",),
|
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"),
|
non_owned_concepts=("identity", "organization function", "application role", "workflow runtime"),
|
||||||
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",),
|
recovery_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||||
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md",),
|
security_docs=("docs/FUNCTION_ASSIGNMENT_WORKFLOWS.md", "docs/TYPED_RELATIONSHIPS.md"),
|
||||||
operations_docs=("README.md",),
|
operations_docs=("README.md",),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+157
@@ -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")
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -12,7 +12,9 @@ from govoplan_idm.backend.assignment_lifecycle import SqlIdmAssignmentLifecycle
|
|||||||
from govoplan_idm.backend.db.models import (
|
from govoplan_idm.backend.db.models import (
|
||||||
IdmFunctionAssignmentChange,
|
IdmFunctionAssignmentChange,
|
||||||
IdmFunctionAssignmentChangeEvent,
|
IdmFunctionAssignmentChangeEvent,
|
||||||
|
IdmIdentityRelationship,
|
||||||
IdmOrganizationFunctionAssignment,
|
IdmOrganizationFunctionAssignment,
|
||||||
|
IdmTypedGroup,
|
||||||
)
|
)
|
||||||
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
from govoplan_organizations.backend.db import models as organization_models # noqa: F401
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ class AssignmentExpiryTests(unittest.TestCase):
|
|||||||
IdmOrganizationFunctionAssignment.__table__,
|
IdmOrganizationFunctionAssignment.__table__,
|
||||||
IdmFunctionAssignmentChange.__table__,
|
IdmFunctionAssignmentChange.__table__,
|
||||||
IdmFunctionAssignmentChangeEvent.__table__,
|
IdmFunctionAssignmentChangeEvent.__table__,
|
||||||
|
IdmTypedGroup.__table__,
|
||||||
|
IdmIdentityRelationship.__table__,
|
||||||
ChangeSequenceEntry.__table__,
|
ChangeSequenceEntry.__table__,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -204,6 +208,60 @@ class AssignmentExpiryTests(unittest.TestCase):
|
|||||||
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
history = session.query(IdmFunctionAssignmentChangeEvent).all()
|
||||||
self.assertEqual(["expired"], [item.action for item in history])
|
self.assertEqual(["expired"], [item.action for item in history])
|
||||||
|
|
||||||
|
def test_sweep_emits_relationship_expiry_once(self) -> None:
|
||||||
|
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add(
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
key="eligible",
|
||||||
|
name="Eligible",
|
||||||
|
group_type="business_status",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
IdmIdentityRelationship(
|
||||||
|
id="relationship-due",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="member",
|
||||||
|
subject_identity_id="identity-1",
|
||||||
|
target_group_id="group-1",
|
||||||
|
valid_until=boundary - timedelta(seconds=1),
|
||||||
|
status="active",
|
||||||
|
properties={},
|
||||||
|
provenance={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
events: list[PlatformEvent] = []
|
||||||
|
bus = EventBus()
|
||||||
|
bus.subscribe("idm.relationship.expired.v1", events.append)
|
||||||
|
with self.database.SessionLocal() as session, event_bus_context(bus):
|
||||||
|
result = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
repeated = self.lifecycle.process_expired(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
self.assertEqual(["relationship-due"], result["relationship_ids"])
|
||||||
|
self.assertEqual(1, result["expired_relationships"])
|
||||||
|
self.assertEqual(0, repeated["expired_relationships"])
|
||||||
|
self.assertEqual(1, len(events))
|
||||||
|
self.assertEqual("identity-1", events[0].subject.id)
|
||||||
|
with self.database.session() as session:
|
||||||
|
item = session.get(IdmIdentityRelationship, "relationship-due")
|
||||||
|
self.assertEqual(2, item.revision)
|
||||||
|
self.assertIsNotNone(item.expired_event_at)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import migrate_database
|
||||||
|
from govoplan_identity.backend.manifest import get_manifest as identity_manifest
|
||||||
|
from govoplan_idm.backend.manifest import get_manifest as idm_manifest
|
||||||
|
from govoplan_organizations.backend.manifest import (
|
||||||
|
get_manifest as organizations_manifest,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdmMigrationTests(unittest.TestCase):
|
||||||
|
def test_migrations_create_typed_relationship_tables_and_head(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-idm-migration-") as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'idm.db'}"
|
||||||
|
migrate_database(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("identity", "organizations", "idm"),
|
||||||
|
manifest_factories=(
|
||||||
|
identity_manifest,
|
||||||
|
organizations_manifest,
|
||||||
|
idm_manifest,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
engine = create_engine(url)
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
self.assertIn(
|
||||||
|
"b1c2d3e4f5a6",
|
||||||
|
set(MigrationContext.configure(connection).get_current_heads()),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"idm_function_assignment_change_events",
|
||||||
|
"idm_function_assignment_changes",
|
||||||
|
"idm_identity_relationships",
|
||||||
|
"idm_organization_function_assignments",
|
||||||
|
"idm_tenant_settings",
|
||||||
|
"idm_typed_groups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name
|
||||||
|
for name in inspect(connection).get_table_names()
|
||||||
|
if name.startswith("idm_")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import configure_database, reset_database
|
||||||
|
from govoplan_identity.backend.db.models import CanonicalIdentity
|
||||||
|
from govoplan_idm.backend.db.models import (
|
||||||
|
IdmIdentityRelationship,
|
||||||
|
IdmTypedGroup,
|
||||||
|
)
|
||||||
|
from govoplan_idm.backend.relationships import SqlIdmRelationshipDirectory
|
||||||
|
|
||||||
|
|
||||||
|
class StubIdentityDirectory:
|
||||||
|
def __init__(self, identities: tuple[IdentityRef, ...]) -> None:
|
||||||
|
self._identities = {item.id: item for item in identities}
|
||||||
|
|
||||||
|
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||||
|
return self._identities.get(identity_id)
|
||||||
|
|
||||||
|
def identity_for_account(self, account_id: str) -> IdentityRef | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def identities_for_accounts(self, account_ids):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class IdmRelationshipDirectoryTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.database = configure_database("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.database.engine,
|
||||||
|
tables=[
|
||||||
|
CanonicalIdentity.__table__,
|
||||||
|
IdmTypedGroup.__table__,
|
||||||
|
IdmIdentityRelationship.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
identities = (
|
||||||
|
IdentityRef(id="identity-active", display_name="Active", status="active"),
|
||||||
|
IdentityRef(id="identity-future", display_name="Future", status="active"),
|
||||||
|
IdentityRef(id="identity-expired", display_name="Expired", status="active"),
|
||||||
|
IdentityRef(id="identity-revoked", display_name="Revoked", status="active"),
|
||||||
|
IdentityRef(id="identity-suspended", display_name="Suspended", status="suspended"),
|
||||||
|
)
|
||||||
|
self.directory = SqlIdmRelationshipDirectory(
|
||||||
|
identities=StubIdentityDirectory(identities) # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
CanonicalIdentity(
|
||||||
|
id=item.id,
|
||||||
|
display_name=item.display_name,
|
||||||
|
source="local",
|
||||||
|
is_active=item.status == "active",
|
||||||
|
settings={},
|
||||||
|
)
|
||||||
|
for item in identities
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
key="permit-holder",
|
||||||
|
name="Permit holders",
|
||||||
|
group_type="business_status",
|
||||||
|
source_provider="ldap",
|
||||||
|
source_resource_type="group",
|
||||||
|
source_resource_id="cn=permit-holders,dc=example",
|
||||||
|
source_revision="directory-42",
|
||||||
|
properties={"classification": "resident"},
|
||||||
|
provenance={"connector_id": "ldap-1"},
|
||||||
|
),
|
||||||
|
IdmTypedGroup(
|
||||||
|
id="group-other",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
key="other",
|
||||||
|
name="Other tenant",
|
||||||
|
group_type="business_status",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
reset_database(dispose=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relationship(
|
||||||
|
relationship_id: str,
|
||||||
|
identity_id: str,
|
||||||
|
*,
|
||||||
|
boundary: datetime,
|
||||||
|
valid_from: datetime | None = None,
|
||||||
|
valid_until: datetime | None = None,
|
||||||
|
status: str = "active",
|
||||||
|
) -> IdmIdentityRelationship:
|
||||||
|
return IdmIdentityRelationship(
|
||||||
|
id=relationship_id,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
relationship_kind="member",
|
||||||
|
subject_identity_id=identity_id,
|
||||||
|
target_group_id="group-1",
|
||||||
|
valid_from=valid_from,
|
||||||
|
valid_until=valid_until,
|
||||||
|
status=status,
|
||||||
|
revoked_at=boundary if status == "revoked" else None,
|
||||||
|
revoked_by="account-1" if status == "revoked" else None,
|
||||||
|
revocation_reason="No longer eligible" if status == "revoked" else None,
|
||||||
|
source_provider="ldap",
|
||||||
|
source_resource_type="membership",
|
||||||
|
source_resource_id=f"member:{identity_id}",
|
||||||
|
source_revision="directory-42",
|
||||||
|
properties={"rank": 1},
|
||||||
|
provenance={"sync_run_id": "sync-1"},
|
||||||
|
revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolution_explains_current_future_expired_revoked_and_lifecycle(self) -> None:
|
||||||
|
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
self._relationship("active", "identity-active", boundary=boundary),
|
||||||
|
self._relationship(
|
||||||
|
"future",
|
||||||
|
"identity-future",
|
||||||
|
boundary=boundary,
|
||||||
|
valid_from=boundary + timedelta(days=1),
|
||||||
|
),
|
||||||
|
self._relationship(
|
||||||
|
"expired",
|
||||||
|
"identity-expired",
|
||||||
|
boundary=boundary,
|
||||||
|
valid_until=boundary,
|
||||||
|
),
|
||||||
|
self._relationship(
|
||||||
|
"revoked",
|
||||||
|
"identity-revoked",
|
||||||
|
boundary=boundary,
|
||||||
|
status="revoked",
|
||||||
|
),
|
||||||
|
self._relationship(
|
||||||
|
"suspended",
|
||||||
|
"identity-suspended",
|
||||||
|
boundary=boundary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
resolved = self.directory.resolve_typed_group_memberships(
|
||||||
|
("group-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)["group-1"]
|
||||||
|
|
||||||
|
self.assertEqual(("identity-active",), resolved.identity_ids)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"active": "relationship.effective",
|
||||||
|
"future": "relationship.not_yet_effective",
|
||||||
|
"expired": "relationship.expired",
|
||||||
|
"revoked": "relationship.revoked",
|
||||||
|
"suspended": "identity.not_active",
|
||||||
|
},
|
||||||
|
{item.relationship.id: item.code for item in resolved.decisions},
|
||||||
|
)
|
||||||
|
self.assertEqual("directory-42", resolved.group.source_revision)
|
||||||
|
self.assertEqual("ldap", resolved.decisions[0].relationship.source_provider)
|
||||||
|
|
||||||
|
def test_forward_reverse_batch_queries_return_only_effective_relationships(self) -> None:
|
||||||
|
boundary = datetime(2026, 8, 2, 12, tzinfo=timezone.utc)
|
||||||
|
with self.database.session() as session:
|
||||||
|
session.add_all(
|
||||||
|
(
|
||||||
|
self._relationship("active", "identity-active", boundary=boundary),
|
||||||
|
self._relationship(
|
||||||
|
"future",
|
||||||
|
"identity-future",
|
||||||
|
boundary=boundary,
|
||||||
|
valid_from=boundary + timedelta(days=1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
forward = self.directory.identity_relationships_for_identities(
|
||||||
|
("identity-active", "identity-future"),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
reverse = self.directory.identity_relationships_for_groups(
|
||||||
|
("group-1",),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=boundary,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("active",), tuple(item.id for item in forward["identity-active"]))
|
||||||
|
self.assertEqual((), forward["identity-future"])
|
||||||
|
self.assertEqual(("active",), tuple(item.id for item in reverse["group-1"]))
|
||||||
|
|
||||||
|
def test_cross_tenant_group_references_are_rejected(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||||
|
self.directory.get_typed_group("group-other", tenant_id="tenant-1")
|
||||||
|
with self.assertRaisesRegex(ValueError, "another tenant"):
|
||||||
|
self.directory.identity_relationships_for_group(
|
||||||
|
"group-other", tenant_id="tenant-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user