Sync GovOPlaN module state

This commit is contained in:
2026-07-10 21:57:20 +02:00
parent c71de86a1f
commit a7c486788e
18 changed files with 1527 additions and 137 deletions

View File

@@ -63,6 +63,11 @@ from govoplan_access.backend.api.v1.admin_schemas import (
GroupListResponse,
GroupSummary,
GroupUpdateRequest,
ExternalFunctionRoleMappingCreateRequest,
ExternalFunctionRoleMappingItem,
ExternalFunctionRoleMappingListDeltaResponse,
ExternalFunctionRoleMappingListResponse,
ExternalFunctionRoleMappingUpdateRequest,
FunctionAdminItem,
FunctionAssignmentAdminItem,
FunctionAssignmentCreateRequest,
@@ -148,6 +153,7 @@ from govoplan_core.core.configuration_control import (
record_configuration_change_applied,
)
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.change_sequence import (
decode_sequence_watermark,
@@ -160,6 +166,7 @@ from govoplan_core.core.change_sequence import (
from govoplan_access.backend.db.models import (
Account,
ApiKey,
ExternalFunctionRoleAssignment,
Function,
FunctionAssignment,
FunctionDelegation,
@@ -190,6 +197,7 @@ ACCESS_ROLES_COLLECTION = "access.admin.roles"
ACCESS_IDENTITIES_COLLECTION = "access.admin.identities"
ACCESS_ORGANIZATION_UNITS_COLLECTION = "access.admin.organization_units"
ACCESS_FUNCTIONS_COLLECTION = "access.admin.functions"
ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION = "access.admin.external_function_role_mappings"
ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION = "access.admin.function_assignments"
ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations"
ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles"
@@ -439,6 +447,60 @@ def _set_function_roles(session: Session, *, function: Function, role_ids: list[
session.flush()
def _external_function_role_mapping_item(item: ExternalFunctionRoleAssignment) -> ExternalFunctionRoleMappingItem:
return ExternalFunctionRoleMappingItem(
id=item.id,
tenant_id=item.tenant_id,
source_module=item.source_module,
function_id=item.function_id,
role_id=item.role_id,
settings=item.settings or {},
created_at=item.created_at,
updated_at=item.updated_at,
)
def _validate_external_function_role(
session: Session,
*,
tenant_id: str,
role_id: str,
) -> Role:
role = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id == role_id, Role.is_assignable.is_(True)).one_or_none()
if role is None:
raise AdminValidationError("Selected role is invalid or not assignable.")
return role
def _organization_directory_or_error() -> OrganizationDirectory:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "organizations_required",
"message": "External function role mappings require the Organizations module.",
},
)
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
if not isinstance(capability, OrganizationDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
return capability
def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
if source_module != ORGANIZATIONS_MODULE_ID:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unsupported function source module: {source_module}",
)
function = _organization_directory_or_error().get_function(function_id)
if function is None or function.tenant_id != tenant_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization function not found.")
if function.status != "active":
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization function is not active.")
def _function_assignment_item(item: FunctionAssignment) -> FunctionAssignmentAdminItem:
return FunctionAssignmentAdminItem(
id=item.id,
@@ -1292,6 +1354,206 @@ def deactivate_function(
return None
@router.get("/external-function-role-mappings", response_model=ExternalFunctionRoleMappingListResponse)
def list_external_function_role_mappings(
tenant_id: str | None = None,
source_module: str | None = None,
function_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
query = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
if source_module:
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
if function_id:
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
items = (
session.query(ExternalFunctionRoleAssignment)
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
.all()
)
return ExternalFunctionRoleMappingListDeltaResponse(
mappings=[_external_function_role_mapping_item(item) for item in items],
deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
has_more=False,
full=True,
)
def _external_function_role_mappings_delta_response(
session: Session,
tenant: Tenant,
*,
since: str,
limit: int,
) -> ExternalFunctionRoleMappingListDeltaResponse:
entries, has_more = _access_delta_entries(
session,
tenant_id=tenant.id,
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
since=since,
limit=limit,
)
if entries is None:
return _full_external_function_role_mappings_delta_response(session, tenant)
changed_ids = _changed_ids(entries, "access_external_function_role_mapping")
visible = {
item.id: item
for item in (
session.query(ExternalFunctionRoleAssignment)
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id, ExternalFunctionRoleAssignment.id.in_(changed_ids))
.all()
if changed_ids
else []
)
}
deleted = [
_delta_deleted_item(entry)
for entry in entries
if entry.resource_type == "access_external_function_role_mapping" and entry.resource_id not in visible
]
return ExternalFunctionRoleMappingListDeltaResponse(
mappings=[_external_function_role_mapping_item(item) for item in visible.values()],
deleted=deleted,
watermark=_access_delta_response_watermark(
session,
tenant_id=tenant.id,
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
entries=entries,
has_more=has_more,
),
has_more=has_more,
full=False,
)
@router.get("/external-function-role-mappings/delta", response_model=ExternalFunctionRoleMappingListDeltaResponse)
def list_external_function_role_mappings_delta(
tenant_id: str | None = Query(default=None),
since: str | None = None,
limit: int = Query(default=500, ge=1, le=1000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
if since is None:
return _full_external_function_role_mappings_delta_response(session, tenant)
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
@router.post("/external-function-role-mappings", response_model=ExternalFunctionRoleMappingItem, status_code=status.HTTP_201_CREATED)
def create_external_function_role_mapping(
payload: ExternalFunctionRoleMappingCreateRequest,
tenant_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
source_module = payload.source_module.strip() or ORGANIZATIONS_MODULE_ID
function_id = payload.function_id.strip()
if not function_id:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function ID is required.")
_validate_external_function_source(tenant_id=tenant.id, source_module=source_module, function_id=function_id)
_validate_external_function_role(session, tenant_id=tenant.id, role_id=payload.role_id)
item = ExternalFunctionRoleAssignment(
tenant_id=tenant.id,
source_module=source_module,
function_id=function_id,
role_id=payload.role_id,
settings=payload.settings,
)
session.add(item)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This external function role mapping already exists.") from exc
_record_access_change(
session,
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
resource_type="access_external_function_role_mapping",
resource_id=item.id,
operation="created",
tenant_id=tenant.id,
principal=principal,
payload={"source_module": source_module, "function_id": function_id, "role_id": payload.role_id},
)
session.commit()
return _external_function_role_mapping_item(item)
@router.patch("/external-function-role-mappings/{mapping_id}", response_model=ExternalFunctionRoleMappingItem)
def update_external_function_role_mapping(
mapping_id: str,
payload: ExternalFunctionRoleMappingUpdateRequest,
tenant_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
item = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.id == mapping_id, ExternalFunctionRoleAssignment.tenant_id == tenant.id).one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="External function role mapping not found")
_validate_external_function_source(tenant_id=tenant.id, source_module=item.source_module, function_id=item.function_id)
if payload.role_id is not None:
_validate_external_function_role(session, tenant_id=tenant.id, role_id=payload.role_id)
item.role_id = payload.role_id
if payload.settings is not None:
item.settings = payload.settings
session.add(item)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This external function role mapping already exists.") from exc
_record_access_change(
session,
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
resource_type="access_external_function_role_mapping",
resource_id=item.id,
operation="updated",
tenant_id=tenant.id,
principal=principal,
payload={"source_module": item.source_module, "function_id": item.function_id, "role_id": item.role_id},
)
session.commit()
return _external_function_role_mapping_item(item)
@router.delete("/external-function-role-mappings/{mapping_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_external_function_role_mapping(
mapping_id: str,
tenant_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
item = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.id == mapping_id, ExternalFunctionRoleAssignment.tenant_id == tenant.id).one_or_none()
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="External function role mapping not found")
session.delete(item)
_record_access_change(
session,
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
resource_type="access_external_function_role_mapping",
resource_id=mapping_id,
operation="deleted",
tenant_id=tenant.id,
principal=principal,
payload={"source_module": item.source_module, "function_id": item.function_id, "role_id": item.role_id},
)
session.commit()
return None
@router.get("/function-assignments", response_model=FunctionAssignmentListResponse)
def list_function_assignments(
tenant_id: str | None = None,