Sync GovOPlaN module state
This commit is contained in:
1
src/govoplan_idm/__init__.py
Normal file
1
src/govoplan_idm/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""GovOPlaN identity management bridge module."""
|
||||
1
src/govoplan_idm/backend/__init__.py
Normal file
1
src/govoplan_idm/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend integration for the GovOPlaN IDM module."""
|
||||
1
src/govoplan_idm/backend/api/__init__.py
Normal file
1
src/govoplan_idm/backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IDM API package."""
|
||||
1
src/govoplan_idm/backend/api/v1/__init__.py
Normal file
1
src/govoplan_idm/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IDM API v1 package."""
|
||||
544
src/govoplan_idm/backend/api/v1/routes.py
Normal file
544
src/govoplan_idm/backend/api/v1/routes.py
Normal file
@@ -0,0 +1,544 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.access import CAPABILITY_AUDIT_RECORDER
|
||||
from govoplan_core.core.configuration_control import (
|
||||
ConfigurationChangeApproval,
|
||||
ConfigurationControlError,
|
||||
ensure_configuration_change_allowed,
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment, IdmTenantSettings
|
||||
from govoplan_organizations.backend.db.models import OrganizationFunction
|
||||
|
||||
from .schemas import (
|
||||
IdmSettingsItem,
|
||||
IdmSettingsUpdateRequest,
|
||||
OrganizationFunctionAssignmentCreateRequest,
|
||||
OrganizationFunctionAssignmentItem,
|
||||
OrganizationFunctionAssignmentList,
|
||||
OrganizationFunctionAssignmentUpdateRequest,
|
||||
OrganizationIdentityCandidate,
|
||||
OrganizationIdentityCandidateList,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/idm", tags=["idm"])
|
||||
|
||||
ORGANIZATION_IDENTITY_READ_SCOPES = (
|
||||
"idm:organization_identity:read",
|
||||
"idm:organization_assignment:write",
|
||||
"organizations:function:assign",
|
||||
"admin:users:read",
|
||||
)
|
||||
IDM_ASSIGNMENT_READ_SCOPES = ("idm:organization_assignment:read", "idm:organization_assignment:write", "organizations:function:assign")
|
||||
IDM_ASSIGNMENT_WRITE_SCOPES = ("idm:organization_assignment:write", "organizations:function:assign")
|
||||
IDM_SETTINGS_READ_SCOPES = (
|
||||
"idm:settings:read",
|
||||
"idm:settings:write",
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
)
|
||||
IDM_SETTINGS_WRITE_SCOPES = ("idm:settings:write",)
|
||||
IDM_ASSIGNMENT_CHANGE_CONTROL_KEY = "idm.organization_assignments"
|
||||
IDM_ASSIGNMENT_AUDIT_EVENT = "idm.organization_assignment.updated"
|
||||
ASSIGNMENT_SOURCES = {"direct", "delegated", "acting_for", "directory", "governance", "system"}
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
|
||||
|
||||
def _tenant_id(principal: ApiPrincipal) -> str:
|
||||
return principal.tenant_id
|
||||
|
||||
|
||||
def _not_found(label: str) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{label} not found")
|
||||
|
||||
|
||||
def _conflict(message: str) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message)
|
||||
|
||||
|
||||
def _invalid(message: str) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=message)
|
||||
|
||||
|
||||
def _configuration_control_http_error(exc: ConfigurationControlError) -> HTTPException:
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": exc.code, "message": str(exc), "plan": exc.plan})
|
||||
|
||||
|
||||
def _get_tenant_row(session: Session, model: type[ModelT], item_id: str, tenant_id: str, label: str) -> ModelT:
|
||||
item = session.get(model, item_id)
|
||||
if item is None or getattr(item, "tenant_id") != tenant_id:
|
||||
raise _not_found(label)
|
||||
return item
|
||||
|
||||
|
||||
def _commit(session: Session, item: ModelT) -> ModelT:
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise _conflict("The IDM assignment conflicts with existing data.") from exc
|
||||
session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def _row_fields(item: object) -> dict[str, Any]:
|
||||
keys = [column.name for column in item.__table__.columns] # type: ignore[attr-defined]
|
||||
return {key: getattr(item, key) for key in keys}
|
||||
|
||||
|
||||
def _jsonable(value: object) -> object:
|
||||
return jsonable_encoder(value)
|
||||
|
||||
|
||||
def _assignment_item(item: IdmOrganizationFunctionAssignment) -> OrganizationFunctionAssignmentItem:
|
||||
return OrganizationFunctionAssignmentItem(**_row_fields(item))
|
||||
|
||||
|
||||
def _settings_item(item: IdmTenantSettings) -> IdmSettingsItem:
|
||||
return IdmSettingsItem(**_row_fields(item))
|
||||
|
||||
|
||||
def _default_settings(tenant_id: str) -> IdmSettingsItem:
|
||||
return IdmSettingsItem(
|
||||
tenant_id=tenant_id,
|
||||
require_assignment_change_requests=False,
|
||||
audit_detail_level="standard",
|
||||
change_retention_days=None,
|
||||
settings={},
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_identity(session: Session, identity_id: str) -> None:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
raise _not_found("Identity")
|
||||
|
||||
|
||||
def _ensure_account_link(session: Session, identity_id: str, account_id: str | None) -> None:
|
||||
if account_id is None:
|
||||
return
|
||||
exists = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
|
||||
.first()
|
||||
)
|
||||
if exists is None:
|
||||
raise _invalid("Account is not linked to the selected identity.")
|
||||
|
||||
|
||||
def _account_linked_to_identity(session: Session, identity_id: str, account_id: str) -> bool:
|
||||
return (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id, IdentityAccountLink.account_id == account_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _ensure_assignment_workflow(
|
||||
session: Session,
|
||||
item: IdmOrganizationFunctionAssignment,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
if item.source not in ASSIGNMENT_SOURCES:
|
||||
raise _invalid("Assignment source is not supported.")
|
||||
if item.valid_from is not None and item.valid_until is not None and item.valid_until <= item.valid_from:
|
||||
raise _invalid("Valid until must be after valid from.")
|
||||
if item.delegated_from_assignment_id is not None and item.delegated_from_assignment_id == item.id:
|
||||
raise _invalid("A function assignment cannot delegate from itself.")
|
||||
|
||||
function = _get_tenant_row(session, OrganizationFunction, item.function_id, tenant_id, "Organization function")
|
||||
base: IdmOrganizationFunctionAssignment | None = None
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
base = _get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||
if not base.is_active:
|
||||
raise _invalid("Delegation source assignment must be active.")
|
||||
if base.function_id != item.function_id:
|
||||
raise _invalid("Delegated and acting-for assignments must use the same organization function as the source assignment.")
|
||||
|
||||
if item.source == "delegated":
|
||||
if base is None:
|
||||
raise _invalid("Delegated assignments require a source assignment.")
|
||||
if not function.delegable:
|
||||
raise _invalid("This organization function does not allow delegation.")
|
||||
if item.acting_for_account_id is not None:
|
||||
raise _invalid("Delegated assignments cannot set an acting-for account.")
|
||||
if item.identity_id == base.identity_id and (item.account_id or "") == (base.account_id or ""):
|
||||
raise _invalid("A delegated assignment must target another identity or account.")
|
||||
elif item.source == "acting_for":
|
||||
if base is None:
|
||||
raise _invalid("Acting-for assignments require a source assignment.")
|
||||
if not function.act_in_place_allowed:
|
||||
raise _invalid("This organization function does not allow acting in place.")
|
||||
if item.acting_for_account_id is None:
|
||||
raise _invalid("Acting-for assignments require an acting-for account.")
|
||||
if base.account_id is not None:
|
||||
if item.acting_for_account_id != base.account_id:
|
||||
raise _invalid("Acting-for account must match the source assignment account.")
|
||||
elif not _account_linked_to_identity(session, base.identity_id, item.acting_for_account_id):
|
||||
raise _invalid("Acting-for account must belong to the source assignment identity.")
|
||||
if item.account_id == item.acting_for_account_id:
|
||||
raise _invalid("The acting account and acting-for account must be different.")
|
||||
else:
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
raise _invalid("Only delegated or acting-for assignments can reference a source assignment.")
|
||||
if item.acting_for_account_id is not None:
|
||||
raise _invalid("Only acting-for assignments can set an acting-for account.")
|
||||
|
||||
|
||||
def _requires_assignment_change_request(session: Session, tenant_id: str) -> bool:
|
||||
item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||
return bool(item and item.require_assignment_change_requests)
|
||||
|
||||
|
||||
def _actor_id(principal: ApiPrincipal) -> str:
|
||||
return principal.membership_id or principal.account_id
|
||||
|
||||
|
||||
def _payload_for_control(resource_type: str, operation: str, payload: object) -> dict[str, Any]:
|
||||
if hasattr(payload, "model_dump"):
|
||||
values = payload.model_dump(mode="json", exclude={"change_request_id"}, exclude_unset=True) # type: ignore[attr-defined]
|
||||
else:
|
||||
values = {}
|
||||
return {"resource_type": resource_type, "operation": operation, "payload": values}
|
||||
|
||||
|
||||
def _target_for_control(tenant_id: str, resource_type: str, operation: str, resource_id: str | None = None) -> dict[str, Any]:
|
||||
target: dict[str, Any] = {"tenant_id": tenant_id, "resource_type": resource_type, "operation": operation}
|
||||
if resource_id is not None:
|
||||
target["resource_id"] = resource_id
|
||||
return target
|
||||
|
||||
|
||||
def _ensure_assignment_change_allowed(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
tenant_id: str,
|
||||
operation: str,
|
||||
payload: object,
|
||||
resource_id: str | None = None,
|
||||
) -> tuple[ConfigurationChangeApproval | None, dict[str, Any], dict[str, Any]]:
|
||||
value = _payload_for_control("organization_function_assignment", operation, payload)
|
||||
target = _target_for_control(tenant_id, "organization_function_assignment", operation, resource_id)
|
||||
if not _requires_assignment_change_request(session, tenant_id):
|
||||
return None, target, value
|
||||
try:
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session,
|
||||
key=IDM_ASSIGNMENT_CHANGE_CONTROL_KEY,
|
||||
value=value,
|
||||
actor_user_id=_actor_id(principal),
|
||||
actor_scopes=tuple(principal.scopes),
|
||||
change_request_id=getattr(payload, "change_request_id", None),
|
||||
target=target,
|
||||
)
|
||||
except ConfigurationControlError as exc:
|
||||
raise _configuration_control_http_error(exc) from exc
|
||||
return approval, target, value
|
||||
|
||||
|
||||
def _record_assignment_change_applied(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
approval: ConfigurationChangeApproval | None,
|
||||
target: dict[str, Any],
|
||||
before: object,
|
||||
after: object,
|
||||
) -> None:
|
||||
if approval is None:
|
||||
return
|
||||
record_configuration_change_applied(
|
||||
session,
|
||||
key=IDM_ASSIGNMENT_CHANGE_CONTROL_KEY,
|
||||
before_value=before,
|
||||
after_value=after,
|
||||
actor_user_id=_actor_id(principal),
|
||||
approval=approval,
|
||||
target=target,
|
||||
audit_event=IDM_ASSIGNMENT_AUDIT_EVENT,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _audit_capability_available() -> bool:
|
||||
registry = get_registry()
|
||||
return bool(registry is not None and hasattr(registry, "has_capability") and registry.has_capability(CAPABILITY_AUDIT_RECORDER))
|
||||
|
||||
|
||||
def _record_assignment_audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
before: object,
|
||||
after: object,
|
||||
) -> None:
|
||||
if not _audit_capability_available():
|
||||
return
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=action,
|
||||
object_type=resource_type,
|
||||
object_id=resource_id,
|
||||
details={"before": before, "after": after},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings", response_model=IdmSettingsItem)
|
||||
def get_idm_settings(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDM_SETTINGS_READ_SCOPES)),
|
||||
) -> IdmSettingsItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||
return _settings_item(item) if item is not None else _default_settings(tenant_id)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=IdmSettingsItem)
|
||||
def update_idm_settings(
|
||||
payload: IdmSettingsUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDM_SETTINGS_WRITE_SCOPES)),
|
||||
) -> IdmSettingsItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
item = session.query(IdmTenantSettings).filter(IdmTenantSettings.tenant_id == tenant_id).one_or_none()
|
||||
before = _jsonable(_row_fields(item)) if item is not None else None
|
||||
if item is None:
|
||||
item = IdmTenantSettings(
|
||||
tenant_id=tenant_id,
|
||||
require_assignment_change_requests=False,
|
||||
audit_detail_level="standard",
|
||||
change_retention_days=None,
|
||||
settings={},
|
||||
)
|
||||
session.add(item)
|
||||
fields = payload.model_fields_set
|
||||
for field in ("require_assignment_change_requests", "audit_detail_level", "change_retention_days"):
|
||||
if field in fields:
|
||||
value = getattr(payload, field)
|
||||
if field != "change_retention_days" and value is None:
|
||||
raise _invalid(f"{field} cannot be empty.")
|
||||
setattr(item, field, value)
|
||||
if "settings" in fields:
|
||||
if payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
item.settings = payload.settings
|
||||
result = _settings_item(_commit(session, item))
|
||||
_record_assignment_audit(
|
||||
session,
|
||||
principal,
|
||||
action="idm.settings.updated",
|
||||
resource_type="idm_tenant_settings",
|
||||
resource_id=tenant_id,
|
||||
before=before,
|
||||
after=result.model_dump(mode="json"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/organization-function-assignments", response_model=OrganizationFunctionAssignmentList)
|
||||
def list_organization_function_assignments(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_READ_SCOPES)),
|
||||
) -> OrganizationFunctionAssignmentList:
|
||||
tenant_id = _tenant_id(principal)
|
||||
assignments = (
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
||||
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return OrganizationFunctionAssignmentList(assignments=[_assignment_item(item) for item in assignments])
|
||||
|
||||
|
||||
@router.post("/organization-function-assignments", response_model=OrganizationFunctionAssignmentItem, status_code=status.HTTP_201_CREATED)
|
||||
def create_organization_function_assignment(
|
||||
payload: OrganizationFunctionAssignmentCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_WRITE_SCOPES)),
|
||||
) -> OrganizationFunctionAssignmentItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="created", payload=payload)
|
||||
_ensure_identity(session, payload.identity_id)
|
||||
_ensure_account_link(session, payload.identity_id, payload.account_id)
|
||||
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
|
||||
item = IdmOrganizationFunctionAssignment(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=payload.identity_id,
|
||||
account_id=payload.account_id,
|
||||
function_id=function.id,
|
||||
organization_unit_id=function.organization_unit_id,
|
||||
applies_to_subunits=payload.applies_to_subunits,
|
||||
source=payload.source,
|
||||
delegated_from_assignment_id=payload.delegated_from_assignment_id,
|
||||
acting_for_account_id=payload.acting_for_account_id,
|
||||
valid_from=payload.valid_from,
|
||||
valid_until=payload.valid_until,
|
||||
is_active=payload.is_active,
|
||||
settings=payload.settings,
|
||||
)
|
||||
if item.delegated_from_assignment_id is not None:
|
||||
_get_tenant_row(session, IdmOrganizationFunctionAssignment, item.delegated_from_assignment_id, tenant_id, "Delegated function assignment")
|
||||
_ensure_assignment_workflow(session, item, tenant_id=tenant_id)
|
||||
session.add(item)
|
||||
saved = _commit(session, item)
|
||||
result = _assignment_item(saved)
|
||||
after = result.model_dump(mode="json")
|
||||
_record_assignment_change_applied(session, principal, approval=approval, target={**target, "resource_id": saved.id}, before=None, after=after)
|
||||
_record_assignment_audit(
|
||||
session,
|
||||
principal,
|
||||
action="idm.organization_assignment.created",
|
||||
resource_type="idm_organization_function_assignment",
|
||||
resource_id=saved.id,
|
||||
before=None,
|
||||
after=after,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch("/organization-function-assignments/{assignment_id}", response_model=OrganizationFunctionAssignmentItem)
|
||||
def update_organization_function_assignment(
|
||||
assignment_id: str,
|
||||
payload: OrganizationFunctionAssignmentUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDM_ASSIGNMENT_WRITE_SCOPES)),
|
||||
) -> OrganizationFunctionAssignmentItem:
|
||||
tenant_id = _tenant_id(principal)
|
||||
item = _get_tenant_row(session, IdmOrganizationFunctionAssignment, assignment_id, tenant_id, "Organization function assignment")
|
||||
before = _jsonable(_row_fields(item))
|
||||
approval, target, _value = _ensure_assignment_change_allowed(session, principal, tenant_id=tenant_id, operation="updated", payload=payload, resource_id=assignment_id)
|
||||
if "function_id" in payload.model_fields_set:
|
||||
if payload.function_id is None:
|
||||
raise _invalid("Function is required.")
|
||||
function = _get_tenant_row(session, OrganizationFunction, payload.function_id, tenant_id, "Organization function")
|
||||
item.function_id = function.id
|
||||
item.organization_unit_id = function.organization_unit_id
|
||||
identity_id = payload.identity_id if "identity_id" in payload.model_fields_set else item.identity_id
|
||||
account_id = payload.account_id if "account_id" in payload.model_fields_set else item.account_id
|
||||
if "identity_id" in payload.model_fields_set:
|
||||
if payload.identity_id is None:
|
||||
raise _invalid("Identity is required.")
|
||||
_ensure_identity(session, payload.identity_id)
|
||||
item.identity_id = payload.identity_id
|
||||
_ensure_account_link(session, identity_id, account_id)
|
||||
if "source" in payload.model_fields_set and payload.source is None:
|
||||
raise _invalid("Assignment source is required.")
|
||||
if "applies_to_subunits" in payload.model_fields_set and payload.applies_to_subunits is None:
|
||||
raise _invalid("Subunit applicability cannot be empty.")
|
||||
if "is_active" in payload.model_fields_set and payload.is_active is None:
|
||||
raise _invalid("Active state cannot be empty.")
|
||||
if "settings" in payload.model_fields_set and payload.settings is None:
|
||||
raise _invalid("Settings cannot be empty.")
|
||||
for field in (
|
||||
"account_id",
|
||||
"applies_to_subunits",
|
||||
"source",
|
||||
"delegated_from_assignment_id",
|
||||
"acting_for_account_id",
|
||||
"valid_from",
|
||||
"valid_until",
|
||||
"is_active",
|
||||
"settings",
|
||||
):
|
||||
if field in payload.model_fields_set:
|
||||
setattr(item, field, getattr(payload, field))
|
||||
_ensure_assignment_workflow(session, item, tenant_id=tenant_id)
|
||||
saved = _commit(session, item)
|
||||
result = _assignment_item(saved)
|
||||
after = result.model_dump(mode="json")
|
||||
_record_assignment_change_applied(session, principal, approval=approval, target=target, before=before, after=after)
|
||||
_record_assignment_audit(
|
||||
session,
|
||||
principal,
|
||||
action="idm.organization_assignment.updated",
|
||||
resource_type="idm_organization_function_assignment",
|
||||
resource_id=saved.id,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/organization-identities", response_model=OrganizationIdentityCandidateList)
|
||||
def list_organization_identity_candidates(
|
||||
query: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
include_inactive: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ORGANIZATION_IDENTITY_READ_SCOPES)),
|
||||
) -> OrganizationIdentityCandidateList:
|
||||
del principal
|
||||
identity_query = session.query(Identity)
|
||||
if not include_inactive:
|
||||
identity_query = identity_query.filter(Identity.is_active.is_(True))
|
||||
if query:
|
||||
pattern = f"%{query.strip().casefold()}%"
|
||||
matching_account_links = session.query(IdentityAccountLink.identity_id).filter(
|
||||
func.lower(IdentityAccountLink.account_id).like(pattern)
|
||||
)
|
||||
identity_query = identity_query.filter(
|
||||
or_(
|
||||
func.lower(Identity.id).like(pattern),
|
||||
func.lower(Identity.display_name).like(pattern),
|
||||
func.lower(Identity.external_subject).like(pattern),
|
||||
Identity.id.in_(matching_account_links),
|
||||
)
|
||||
)
|
||||
|
||||
identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all()
|
||||
identity_ids = [identity.id for identity in identities]
|
||||
links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids}
|
||||
if identity_ids:
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
links_by_identity.setdefault(link.identity_id, []).append(link)
|
||||
|
||||
return OrganizationIdentityCandidateList(
|
||||
identities=[
|
||||
_identity_candidate(identity, links_by_identity.get(identity.id, []))
|
||||
for identity in identities
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _identity_candidate(identity: Identity, links: list[IdentityAccountLink]) -> OrganizationIdentityCandidate:
|
||||
primary_link = next((link for link in links if link.is_primary), None)
|
||||
return OrganizationIdentityCandidate(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
external_subject=identity.external_subject,
|
||||
source=identity.source,
|
||||
primary_account_id=primary_link.account_id if primary_link is not None else None,
|
||||
account_ids=[link.account_id for link in links],
|
||||
status="active" if identity.is_active else "inactive",
|
||||
)
|
||||
94
src/govoplan_idm/backend/api/v1/schemas.py
Normal file
94
src/govoplan_idm/backend/api/v1/schemas.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
AuditDetailLevel = Literal["summary", "standard", "full"]
|
||||
|
||||
|
||||
class OrganizationIdentityCandidate(BaseModel):
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
external_subject: str | None = None
|
||||
source: str
|
||||
primary_account_id: str | None = None
|
||||
account_ids: list[str]
|
||||
status: str
|
||||
|
||||
|
||||
class OrganizationIdentityCandidateList(BaseModel):
|
||||
identities: list[OrganizationIdentityCandidate]
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
identity_id: str
|
||||
account_id: str | None = None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool
|
||||
source: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool
|
||||
settings: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentList(BaseModel):
|
||||
assignments: list[OrganizationFunctionAssignmentItem]
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentCreateRequest(BaseModel):
|
||||
identity_id: str = Field(min_length=1)
|
||||
function_id: str = Field(min_length=1)
|
||||
account_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: str = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class OrganizationFunctionAssignmentUpdateRequest(BaseModel):
|
||||
identity_id: str | None = None
|
||||
function_id: str | None = None
|
||||
account_id: str | None = None
|
||||
applies_to_subunits: bool | None = None
|
||||
source: str | None = None
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class IdmSettingsItem(BaseModel):
|
||||
tenant_id: str
|
||||
require_assignment_change_requests: bool
|
||||
audit_detail_level: AuditDetailLevel
|
||||
change_retention_days: int | None = None
|
||||
settings: dict[str, Any]
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class IdmSettingsUpdateRequest(BaseModel):
|
||||
require_assignment_change_requests: bool | None = None
|
||||
audit_detail_level: AuditDetailLevel | None = None
|
||||
change_retention_days: int | None = Field(default=None, ge=0)
|
||||
settings: dict[str, Any] | None = None
|
||||
1
src/govoplan_idm/backend/db/__init__.py
Normal file
1
src/govoplan_idm/backend/db/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IDM database models."""
|
||||
55
src/govoplan_idm/backend/db/models.py
Normal file
55
src/govoplan_idm/backend/db/models.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class IdmOrganizationFunctionAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "idm_organization_function_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"identity_id",
|
||||
"function_id",
|
||||
"organization_unit_id",
|
||||
name="uq_idm_org_function_assignments_identity_scope",
|
||||
),
|
||||
)
|
||||
|
||||
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)
|
||||
identity_id: Mapped[str] = mapped_column(ForeignKey("identity_identities.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("organizations_functions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("organizations_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
|
||||
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("idm_organization_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=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)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class IdmTenantSettings(Base, TimestampMixin):
|
||||
__tablename__ = "idm_tenant_settings"
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
require_assignment_change_requests: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
audit_detail_level: Mapped[str] = mapped_column(String(20), default="standard", nullable=False)
|
||||
change_retention_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
__all__ = ["IdmOrganizationFunctionAssignment", "IdmTenantSettings", "new_uuid"]
|
||||
93
src/govoplan_idm/backend/directory.py
Normal file
93
src/govoplan_idm/backend/directory.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import or_
|
||||
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_identity.backend.db.models import IdentityAccountLink
|
||||
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
|
||||
from govoplan_organizations.backend.db.models import OrganizationFunction
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
return "active" if active else "inactive"
|
||||
|
||||
|
||||
def _assignment_ref(item: IdmOrganizationFunctionAssignment) -> OrganizationFunctionAssignmentRef:
|
||||
return OrganizationFunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
identity_id=item.identity_id,
|
||||
account_id=item.account_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
source=item.source, # type: ignore[arg-type]
|
||||
delegated_from_assignment_id=item.delegated_from_assignment_id,
|
||||
acting_for_account_id=item.acting_for_account_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class SqlIdmDirectory(IdmDirectory):
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(IdmOrganizationFunctionAssignment, assignment_id)
|
||||
return _assignment_ref(item) if item is not None else None
|
||||
|
||||
def organization_function_assignments_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
return self._assignments_for_identity_ids(identity_ids=(identity_id,), tenant_id=tenant_id)
|
||||
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
with get_database().session() as session:
|
||||
identity_ids = [
|
||||
row[0]
|
||||
for row in session.query(IdentityAccountLink.identity_id)
|
||||
.filter(IdentityAccountLink.account_id == account_id)
|
||||
.all()
|
||||
]
|
||||
if not identity_ids:
|
||||
return ()
|
||||
return self._assignments_for_identity_ids(identity_ids=tuple(identity_ids), tenant_id=tenant_id, account_id=account_id)
|
||||
|
||||
def _assignments_for_identity_ids(
|
||||
self,
|
||||
*,
|
||||
identity_ids: tuple[str, ...],
|
||||
tenant_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
if not identity_ids:
|
||||
return ()
|
||||
now = utc_now()
|
||||
with get_database().session() as session:
|
||||
query = (
|
||||
session.query(IdmOrganizationFunctionAssignment)
|
||||
.join(OrganizationFunction, OrganizationFunction.id == IdmOrganizationFunctionAssignment.function_id)
|
||||
.filter(
|
||||
IdmOrganizationFunctionAssignment.identity_id.in_(identity_ids),
|
||||
IdmOrganizationFunctionAssignment.is_active.is_(True),
|
||||
OrganizationFunction.is_active.is_(True),
|
||||
or_(IdmOrganizationFunctionAssignment.valid_from.is_(None), IdmOrganizationFunctionAssignment.valid_from <= now),
|
||||
or_(IdmOrganizationFunctionAssignment.valid_until.is_(None), IdmOrganizationFunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(IdmOrganizationFunctionAssignment.created_at.asc())
|
||||
)
|
||||
if account_id is not None:
|
||||
query = query.filter(or_(IdmOrganizationFunctionAssignment.account_id.is_(None), IdmOrganizationFunctionAssignment.account_id == account_id))
|
||||
if tenant_id is not None:
|
||||
query = query.filter(IdmOrganizationFunctionAssignment.tenant_id == tenant_id)
|
||||
return tuple(_assignment_ref(item) for item in query.all())
|
||||
186
src/govoplan_idm/backend/manifest.py
Normal file
186
src/govoplan_idm/backend/manifest.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_idm.backend.db import models as idm_models # noqa: F401 - populate metadata
|
||||
|
||||
IDM_READ_SCOPES = (
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:settings:read",
|
||||
"organizations:function:assign",
|
||||
)
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="IDM",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission(
|
||||
"idm:organization_identity:read",
|
||||
"View organization identity candidates",
|
||||
"Search identities and account links for organization function assignments.",
|
||||
),
|
||||
_permission(
|
||||
"idm:organization_assignment:read",
|
||||
"View organization function assignments",
|
||||
"Read identity-to-organization-function assignment links.",
|
||||
),
|
||||
_permission(
|
||||
"idm:organization_assignment:write",
|
||||
"Manage organization function assignments",
|
||||
"Create and edit identity-to-organization-function assignment links.",
|
||||
),
|
||||
_permission(
|
||||
"idm:settings:read",
|
||||
"View IDM settings",
|
||||
"Read IDM governance and assignment-change policy settings.",
|
||||
),
|
||||
_permission(
|
||||
"idm:settings:write",
|
||||
"Manage IDM settings",
|
||||
"Update IDM governance and assignment-change policy settings.",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="idm_organization_mapper",
|
||||
name="IDM organization mapper",
|
||||
description="Resolve identity/account candidates and manage organization function assignment links.",
|
||||
permissions=(
|
||||
"idm:organization_identity:read",
|
||||
"idm:organization_assignment:read",
|
||||
"idm:organization_assignment:write",
|
||||
"idm:settings:read",
|
||||
"idm:settings:write",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_idm.backend.api.v1.routes import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _idm_directory(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_idm.backend.directory import SqlIdmDirectory
|
||||
|
||||
return SqlIdmDirectory()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="idm",
|
||||
name="IDM",
|
||||
version="0.1.6",
|
||||
dependencies=("identity", "organizations"),
|
||||
optional_dependencies=("access", "audit"),
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||
frontend=FrontendModule(
|
||||
module_id="idm",
|
||||
package_name="@govoplan/idm-webui",
|
||||
routes=(FrontendRoute(path="/idm", component="IdmPage", required_any=IDM_READ_SCOPES, order=72),),
|
||||
nav_items=(NavItem(path="/idm", label="IDM", icon="users", required_any=IDM_READ_SCOPES, order=72),),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="idm",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
idm_models.IdmOrganizationFunctionAssignment,
|
||||
idm_models.IdmTenantSettings,
|
||||
label="IDM",
|
||||
),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_IDM_DIRECTORY: _idm_directory,
|
||||
},
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="idm.organization_identity_bridge",
|
||||
title="Identity to organization bridge",
|
||||
summary="IDM resolves which identities and accounts can be matched to organization function assignments.",
|
||||
body=(
|
||||
"Identity owns normalized identities and account links. Organizations owns units and functions. "
|
||||
"IDM owns assignment links between identities and organization functions, including identity search for organization assignments and future synchronization/mapping workflows. "
|
||||
"Access may consume these assignment links when IDM is installed, but IDM does not require access to evaluate rights."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
related_modules=("identity", "organizations", "access"),
|
||||
order=26,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.reference.assignment-governance",
|
||||
title="IDM assignment governance",
|
||||
summary="Tenant IDM settings can require approved change requests before identity-to-function assignments are applied.",
|
||||
body=(
|
||||
"Assignment links are high-impact because they can later feed access decisions. "
|
||||
"Tenants can enable recorded change requests for assignment create and update operations. "
|
||||
"The legacy organizations:function:assign scope remains accepted for transition, while new role templates should grant idm:organization_assignment:write."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
related_modules=("identity", "organizations", "access", "audit", "policy"),
|
||||
order=27,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="idm.workflow.assign-function-to-identity",
|
||||
title="Assign an organization function to an identity",
|
||||
summary="IDM links identities or accounts to organization functions. Access can consume those accepted links when a function-to-role mapping exists.",
|
||||
body=(
|
||||
"Create the unit and function in Organizations first. Make sure the person and account exist in Identity. "
|
||||
"Then create the assignment in IDM. Direct assignments state who holds the function. Delegated assignments require a source assignment and a delegable function. "
|
||||
"Acting-for assignments require a source assignment, an acting account, and a function that allows acting in place. "
|
||||
"Access maps accepted function facts to roles and rights; without such a mapping, the assignment is recorded but does not grant application permissions."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("identity", "organizations", "access"),
|
||||
order=28,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
1
src/govoplan_idm/backend/migrations/__init__.py
Normal file
1
src/govoplan_idm/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IDM database migrations."""
|
||||
@@ -0,0 +1,139 @@
|
||||
"""idm organization function assignments
|
||||
|
||||
Revision ID: 8f9a0b1c2d3e
|
||||
Revises:
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "8f9a0b1c2d3e"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tables() -> set[str]:
|
||||
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def _indexes(table_name: str) -> set[str]:
|
||||
if table_name not in _tables():
|
||||
return set()
|
||||
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False) -> None:
|
||||
if table_name in _tables() and name not in _indexes(table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique)
|
||||
|
||||
|
||||
def _drop_index_if_exists(name: str, table_name: str) -> None:
|
||||
if table_name in _tables() and name in _indexes(table_name):
|
||||
op.drop_index(name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if "idm_tenant_settings" not in _tables():
|
||||
op.create_table(
|
||||
"idm_tenant_settings",
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("require_assignment_change_requests", sa.Boolean(), nullable=False),
|
||||
sa.Column("audit_detail_level", sa.String(length=20), nullable=False),
|
||||
sa.Column("change_retention_days", sa.Integer(), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint("tenant_id", name=op.f("pk_idm_tenant_settings")),
|
||||
)
|
||||
|
||||
if "idm_organization_function_assignments" not in _tables():
|
||||
op.create_table(
|
||||
"idm_organization_function_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegated_from_assignment_id"],
|
||||
["idm_organization_function_assignments.id"],
|
||||
name=op.f("fk_idm_organization_function_assignments_delegated_from_assignment_id_idm_organization_function_assignments"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["organizations_functions.id"],
|
||||
name=op.f("fk_idm_organization_function_assignments_function_id_organizations_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["identity_identities.id"],
|
||||
name=op.f("fk_idm_organization_function_assignments_identity_id_identity_identities"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_unit_id"],
|
||||
["organizations_units.id"],
|
||||
name=op.f("fk_idm_organization_function_assignments_organization_unit_id_organizations_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_idm_organization_function_assignments")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"identity_id",
|
||||
"function_id",
|
||||
"organization_unit_id",
|
||||
name="uq_idm_org_function_assignments_identity_scope",
|
||||
),
|
||||
)
|
||||
|
||||
for column in (
|
||||
"account_id",
|
||||
"acting_for_account_id",
|
||||
"delegated_from_assignment_id",
|
||||
"function_id",
|
||||
"identity_id",
|
||||
"organization_unit_id",
|
||||
"tenant_id",
|
||||
):
|
||||
_create_index_if_missing(
|
||||
op.f(f"ix_idm_organization_function_assignments_{column}"),
|
||||
"idm_organization_function_assignments",
|
||||
[column],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"organization_unit_id",
|
||||
"identity_id",
|
||||
"function_id",
|
||||
"delegated_from_assignment_id",
|
||||
"acting_for_account_id",
|
||||
"account_id",
|
||||
):
|
||||
_drop_index_if_exists(
|
||||
op.f(f"ix_idm_organization_function_assignments_{column}"),
|
||||
"idm_organization_function_assignments",
|
||||
)
|
||||
if "idm_organization_function_assignments" in _tables():
|
||||
op.drop_table("idm_organization_function_assignments")
|
||||
if "idm_tenant_settings" in _tables():
|
||||
op.drop_table("idm_tenant_settings")
|
||||
1
src/govoplan_idm/backend/migrations/versions/__init__.py
Normal file
1
src/govoplan_idm/backend/migrations/versions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""IDM migration revisions."""
|
||||
1
src/govoplan_idm/py.typed
Normal file
1
src/govoplan_idm/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user