from __future__ import annotations from collections.abc import Iterable from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_access.backend.admin.governance import ( assert_api_keys_allowed, assert_custom_groups_allowed, assert_custom_roles_allowed, ensure_group_mutation_allowed, ensure_role_mutation_allowed, ) from govoplan_access.backend.admin.service import ( AdminConflictError, AdminValidationError, assert_can_delegate_roles, assert_can_delegate_system_permissions, assert_can_delegate_system_roles, assert_can_delegate_tenant_permissions, assert_system_owner_exists, assert_tenant_owner_exists, create_custom_role, create_membership, create_system_role, delete_custom_role, delete_system_role, ensure_default_roles, get_or_create_account, set_group_members, set_group_roles, set_system_roles, set_user_groups, set_user_roles, slugify, tenant_owner_user_ids, update_custom_role, update_system_role, ) from govoplan_access.backend.api.v1.admin_common import ( _api_key_item, _group_summary, _http_admin_error, _require_permission, _require_system_role_assignment, _resolve_tenant, _role_summary, _set_system_memberships, _system_account_item, _user_item, ) from govoplan_access.backend.api.v1.admin_schemas import ( AdminApiKeyCreateRequest, AdminApiKeyCreateResponse, ApiKeyAdminItem, ApiKeyListDeltaResponse, ApiKeyListResponse, GroupCreateRequest, GroupListDeltaResponse, GroupListResponse, GroupSummary, GroupUpdateRequest, ExternalFunctionRoleMappingCreateRequest, ExternalFunctionRoleMappingItem, ExternalFunctionRoleMappingListDeltaResponse, ExternalFunctionRoleMappingListResponse, ExternalFunctionRoleMappingUpdateRequest, FunctionAdminItem, FunctionAssignmentAdminItem, FunctionAssignmentCreateRequest, FunctionAssignmentListResponse, FunctionAssignmentUpdateRequest, FunctionCreateRequest, FunctionDelegationAdminItem, FunctionDelegationCreateRequest, FunctionDelegationListResponse, FunctionDelegationUpdateRequest, FunctionListResponse, FunctionUpdateRequest, IdentityAdminItem, IdentityCreateRequest, IdentityListResponse, IdentityUpdateRequest, OrganizationUnitCreateRequest, OrganizationUnitItem, OrganizationUnitListResponse, OrganizationUnitUpdateRequest, ConfigurationChangeSafetyPlanRequest, ConfigurationChangeSafetyPlanResponse, ConfigurationChangeRequestApproveRequest, ConfigurationChangeRequestCreateRequest, ConfigurationChangeRequestResponse, ConfigurationControlDeltaResponse, ConfigurationControlSnapshotResponse, ConfigurationPackageApplyResponse, ConfigurationPackageCatalogResponse, ConfigurationPackageDryRunResponse, ConfigurationPackageExportRequest, ConfigurationPackageExportResponse, ConfigurationPackageRunRequest, ConfigurationSafetyCatalogResponse, ConfigurationSafetyFieldItem, PermissionCatalogResponse, PermissionItem, RoleCreateRequest, RoleListDeltaResponse, RoleListResponse, RoleSummary, RoleUpdateRequest, SystemAccountCreateRequest, SystemAccountCreateResponse, SystemAccountItem, SystemAccountListDeltaResponse, SystemAccountListResponse, SystemAccountMembershipsUpdateRequest, SystemAccountRolesUpdateRequest, SystemAccountUpdateRequest, UserCreateRequest, UserCreateResponse, UserAdminItem, UserListDeltaResponse, UserListResponse, UserUpdateRequest, ) from govoplan_access.backend.security.api_keys import create_api_key from govoplan_access.backend.security.sessions import collect_user_scopes from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope from govoplan_core.audit.logging import audit_event, audit_from_principal from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider from govoplan_access.backend.runtime import get_registry from govoplan_core.core.configuration_packages import ( CONFIGURATION_PROVIDER_CAPABILITY, ConfigurationExportSelection, ConfigurationPreflightContext, apply_configuration_package, dry_run_configuration_package, export_configuration_package, validate_configuration_package_catalog, ) from govoplan_core.core.configuration_control import ( CONFIGURATION_CHANGE_RECORD_RESOURCE, CONFIGURATION_CHANGE_REQUEST_RESOURCE, CONFIGURATION_CHANGES_COLLECTION, CONFIGURATION_CONTROL_MODULE_ID, ConfigurationControlError, approve_configuration_change_request, configuration_control_snapshot, create_configuration_change_request, ensure_configuration_change_allowed, 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, encode_sequence_watermark, max_sequence_id, record_change, sequence_entries_since, sequence_watermark_is_expired, ) from govoplan_access.backend.db.models import ( Account, ApiKey, ExternalFunctionRoleAssignment, Function, FunctionAssignment, FunctionDelegation, FunctionRoleAssignment, Group, GroupRoleAssignment, Identity, IdentityAccountLink, OrganizationUnit, Role, SystemRoleAssignment, Tenant, User, UserGroupMembership, UserRoleAssignment, ) from govoplan_access.backend.semantic import identity_id_for_account from govoplan_core.db.session import get_session from govoplan_core.security.permissions import ALL_PERMISSIONS, normalize_email, scopes_grant from govoplan_core.security.time import utc_now router = APIRouter(prefix="/admin", tags=["admin"]) ACCESS_MODULE_ID = "access" ACCESS_USERS_COLLECTION = "access.admin.users" ACCESS_GROUPS_COLLECTION = "access.admin.groups" 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" ACCESS_SYSTEM_ACCOUNTS_COLLECTION = "access.admin.system_accounts" ACCESS_API_KEYS_COLLECTION = "access.admin.api_keys" def _access_delta_watermark(session: Session, tenant_id: str | None, collections: tuple[str, ...]) -> str: return encode_sequence_watermark( max_sequence_id( session, tenant_id=tenant_id, module_id=ACCESS_MODULE_ID, collections=collections, ) ) def _access_delta_entries( session: Session, *, tenant_id: str | None, collections: tuple[str, ...], since: str, limit: int, ): try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if sequence_watermark_is_expired( session, since=since_sequence, tenant_id=tenant_id, module_id=ACCESS_MODULE_ID, collections=collections, ): return None, False entries_plus_one = sequence_entries_since( session, since=since_sequence, tenant_id=tenant_id, module_id=ACCESS_MODULE_ID, collections=collections, limit=limit + 1, ) has_more = len(entries_plus_one) > limit return entries_plus_one[:limit], has_more def _access_delta_response_watermark( session: Session, *, tenant_id: str | None, collections: tuple[str, ...], entries, has_more: bool, ) -> str: return ( encode_sequence_watermark(entries[-1].id) if has_more and entries else _access_delta_watermark(session, tenant_id, collections) ) def _delta_deleted_item(entry) -> DeltaDeletedItem: return DeltaDeletedItem( id=entry.resource_id, resource_type=entry.resource_type, revision=encode_sequence_watermark(entry.id), deleted_at=entry.created_at if entry.operation == "deleted" else None, ) def _changed_ids(entries, resource_type: str) -> list[str]: return list(dict.fromkeys(entry.resource_id for entry in entries if entry.resource_type == resource_type)) def _record_access_change( session: Session, *, collection: str, resource_type: str, resource_id: str | None, operation: str, tenant_id: str | None, principal: ApiPrincipal, payload: dict | None = None, ) -> None: if not resource_id: return record_change( session, module_id=ACCESS_MODULE_ID, collection=collection, resource_type=resource_type, resource_id=resource_id, operation=operation, tenant_id=tenant_id, actor_type="user", actor_id=principal.user.id, payload=payload or {}, ) def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None: if not any(has_scope(principal, scope) for scope in scopes): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}") def _identity_item(session: Session, identity: Identity) -> IdentityAdminItem: rows = ( session.query(IdentityAccountLink, Account) .join(Account, Account.id == IdentityAccountLink.account_id) .filter(IdentityAccountLink.identity_id == identity.id) .order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc()) .all() ) return IdentityAdminItem( id=identity.id, display_name=identity.display_name, external_subject=identity.external_subject, source=identity.source, is_active=identity.is_active, accounts=[ { "id": link.id, "account_id": account.id, "email": account.email, "display_name": account.display_name, "is_primary": link.is_primary, "source": link.source, } for link, account in rows ], created_at=identity.created_at, updated_at=identity.updated_at, ) def _set_identity_accounts( session: Session, *, identity: Identity, account_ids: list[str], primary_account_id: str | None, source: str, ) -> None: requested = list(dict.fromkeys(account_ids)) if primary_account_id and primary_account_id not in requested: requested.insert(0, primary_account_id) if not requested: session.query(IdentityAccountLink).filter(IdentityAccountLink.identity_id == identity.id).delete(synchronize_session=False) session.flush() return accounts = session.query(Account).filter(Account.id.in_(requested)).all() if len(accounts) != len(requested): raise AdminValidationError("One or more account IDs are unknown.") conflicting = ( session.query(IdentityAccountLink) .filter(IdentityAccountLink.account_id.in_(requested), IdentityAccountLink.identity_id != identity.id) .first() ) if conflicting is not None: raise AdminConflictError("One or more accounts are already linked to another identity.") primary = primary_account_id or requested[0] session.query(IdentityAccountLink).filter(IdentityAccountLink.identity_id == identity.id).delete(synchronize_session=False) for account_id in requested: session.add( IdentityAccountLink( identity_id=identity.id, account_id=account_id, is_primary=account_id == primary, source=source, ) ) session.flush() def _organization_unit_item(item: OrganizationUnit) -> OrganizationUnitItem: return OrganizationUnitItem( id=item.id, tenant_id=item.tenant_id, parent_id=item.parent_id, slug=item.slug, name=item.name, description=item.description, is_active=item.is_active, created_at=item.created_at, updated_at=item.updated_at, ) def _validate_parent_organization_unit( session: Session, *, tenant_id: str, parent_id: str | None, current_id: str | None = None, ) -> None: if parent_id is None: return if parent_id == current_id: raise AdminValidationError("An organization unit cannot be its own parent.") parent = session.query(OrganizationUnit).filter(OrganizationUnit.id == parent_id, OrganizationUnit.tenant_id == tenant_id).one_or_none() if parent is None: raise AdminValidationError("Parent organization unit does not belong to the tenant.") def _function_item(session: Session, item: Function) -> FunctionAdminItem: role_ids = [ row[0] for row in session.query(FunctionRoleAssignment.role_id) .filter(FunctionRoleAssignment.function_id == item.id) .order_by(FunctionRoleAssignment.created_at.asc()) .all() ] return FunctionAdminItem( id=item.id, tenant_id=item.tenant_id, organization_unit_id=item.organization_unit_id, slug=item.slug, name=item.name, description=item.description, role_ids=role_ids, delegable=item.delegable, act_in_place_allowed=item.act_in_place_allowed, is_active=item.is_active, created_at=item.created_at, updated_at=item.updated_at, ) def _set_function_roles(session: Session, *, function: Function, role_ids: list[str]) -> None: requested = sorted(set(role_ids)) roles = ( session.query(Role) .filter(Role.tenant_id == function.tenant_id, Role.id.in_(requested), Role.is_assignable.is_(True)) .all() if requested else [] ) if len(roles) != len(requested): raise AdminValidationError("One or more selected roles are invalid or not assignable.") session.query(FunctionRoleAssignment).filter(FunctionRoleAssignment.function_id == function.id).delete(synchronize_session=False) for role in roles: session.add(FunctionRoleAssignment(tenant_id=function.tenant_id, function_id=function.id, role_id=role.id)) 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, tenant_id=item.tenant_id, account_id=item.account_id, identity_id=item.identity_id, function_id=item.function_id, organization_unit_id=item.organization_unit_id, applies_to_subunits=item.applies_to_subunits, source=item.source, 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, is_active=item.is_active, created_at=item.created_at, updated_at=item.updated_at, ) def _validate_assignment_window(valid_from, valid_until) -> None: if valid_from is not None and valid_until is not None and valid_until <= valid_from: raise AdminValidationError("valid_until must be after valid_from.") def _function_delegation_item(item: FunctionDelegation) -> FunctionDelegationAdminItem: return FunctionDelegationAdminItem( id=item.id, tenant_id=item.tenant_id, function_assignment_id=item.function_assignment_id, delegator_account_id=item.delegator_account_id, delegate_account_id=item.delegate_account_id, mode=item.mode, # type: ignore[arg-type] reason=item.reason, valid_from=item.valid_from, valid_until=item.valid_until, revoked_at=item.revoked_at, is_active=item.is_active, created_at=item.created_at, updated_at=item.updated_at, ) def _record_many_access_changes( session: Session, *, collection: str, resource_type: str, resource_ids: Iterable[str], operation: str, tenant_id: str | None, principal: ApiPrincipal, payload: dict | None = None, ) -> None: for resource_id in sorted(set(resource_ids)): _record_access_change( session, collection=collection, resource_type=resource_type, resource_id=resource_id, operation=operation, tenant_id=tenant_id, principal=principal, payload=payload, ) def _current_user_group_ids(session: Session, user: User) -> set[str]: return { row[0] for row in session.query(UserGroupMembership.group_id) .filter(UserGroupMembership.tenant_id == user.tenant_id, UserGroupMembership.user_id == user.id) .all() } def _current_user_role_ids(session: Session, user: User) -> set[str]: return { row[0] for row in session.query(UserRoleAssignment.role_id) .filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id) .all() } def _current_group_member_ids(session: Session, group: Group) -> set[str]: return { row[0] for row in session.query(UserGroupMembership.user_id) .filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id) .all() } def _current_group_role_ids(session: Session, group: Group) -> set[str]: return { row[0] for row in session.query(GroupRoleAssignment.role_id) .filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id) .all() } def _tenant_user_ids_for_role(session: Session, role_id: str, tenant_id: str) -> set[str]: direct = { row[0] for row in session.query(UserRoleAssignment.user_id) .filter(UserRoleAssignment.tenant_id == tenant_id, UserRoleAssignment.role_id == role_id) .all() } via_groups = { row[0] for row in session.query(UserGroupMembership.user_id) .join(GroupRoleAssignment, GroupRoleAssignment.group_id == UserGroupMembership.group_id) .filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.role_id == role_id) .all() } return direct | via_groups def _tenant_group_ids_for_role(session: Session, role_id: str, tenant_id: str) -> set[str]: return { row[0] for row in session.query(GroupRoleAssignment.group_id) .filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.role_id == role_id) .all() } def _system_account_ids_for_role(session: Session, role_id: str) -> set[str]: return { row[0] for row in session.query(SystemRoleAssignment.account_id) .filter(SystemRoleAssignment.role_id == role_id) .all() } def _current_system_role_ids(session: Session, account: Account) -> set[str]: return { row[0] for row in session.query(SystemRoleAssignment.role_id) .filter(SystemRoleAssignment.account_id == account.id) .all() } @router.get("/permissions", response_model=PermissionCatalogResponse) def permission_catalog( principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "system:roles:read", "system:access:read", "system:governance:read")), ): items = [ PermissionItem( scope=item.scope, label=item.label, description=item.description, category=item.category, level=item.level, ) for item in ALL_PERMISSIONS if item.level == "tenant" or has_scope(principal, "system:roles:read") or has_scope(principal, "system:access:read") or has_scope(principal, "system:governance:read") ] return PermissionCatalogResponse(permissions=items) @router.get("/configuration-safety", response_model=ConfigurationSafetyCatalogResponse) def configuration_safety_catalog_endpoint( include_env_only: bool = False, principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): del principal return ConfigurationSafetyCatalogResponse(fields=[ConfigurationSafetyFieldItem(**item.to_dict()) for item in configuration_safety_catalog(include_env_only=include_env_only)]) @router.post("/configuration-safety/plan", response_model=ConfigurationChangeSafetyPlanResponse) def configuration_change_safety_plan( payload: ConfigurationChangeSafetyPlanRequest, principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): plan = plan_configuration_change( payload.key, actor_scopes=tuple(principal.scopes), value=payload.value, dry_run=payload.dry_run, maintenance_mode=payload.maintenance_mode, approval_count=payload.approval_count, include_env_only=True, ) return ConfigurationChangeSafetyPlanResponse(plan=plan.to_dict()) @router.get("/configuration-packages/catalog", response_model=ConfigurationPackageCatalogResponse) def configuration_package_catalog_validation( principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "system:settings:read", "system:governance:read")), ): del principal return ConfigurationPackageCatalogResponse(validation=validate_configuration_package_catalog()) @router.post("/configuration-packages/dry-run", response_model=ConfigurationPackageDryRunResponse) def configuration_package_dry_run_endpoint( payload: ConfigurationPackageRunRequest, principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): result = dry_run_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data)) return ConfigurationPackageDryRunResponse( diagnostics=[item.to_dict() for item in result.diagnostics], required_data=[item.to_dict() for item in result.required_data], plan=[item.to_dict() for item in result.plan], ) @router.post("/configuration-packages/apply", response_model=ConfigurationPackageApplyResponse) def configuration_package_apply_endpoint( payload: ConfigurationPackageRunRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:write", "admin:policies:write", "system:settings:write", "system:governance:write")), ): try: approval = ensure_configuration_change_allowed( session, key="configuration_packages.apply", value=payload.package, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), change_request_id=payload.change_request_id, target={"tenant_id": payload.tenant_id or principal.tenant_id}, ) except ConfigurationControlError as exc: raise _configuration_control_http_error(exc) from exc result = apply_configuration_package(payload.package, _configuration_providers(), _configuration_context(principal, tenant_id=payload.tenant_id, supplied_data=payload.supplied_data)) record_configuration_change_applied( session, key="configuration_packages.apply", before_value={}, after_value=payload.package, actor_user_id=principal.user.id, approval=approval, target={"tenant_id": payload.tenant_id or principal.tenant_id}, audit_event="configuration_package.applied", ) audit_from_principal( session, principal, action="configuration_package.applied", scope="system", object_type="configuration_package", object_id=approval.request.get("id") if approval.request else "direct", details={"tenant_id": payload.tenant_id or principal.tenant_id}, ) session.commit() return ConfigurationPackageApplyResponse( diagnostics=[item.to_dict() for item in result.diagnostics], created_refs=dict(result.created_refs), updated_refs=dict(result.updated_refs), ) @router.post("/configuration-packages/export", response_model=ConfigurationPackageExportResponse) def configuration_package_export_endpoint( payload: ConfigurationPackageExportRequest, principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): selection = ConfigurationExportSelection( tenant_id=payload.tenant_id, scopes=tuple(payload.scopes), module_ids=tuple(payload.module_ids), object_refs=tuple(payload.object_refs), ) result = export_configuration_package(_configuration_providers(), selection, _configuration_context(principal, tenant_id=payload.tenant_id)) return ConfigurationPackageExportResponse( fragments=[item.to_dict() for item in result.fragments], data_requirements=[item.to_dict() for item in result.data_requirements], diagnostics=[item.to_dict() for item in result.diagnostics], ) def _configuration_providers() -> tuple[object, ...]: registry = get_registry() if registry is not None and hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY): capability = registry.capability(ACCESS_CONFIGURATION_CAPABILITY) if capability is not None: return (capability,) return (SqlAccessConfigurationProvider(),) def _configuration_context(principal: ApiPrincipal, *, tenant_id: str | None = None, supplied_data: dict[str, Any] | None = None) -> ConfigurationPreflightContext: registry = get_registry() installed_modules: dict[str, str] = {"access": "0.1.6"} capabilities = {CONFIGURATION_PROVIDER_CAPABILITY, ACCESS_CONFIGURATION_CAPABILITY} if registry is not None and hasattr(registry, "manifests"): manifests = registry.manifests() installed_modules = {manifest.id: manifest.version for manifest in manifests} if hasattr(registry, "has_capability") and registry.has_capability(ACCESS_CONFIGURATION_CAPABILITY): capabilities.add(ACCESS_CONFIGURATION_CAPABILITY) return ConfigurationPreflightContext( tenant_id=tenant_id or principal.tenant_id, operator_user_id=principal.user.id, supplied_data=supplied_data or {}, installed_modules=installed_modules, capabilities=frozenset(capabilities), ) 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 _configuration_delta_watermark(session: Session) -> str: return encode_sequence_watermark( max_sequence_id( session, module_id=CONFIGURATION_CONTROL_MODULE_ID, collections=(CONFIGURATION_CHANGES_COLLECTION,), ) ) def _configuration_delta_entries(session: Session, *, since: str, limit: int): try: since_sequence = decode_sequence_watermark(since) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc if sequence_watermark_is_expired( session, since=since_sequence, module_id=CONFIGURATION_CONTROL_MODULE_ID, collections=(CONFIGURATION_CHANGES_COLLECTION,), ): return None, False entries_plus_one = sequence_entries_since( session, since=since_sequence, module_id=CONFIGURATION_CONTROL_MODULE_ID, collections=(CONFIGURATION_CHANGES_COLLECTION,), limit=limit + 1, ) has_more = len(entries_plus_one) > limit return entries_plus_one[:limit], has_more def _configuration_delta_response_watermark(session: Session, *, entries, has_more: bool) -> str: return encode_sequence_watermark(entries[-1].id) if has_more and entries else _configuration_delta_watermark(session) def _full_configuration_delta_response(session: Session) -> ConfigurationControlDeltaResponse: snapshot = configuration_control_snapshot(session) return ConfigurationControlDeltaResponse( requests=snapshot["requests"], history=snapshot["history"], deleted=[], watermark=_configuration_delta_watermark(session), has_more=False, full=True, ) @router.get("/configuration-changes", response_model=ConfigurationControlSnapshotResponse) def list_configuration_changes( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): del principal return ConfigurationControlSnapshotResponse(**configuration_control_snapshot(session)) @router.get("/configuration-changes/delta", response_model=ConfigurationControlDeltaResponse) def list_configuration_changes_delta( since: str | None = None, limit: int = Query(default=100, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:read", "admin:policies:read", "system:settings:read", "system:governance:read")), ): del principal if since is None: return _full_configuration_delta_response(session) entries, has_more = _configuration_delta_entries(session, since=since, limit=limit) if entries is None: return _full_configuration_delta_response(session) request_ids = { entry.resource_id for entry in entries if entry.resource_type == CONFIGURATION_CHANGE_REQUEST_RESOURCE } record_ids = { entry.resource_id for entry in entries if entry.resource_type == CONFIGURATION_CHANGE_RECORD_RESOURCE } snapshot = configuration_control_snapshot(session) return ConfigurationControlDeltaResponse( requests=[item for item in snapshot["requests"] if item.get("id") in request_ids], history=[item for item in snapshot["history"] if item.get("id") in record_ids], deleted=[], watermark=_configuration_delta_response_watermark(session, entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.post("/configuration-change-requests", response_model=ConfigurationChangeRequestResponse, status_code=status.HTTP_201_CREATED) def create_configuration_change_request_endpoint( payload: ConfigurationChangeRequestCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:write", "admin:policies:write", "system:settings:write", "system:governance:write")), ): try: request = create_configuration_change_request( session, key=payload.key, value=payload.value, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), dry_run=payload.dry_run, target=payload.target, reason=payload.reason, ) except ConfigurationControlError as exc: raise _configuration_control_http_error(exc) from exc audit_from_principal( session, principal, action="configuration_change.requested", scope="system", object_type="configuration_change_request", object_id=str(request["id"]), details={"key": payload.key, "target": payload.target, "dry_run": payload.dry_run}, ) session.commit() return ConfigurationChangeRequestResponse(request=request) @router.post("/configuration-change-requests/{request_id}/approve", response_model=ConfigurationChangeRequestResponse) def approve_configuration_change_request_endpoint( request_id: str, payload: ConfigurationChangeRequestApproveRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:settings:write", "admin:policies:write", "system:settings:write", "system:governance:write")), ): try: request = approve_configuration_change_request( session, request_id=request_id, actor_user_id=principal.user.id, actor_scopes=tuple(principal.scopes), reason=payload.reason, ) except ConfigurationControlError as exc: raise _configuration_control_http_error(exc) from exc audit_from_principal( session, principal, action="configuration_change.approved", scope="system", object_type="configuration_change_request", object_id=request_id, details={"key": request.get("key")}, ) session.commit() return ConfigurationChangeRequestResponse(request=request) @router.get("/identities", response_model=IdentityListResponse) def list_identities( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "access:account:read")), ): del principal identities = session.query(Identity).order_by(Identity.display_name.asc(), Identity.created_at.asc()).all() return IdentityListResponse(identities=[_identity_item(session, item) for item in identities]) @router.post("/identities", response_model=IdentityAdminItem, status_code=status.HTTP_201_CREATED) def create_identity( payload: IdentityCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:update", "access:account:update")), ): identity = Identity( display_name=payload.display_name.strip() if payload.display_name else None, external_subject=payload.external_subject.strip() if payload.external_subject else None, source=payload.source.strip() or "local", is_active=payload.is_active, ) session.add(identity) session.flush() try: _set_identity_accounts( session, identity=identity, account_ids=payload.account_ids, primary_account_id=payload.primary_account_id, source=identity.source, ) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_IDENTITIES_COLLECTION, resource_type="access_identity", resource_id=identity.id, operation="created", tenant_id=None, principal=principal, ) session.commit() return _identity_item(session, identity) @router.patch("/identities/{identity_id}", response_model=IdentityAdminItem) def update_identity( identity_id: str, payload: IdentityUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:update", "access:account:update")), ): identity = session.get(Identity, identity_id) if identity is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") if payload.display_name is not None: identity.display_name = payload.display_name.strip() or None if payload.external_subject is not None: identity.external_subject = payload.external_subject.strip() or None if payload.source is not None: identity.source = payload.source.strip() or "local" if payload.is_active is not None: identity.is_active = payload.is_active session.add(identity) session.flush() if payload.account_ids is not None or payload.primary_account_id is not None: existing = [ row[0] for row in session.query(IdentityAccountLink.account_id) .filter(IdentityAccountLink.identity_id == identity.id) .all() ] try: _set_identity_accounts( session, identity=identity, account_ids=payload.account_ids if payload.account_ids is not None else existing, primary_account_id=payload.primary_account_id, source=identity.source, ) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_IDENTITIES_COLLECTION, resource_type="access_identity", resource_id=identity.id, operation="updated", tenant_id=None, principal=principal, ) session.commit() return _identity_item(session, identity) @router.delete("/identities/{identity_id}", status_code=status.HTTP_204_NO_CONTENT) def deactivate_identity( identity_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:update", "access:account:update")), ): identity = session.get(Identity, identity_id) if identity is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Identity not found") identity.is_active = False session.add(identity) _record_access_change( session, collection=ACCESS_IDENTITIES_COLLECTION, resource_type="access_identity", resource_id=identity.id, operation="deleted", tenant_id=None, principal=principal, ) session.commit() return None @router.get("/organization-units", response_model=OrganizationUnitListResponse) def list_organization_units( tenant_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) items = ( session.query(OrganizationUnit) .filter(OrganizationUnit.tenant_id == tenant.id) .order_by(OrganizationUnit.name.asc()) .all() ) return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items]) @router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED) def create_organization_unit( payload: OrganizationUnitCreateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) slug = slugify(payload.slug) if not slug: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") if session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant.id, OrganizationUnit.slug == slug).count(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An organization unit with this slug already exists.") try: _validate_parent_organization_unit(session, tenant_id=tenant.id, parent_id=payload.parent_id) except AdminValidationError as exc: raise _http_admin_error(exc) from exc item = OrganizationUnit( tenant_id=tenant.id, parent_id=payload.parent_id, slug=slug, name=payload.name.strip(), description=payload.description.strip() if payload.description else None, is_active=payload.is_active, ) session.add(item) session.flush() _record_access_change( session, collection=ACCESS_ORGANIZATION_UNITS_COLLECTION, resource_type="access_organization_unit", resource_id=item.id, operation="created", tenant_id=tenant.id, principal=principal, ) session.commit() return _organization_unit_item(item) @router.patch("/organization-units/{organization_unit_id}", response_model=OrganizationUnitItem) def update_organization_unit( organization_unit_id: str, payload: OrganizationUnitUpdateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) item = session.query(OrganizationUnit).filter(OrganizationUnit.id == organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization unit not found") if payload.slug is not None: slug = slugify(payload.slug) if not slug: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") if session.query(OrganizationUnit).filter(OrganizationUnit.tenant_id == tenant.id, OrganizationUnit.slug == slug, OrganizationUnit.id != item.id).count(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="An organization unit with this slug already exists.") item.slug = slug if payload.parent_id is not None: try: _validate_parent_organization_unit(session, tenant_id=tenant.id, parent_id=payload.parent_id, current_id=item.id) except AdminValidationError as exc: raise _http_admin_error(exc) from exc item.parent_id = payload.parent_id if payload.name is not None: item.name = payload.name.strip() if payload.description is not None: item.description = payload.description.strip() or None if payload.is_active is not None: item.is_active = payload.is_active session.add(item) _record_access_change( session, collection=ACCESS_ORGANIZATION_UNITS_COLLECTION, resource_type="access_organization_unit", resource_id=item.id, operation="updated", tenant_id=tenant.id, principal=principal, ) session.commit() return _organization_unit_item(item) @router.delete("/organization-units/{organization_unit_id}", status_code=status.HTTP_204_NO_CONTENT) def deactivate_organization_unit( organization_unit_id: str, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) item = session.query(OrganizationUnit).filter(OrganizationUnit.id == organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Organization unit not found") item.is_active = False session.add(item) _record_access_change( session, collection=ACCESS_ORGANIZATION_UNITS_COLLECTION, resource_type="access_organization_unit", resource_id=item.id, operation="deleted", tenant_id=tenant.id, principal=principal, ) session.commit() return None @router.get("/functions", response_model=FunctionListResponse) def list_functions( tenant_id: str | None = None, organization_unit_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(Function).filter(Function.tenant_id == tenant.id) if organization_unit_id: query = query.filter(Function.organization_unit_id == organization_unit_id) functions = query.order_by(Function.name.asc()).all() return FunctionListResponse(functions=[_function_item(session, item) for item in functions]) @router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED) def create_function( payload: FunctionCreateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == payload.organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() if unit is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") slug = slugify(payload.slug) if not slug: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") if session.query(Function).filter(Function.tenant_id == tenant.id, Function.organization_unit_id == unit.id, Function.slug == slug).count(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A function with this slug already exists in the organization unit.") function = Function( tenant_id=tenant.id, organization_unit_id=unit.id, slug=slug, name=payload.name.strip(), description=payload.description.strip() if payload.description else None, delegable=payload.delegable, act_in_place_allowed=payload.act_in_place_allowed, is_active=payload.is_active, ) session.add(function) session.flush() try: _set_function_roles(session, function=function, role_ids=payload.role_ids) except AdminValidationError as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_FUNCTIONS_COLLECTION, resource_type="access_function", resource_id=function.id, operation="created", tenant_id=tenant.id, principal=principal, ) session.commit() return _function_item(session, function) @router.patch("/functions/{function_id}", response_model=FunctionAdminItem) def update_function( function_id: str, payload: FunctionUpdateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) function = session.query(Function).filter(Function.id == function_id, Function.tenant_id == tenant.id).one_or_none() if function is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") if payload.organization_unit_id is not None: unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == payload.organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() if unit is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") function.organization_unit_id = unit.id if payload.slug is not None: slug = slugify(payload.slug) if not slug: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Enter a slug.") if session.query(Function).filter(Function.tenant_id == tenant.id, Function.organization_unit_id == function.organization_unit_id, Function.slug == slug, Function.id != function.id).count(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A function with this slug already exists in the organization unit.") function.slug = slug if payload.name is not None: function.name = payload.name.strip() if payload.description is not None: function.description = payload.description.strip() or None if payload.delegable is not None: function.delegable = payload.delegable if payload.act_in_place_allowed is not None: function.act_in_place_allowed = payload.act_in_place_allowed if payload.is_active is not None: function.is_active = payload.is_active session.add(function) session.flush() if payload.role_ids is not None: try: _set_function_roles(session, function=function, role_ids=payload.role_ids) except AdminValidationError as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_FUNCTIONS_COLLECTION, resource_type="access_function", resource_id=function.id, operation="updated", tenant_id=tenant.id, principal=principal, ) session.commit() return _function_item(session, function) @router.delete("/functions/{function_id}", status_code=status.HTTP_204_NO_CONTENT) def deactivate_function( function_id: str, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) function = session.query(Function).filter(Function.id == function_id, Function.tenant_id == tenant.id).one_or_none() if function is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") function.is_active = False session.add(function) _record_access_change( session, collection=ACCESS_FUNCTIONS_COLLECTION, resource_type="access_function", resource_id=function.id, operation="deleted", tenant_id=tenant.id, principal=principal, ) session.commit() 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, account_id: 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:function:assign")), ): tenant = _resolve_tenant(session, principal, tenant_id) query = session.query(FunctionAssignment).filter(FunctionAssignment.tenant_id == tenant.id) if account_id: query = query.filter(FunctionAssignment.account_id == account_id) if function_id: query = query.filter(FunctionAssignment.function_id == function_id) assignments = query.order_by(FunctionAssignment.created_at.desc()).all() return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments]) @router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED) def create_function_assignment( payload: FunctionAssignmentCreateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:assign")), ): tenant = _resolve_tenant(session, principal, tenant_id) account = session.get(Account, payload.account_id) if account is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Account not found") function = session.query(Function).filter(Function.id == payload.function_id, Function.tenant_id == tenant.id).one_or_none() if function is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function not found") organization_unit_id = payload.organization_unit_id or function.organization_unit_id unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() if unit is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") identity_id = payload.identity_id or identity_id_for_account(session, account.id) if identity_id is not None and session.get(Identity, identity_id) is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Identity not found") try: _validate_assignment_window(payload.valid_from, payload.valid_until) except AdminValidationError as exc: raise _http_admin_error(exc) from exc assignment = FunctionAssignment( tenant_id=tenant.id, account_id=account.id, identity_id=identity_id, function_id=function.id, organization_unit_id=unit.id, applies_to_subunits=payload.applies_to_subunits, source=payload.source.strip() or "direct", 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, ) session.add(assignment) try: session.flush() except IntegrityError as exc: raise _http_admin_error(AdminConflictError("This function assignment already exists for the account and scope.")) from exc _record_access_change( session, collection=ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION, resource_type="access_function_assignment", resource_id=assignment.id, operation="created", tenant_id=tenant.id, principal=principal, ) session.commit() return _function_assignment_item(assignment) @router.patch("/function-assignments/{assignment_id}", response_model=FunctionAssignmentAdminItem) def update_function_assignment( assignment_id: str, payload: FunctionAssignmentUpdateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:assign")), ): tenant = _resolve_tenant(session, principal, tenant_id) assignment = session.query(FunctionAssignment).filter(FunctionAssignment.id == assignment_id, FunctionAssignment.tenant_id == tenant.id).one_or_none() if assignment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function assignment not found") if payload.identity_id is not None: if session.get(Identity, payload.identity_id) is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Identity not found") assignment.identity_id = payload.identity_id if payload.organization_unit_id is not None: unit = session.query(OrganizationUnit).filter(OrganizationUnit.id == payload.organization_unit_id, OrganizationUnit.tenant_id == tenant.id).one_or_none() if unit is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization unit not found") assignment.organization_unit_id = unit.id if payload.applies_to_subunits is not None: assignment.applies_to_subunits = payload.applies_to_subunits if payload.source is not None: assignment.source = payload.source.strip() or "direct" if payload.delegated_from_assignment_id is not None: assignment.delegated_from_assignment_id = payload.delegated_from_assignment_id if payload.acting_for_account_id is not None: assignment.acting_for_account_id = payload.acting_for_account_id if payload.valid_from is not None: assignment.valid_from = payload.valid_from if payload.valid_until is not None: assignment.valid_until = payload.valid_until if payload.is_active is not None: assignment.is_active = payload.is_active try: _validate_assignment_window(assignment.valid_from, assignment.valid_until) except AdminValidationError as exc: raise _http_admin_error(exc) from exc session.add(assignment) _record_access_change( session, collection=ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION, resource_type="access_function_assignment", resource_id=assignment.id, operation="updated", tenant_id=tenant.id, principal=principal, ) session.commit() return _function_assignment_item(assignment) @router.delete("/function-assignments/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT) def deactivate_function_assignment( assignment_id: str, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:assign")), ): tenant = _resolve_tenant(session, principal, tenant_id) assignment = session.query(FunctionAssignment).filter(FunctionAssignment.id == assignment_id, FunctionAssignment.tenant_id == tenant.id).one_or_none() if assignment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function assignment not found") assignment.is_active = False session.add(assignment) _record_access_change( session, collection=ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION, resource_type="access_function_assignment", resource_id=assignment.id, operation="deleted", tenant_id=tenant.id, principal=principal, ) session.commit() return None @router.get("/function-delegations", response_model=FunctionDelegationListResponse) def list_function_delegations( tenant_id: str | None = None, account_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")), ): tenant = _resolve_tenant(session, principal, tenant_id) query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id) if account_id: query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id)) delegations = query.order_by(FunctionDelegation.created_at.desc()).all() return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations]) @router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED) def create_function_delegation( payload: FunctionDelegationCreateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:delegate")), ): tenant = _resolve_tenant(session, principal, tenant_id) assignment = session.query(FunctionAssignment).filter(FunctionAssignment.id == payload.function_assignment_id, FunctionAssignment.tenant_id == tenant.id).one_or_none() if assignment is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function assignment not found") function = session.query(Function).filter(Function.id == assignment.function_id, Function.tenant_id == tenant.id).one_or_none() if function is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function not found") if payload.mode == "delegate" and not function.delegable: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This function cannot be delegated.") if payload.mode == "act_in_place" and not function.act_in_place_allowed: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This function does not permit acting in place.") delegate = session.get(Account, payload.delegate_account_id) if delegate is None: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Delegate account not found") if delegate.id == assignment.account_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Delegate account must differ from delegator account.") try: _validate_assignment_window(payload.valid_from, payload.valid_until) except AdminValidationError as exc: raise _http_admin_error(exc) from exc delegation = FunctionDelegation( tenant_id=tenant.id, function_assignment_id=assignment.id, delegator_account_id=assignment.account_id, delegate_account_id=delegate.id, mode=payload.mode, reason=payload.reason.strip() if payload.reason else None, valid_from=payload.valid_from, valid_until=payload.valid_until, is_active=payload.is_active, ) session.add(delegation) try: session.flush() except IntegrityError as exc: raise _http_admin_error(AdminConflictError("This function delegation already exists.")) from exc _record_access_change( session, collection=ACCESS_FUNCTION_DELEGATIONS_COLLECTION, resource_type="access_function_delegation", resource_id=delegation.id, operation="created", tenant_id=tenant.id, principal=principal, ) session.commit() return _function_delegation_item(delegation) @router.patch("/function-delegations/{delegation_id}", response_model=FunctionDelegationAdminItem) def update_function_delegation( delegation_id: str, payload: FunctionDelegationUpdateRequest, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:delegate")), ): tenant = _resolve_tenant(session, principal, tenant_id) delegation = session.query(FunctionDelegation).filter(FunctionDelegation.id == delegation_id, FunctionDelegation.tenant_id == tenant.id).one_or_none() if delegation is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function delegation not found") if payload.reason is not None: delegation.reason = payload.reason.strip() or None if payload.valid_from is not None: delegation.valid_from = payload.valid_from if payload.valid_until is not None: delegation.valid_until = payload.valid_until if payload.is_active is not None: delegation.is_active = payload.is_active if payload.revoked is True: delegation.revoked_at = utc_now() delegation.is_active = False try: _validate_assignment_window(delegation.valid_from, delegation.valid_until) except AdminValidationError as exc: raise _http_admin_error(exc) from exc session.add(delegation) _record_access_change( session, collection=ACCESS_FUNCTION_DELEGATIONS_COLLECTION, resource_type="access_function_delegation", resource_id=delegation.id, operation="updated", tenant_id=tenant.id, principal=principal, ) session.commit() return _function_delegation_item(delegation) @router.delete("/function-delegations/{delegation_id}", status_code=status.HTTP_204_NO_CONTENT) def revoke_function_delegation( delegation_id: str, tenant_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:assign", "access:function:delegate")), ): tenant = _resolve_tenant(session, principal, tenant_id) delegation = session.query(FunctionDelegation).filter(FunctionDelegation.id == delegation_id, FunctionDelegation.tenant_id == tenant.id).one_or_none() if delegation is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function delegation not found") delegation.revoked_at = utc_now() delegation.is_active = False session.add(delegation) _record_access_change( session, collection=ACCESS_FUNCTION_DELEGATIONS_COLLECTION, resource_type="access_function_delegation", resource_id=delegation.id, operation="deleted", tenant_id=tenant.id, principal=principal, ) session.commit() return None def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse: users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() owner_ids = tenant_owner_user_ids(session, tenant.id) return UserListDeltaResponse( users=[_user_item(session, user, owner_ids=owner_ids) for user in users], deleted=[], watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), has_more=False, full=True, ) def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> UserListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), since=since, limit=limit) if entries is None: return _full_users_delta_response(session, tenant) changed_ids = _changed_ids(entries, "access_user") visible = { user.id: user for user in ( session.query(User).filter(User.tenant_id == tenant.id, User.id.in_(changed_ids)).all() if changed_ids else [] ) } owner_ids = tenant_owner_user_ids(session, tenant.id) deleted = [ _delta_deleted_item(entry) for entry in entries if entry.resource_type == "access_user" and entry.resource_id not in visible ] return UserListDeltaResponse( users=[_user_item(session, user, owner_ids=owner_ids) for user in visible.values()], deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/users/delta", response_model=UserListDeltaResponse) def list_users_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_scope("admin:users:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) if since is None: return _full_users_delta_response(session, tenant) return _users_delta_response(session, tenant, since=since, limit=limit) @router.get("/users", response_model=UserListResponse) def list_users( tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:users:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all() owner_ids = tenant_owner_user_ids(session, tenant.id) return UserListResponse(users=[_user_item(session, user, owner_ids=owner_ids) for user in users]) @router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED) def create_user( payload: UserCreateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): _require_permission(principal, "admin:users:create") if payload.group_ids: _require_permission(principal, "admin:groups:manage_members") if payload.role_ids: _require_permission(principal, "admin:roles:assign") tenant = _resolve_tenant(session, principal, tenant_id) try: result = create_membership( session, tenant=tenant, email=payload.email, display_name=payload.display_name, password=payload.password, password_reset_required=payload.password_reset_required, is_active=payload.is_active, ) set_user_groups(session, user=result.user, group_ids=payload.group_ids) assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id) set_user_roles(session, user=result.user, role_ids=payload.role_ids) assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_id=result.user.id, operation="created", tenant_id=tenant.id, principal=principal, ) if result.account_created: _record_access_change( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, resource_type="access_system_account", resource_id=result.account.id, operation="created", tenant_id=None, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_ids=payload.group_ids, operation="updated", tenant_id=tenant.id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_ids=payload.role_ids, operation="updated", tenant_id=tenant.id, principal=principal, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="user.created", object_type="user", object_id=result.user.id, details={ "email": result.account.email, "account_created": result.account_created, "group_ids": payload.group_ids, "role_ids": payload.role_ids, }, ) session.commit() return UserCreateResponse( user=_user_item(session, result.user), account_created=result.account_created, temporary_password=result.temporary_password, ) @router.patch("/users/{user_id}", response_model=UserAdminItem) def update_user( user_id: str, payload: UserUpdateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): if "display_name" in payload.model_fields_set: _require_permission(principal, "admin:users:update") if payload.is_active is not None: _require_permission(principal, "admin:users:suspend") if payload.group_ids is not None: _require_permission(principal, "admin:groups:manage_members") if payload.role_ids is not None: _require_permission(principal, "admin:roles:assign") tenant = _resolve_tenant(session, principal, tenant_id) user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") previous_group_ids = _current_user_group_ids(session, user) if payload.group_ids is not None else set() previous_role_ids = _current_user_role_ids(session, user) if payload.role_ids is not None else set() try: if payload.display_name is not None: user.display_name = payload.display_name.strip() or None if payload.is_active is not None: user.is_active = payload.is_active session.add(user) if payload.group_ids is not None: set_user_groups(session, user=user, group_ids=payload.group_ids) if payload.role_ids is not None: assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id) set_user_roles(session, user=user, role_ids=payload.role_ids) assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_id=user.id, operation="updated", tenant_id=tenant.id, principal=principal, ) if payload.group_ids is not None: _record_many_access_changes( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_ids=previous_group_ids | set(payload.group_ids), operation="updated", tenant_id=tenant.id, principal=principal, ) if payload.role_ids is not None: _record_many_access_changes( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_ids=previous_role_ids | set(payload.role_ids), operation="updated", tenant_id=tenant.id, principal=principal, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="user.updated", object_type="user", object_id=user.id, details=payload.model_dump(exclude_none=True), ) session.commit() return _user_item(session, user) def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse: groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() return GroupListDeltaResponse( groups=[_group_summary(session, group) for group in groups], deleted=[], watermark=_access_delta_watermark(session, tenant.id, (ACCESS_GROUPS_COLLECTION,)), has_more=False, full=True, ) def _groups_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> GroupListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), since=since, limit=limit) if entries is None: return _full_groups_delta_response(session, tenant) changed_ids = _changed_ids(entries, "access_group") visible = { group.id: group for group in ( session.query(Group).filter(Group.tenant_id == tenant.id, Group.id.in_(changed_ids)).all() if changed_ids else [] ) } deleted = [ _delta_deleted_item(entry) for entry in entries if entry.resource_type == "access_group" and entry.resource_id not in visible ] return GroupListDeltaResponse( groups=[_group_summary(session, group) for group in visible.values()], deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_GROUPS_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/groups/delta", response_model=GroupListDeltaResponse) def list_groups_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_scope("admin:groups:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) if since is None: return _full_groups_delta_response(session, tenant) return _groups_delta_response(session, tenant, since=since, limit=limit) @router.get("/groups", response_model=GroupListResponse) def list_groups( tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:groups:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) groups = session.query(Group).filter(Group.tenant_id == tenant.id).order_by(Group.name.asc()).all() return GroupListResponse(groups=[_group_summary(session, group) for group in groups]) @router.post("/groups", response_model=GroupSummary, status_code=status.HTTP_201_CREATED) def create_group( payload: GroupCreateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): _require_permission(principal, "admin:groups:write") if payload.member_ids: _require_permission(principal, "admin:groups:manage_members") if payload.role_ids: _require_permission(principal, "admin:roles:assign") tenant = _resolve_tenant(session, principal, tenant_id) try: assert_custom_groups_allowed(session, tenant) except AdminConflictError as exc: raise _http_admin_error(exc) from exc group_slug = slugify(payload.slug) if session.query(Group).filter(Group.tenant_id == tenant.id, Group.slug == group_slug).count(): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A group with this slug already exists") group = Group( tenant_id=tenant.id, slug=group_slug, name=payload.name.strip(), description=payload.description.strip() if payload.description else None, is_active=payload.is_active, ) session.add(group) session.flush() try: set_group_members(session, group=group, user_ids=payload.member_ids) assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id) set_group_roles(session, group=group, role_ids=payload.role_ids) assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_id=group.id, operation="created", tenant_id=tenant.id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_ids=payload.member_ids, operation="updated", tenant_id=tenant.id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_ids=payload.role_ids, operation="updated", tenant_id=tenant.id, principal=principal, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="group.created", object_type="group", object_id=group.id, details={"slug": group.slug, "member_ids": payload.member_ids, "role_ids": payload.role_ids}, ) session.commit() return _group_summary(session, group) @router.patch("/groups/{group_id}", response_model=GroupSummary) def update_group( group_id: str, payload: GroupUpdateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): if any(field in payload.model_fields_set for field in ("name", "description", "is_active")): _require_permission(principal, "admin:groups:write") if payload.member_ids is not None: _require_permission(principal, "admin:groups:manage_members") if payload.role_ids is not None: _require_permission(principal, "admin:roles:assign") tenant = _resolve_tenant(session, principal, tenant_id) group = session.query(Group).filter(Group.id == group_id, Group.tenant_id == tenant.id).one_or_none() if group is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") previous_member_ids = _current_group_member_ids(session, group) if payload.member_ids is not None else set() previous_role_ids = _current_group_role_ids(session, group) if payload.role_ids is not None else set() try: ensure_group_mutation_allowed(group, requested_active=payload.is_active) if group.system_template_id is None and payload.name is not None: group.name = payload.name.strip() if group.system_template_id is None and "description" in payload.model_fields_set: group.description = payload.description.strip() if payload.description and payload.description.strip() else None if payload.is_active is not None: group.is_active = payload.is_active session.add(group) if payload.member_ids is not None: set_group_members(session, group=group, user_ids=payload.member_ids) if payload.role_ids is not None: assert_can_delegate_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids, tenant_id=tenant.id) set_group_roles(session, group=group, role_ids=payload.role_ids) assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_id=group.id, operation="updated", tenant_id=tenant.id, principal=principal, ) if payload.member_ids is not None: _record_many_access_changes( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_ids=previous_member_ids | set(payload.member_ids), operation="updated", tenant_id=tenant.id, principal=principal, ) if payload.role_ids is not None: _record_many_access_changes( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_ids=previous_role_ids | set(payload.role_ids), operation="updated", tenant_id=tenant.id, principal=principal, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="group.updated", object_type="group", object_id=group.id, details=payload.model_dump(exclude_unset=True), ) session.commit() return _group_summary(session, group) def _full_roles_delta_response(session: Session, tenant: Tenant) -> RoleListDeltaResponse: ensure_default_roles(session, tenant) session.commit() roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() return RoleListDeltaResponse( roles=[_role_summary(session, role) for role in roles], deleted=[], watermark=_access_delta_watermark(session, tenant.id, (ACCESS_ROLES_COLLECTION,)), has_more=False, full=True, ) def _roles_delta_response(session: Session, tenant: Tenant, *, since: str, limit: int) -> RoleListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), since=since, limit=limit) if entries is None: return _full_roles_delta_response(session, tenant) changed_ids = _changed_ids(entries, "access_role") visible = { role.id: role for role in ( session.query(Role).filter(Role.tenant_id == tenant.id, Role.id.in_(changed_ids)).all() if changed_ids else [] ) } deleted = [ _delta_deleted_item(entry) for entry in entries if entry.resource_type == "access_role" and entry.resource_id not in visible ] return RoleListDeltaResponse( roles=[_role_summary(session, role) for role in visible.values()], deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_ROLES_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/roles/delta", response_model=RoleListDeltaResponse) def list_roles_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_scope("admin:roles:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) if since is None: return _full_roles_delta_response(session, tenant) return _roles_delta_response(session, tenant, since=since, limit=limit) @router.get("/roles", response_model=RoleListResponse) def list_roles( tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:roles:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) ensure_default_roles(session, tenant) session.commit() roles = session.query(Role).filter(Role.tenant_id == tenant.id).order_by(Role.is_builtin.desc(), Role.name.asc()).all() return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) @router.post("/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) def create_role( payload: RoleCreateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:roles:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) try: assert_custom_roles_allowed(session, tenant) assert_can_delegate_tenant_permissions(principal.scopes, payload.permissions) role = create_custom_role( session, tenant=tenant, slug=payload.slug, name=payload.name, description=payload.description, permissions=payload.permissions, ) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_id=role.id, operation="created", tenant_id=tenant.id, principal=principal, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="role.created", object_type="role", object_id=role.id, details={"slug": role.slug, "permissions": role.permissions}, ) session.commit() return _role_summary(session, role) @router.patch("/roles/{role_id}", response_model=RoleSummary) def update_role( role_id: str, payload: RoleUpdateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:roles:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) role = session.query(Role).filter(Role.id == role_id, Role.tenant_id == tenant.id).one_or_none() if role is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") affected_user_ids = _tenant_user_ids_for_role(session, role.id, tenant.id) affected_group_ids = _tenant_group_ids_for_role(session, role.id, tenant.id) try: ensure_role_mutation_allowed(role, requested_assignable=payload.is_assignable) if payload.permissions is not None: assert_can_delegate_tenant_permissions(principal.scopes, payload.permissions) update_custom_role( session, role=role, name=payload.name, description=payload.description, permissions=payload.permissions, is_assignable=payload.is_assignable, ) assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_id=role.id, operation="updated", tenant_id=tenant.id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_ids=affected_user_ids, operation="updated", tenant_id=tenant.id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_ids=affected_group_ids, operation="updated", tenant_id=tenant.id, principal=principal, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="role.updated", object_type="role", object_id=role.id, details=payload.model_dump(), ) session.commit() return _role_summary(session, role) @router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_role( role_id: str, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:roles:write")), ): tenant = _resolve_tenant(session, principal, tenant_id) role = session.query(Role).filter(Role.id == role_id, Role.tenant_id == tenant.id).one_or_none() if role is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found") try: if role.system_template_id: raise AdminConflictError("Centrally managed roles can only be removed from System administration.") role_slug = role.slug delete_custom_role(session, role) assert_tenant_owner_exists(session, tenant.id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_id=role_id, operation="deleted", tenant_id=tenant.id, principal=principal, payload={"slug": role_slug}, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="role.deleted", object_type="role", object_id=role_id, details={"slug": role_slug}, ) session.commit() return None def _full_system_roles_delta_response(session: Session) -> RoleListDeltaResponse: ensure_default_roles(session, None) session.commit() roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() return RoleListDeltaResponse( roles=[_role_summary(session, role) for role in roles], deleted=[], watermark=_access_delta_watermark(session, None, (ACCESS_SYSTEM_ROLES_COLLECTION,)), has_more=False, full=True, ) def _system_roles_delta_response(session: Session, *, since: str, limit: int) -> RoleListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), since=since, limit=limit) if entries is None: return _full_system_roles_delta_response(session) changed_ids = _changed_ids(entries, "access_system_role") visible = { role.id: role for role in ( session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(changed_ids)).all() if changed_ids else [] ) } deleted = [ _delta_deleted_item(entry) for entry in entries if entry.resource_type == "access_system_role" and entry.resource_id not in visible ] return RoleListDeltaResponse( roles=[_role_summary(session, role) for role in visible.values()], deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=None, collections=(ACCESS_SYSTEM_ROLES_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/system/roles/delta", response_model=RoleListDeltaResponse) def list_system_roles_delta( since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), ): del principal if since is None: return _full_system_roles_delta_response(session) return _system_roles_delta_response(session, since=since, limit=limit) @router.get("/system/roles", response_model=RoleListResponse) def list_system_roles( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:roles:read", "system:access:read")), ): ensure_default_roles(session, None) session.commit() roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() return RoleListResponse(roles=[_role_summary(session, role) for role in roles]) @router.post("/system/roles", response_model=RoleSummary, status_code=status.HTTP_201_CREATED) def create_system_role_endpoint( payload: RoleCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:roles:write")), ): try: assert_can_delegate_system_permissions(principal.scopes, payload.permissions) role = create_system_role( session, slug=payload.slug, name=payload.name, description=payload.description, permissions=payload.permissions, ) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ROLES_COLLECTION, resource_type="access_system_role", resource_id=role.id, operation="created", tenant_id=None, principal=principal, ) audit_from_principal( session, principal, action="system_role.created", scope="system", object_type="role", object_id=role.id, details={"slug": role.slug, "permissions": role.permissions}, ) session.commit() return _role_summary(session, role) @router.patch("/system/roles/{role_id}", response_model=RoleSummary) def update_system_role_endpoint( role_id: str, payload: RoleUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:roles:write")), ): role = session.query(Role).filter(Role.id == role_id, Role.tenant_id.is_(None)).one_or_none() if role is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="System role not found") affected_account_ids = _system_account_ids_for_role(session, role.id) try: assert_can_delegate_system_permissions(principal.scopes, payload.permissions) update_system_role( session, role=role, name=payload.name, description=payload.description, permissions=payload.permissions, is_assignable=payload.is_assignable, ) assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ROLES_COLLECTION, resource_type="access_system_role", resource_id=role.id, operation="updated", tenant_id=None, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, resource_type="access_system_account", resource_ids=affected_account_ids, operation="updated", tenant_id=None, principal=principal, ) audit_from_principal( session, principal, action="system_role.updated", scope="system", object_type="role", object_id=role.id, details=payload.model_dump(), ) session.commit() return _role_summary(session, role) @router.delete("/system/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_system_role_endpoint( role_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:roles:write")), ): role = session.query(Role).filter(Role.id == role_id, Role.tenant_id.is_(None)).one_or_none() if role is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="System role not found") try: role_slug = role.slug delete_system_role(session, role) assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ROLES_COLLECTION, resource_type="access_system_role", resource_id=role_id, operation="deleted", tenant_id=None, principal=principal, payload={"slug": role_slug}, ) audit_from_principal( session, principal, action="system_role.deleted", scope="system", object_type="role", object_id=role_id, details={"slug": role_slug}, ) session.commit() return None _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_SYSTEM_ROLES_COLLECTION) def _full_system_accounts_delta_response(session: Session) -> SystemAccountListDeltaResponse: ensure_default_roles(session, None) session.commit() system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() accounts = session.query(Account).order_by(Account.email.asc()).all() return SystemAccountListDeltaResponse( accounts=[_system_account_item(session, account) for account in accounts], roles=[_role_summary(session, role) for role in system_roles], deleted=[], watermark=_access_delta_watermark(session, None, _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS), has_more=False, full=True, ) def _system_accounts_delta_response(session: Session, *, since: str, limit: int) -> SystemAccountListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, since=since, limit=limit) if entries is None: return _full_system_accounts_delta_response(session) account_ids = _changed_ids(entries, "access_system_account") role_ids = _changed_ids(entries, "access_system_role") visible_accounts = { account.id: account for account in ( session.query(Account).filter(Account.id.in_(account_ids)).all() if account_ids else [] ) } visible_roles = { role.id: role for role in ( session.query(Role).filter(Role.tenant_id.is_(None), Role.id.in_(role_ids)).all() if role_ids else [] ) } deleted = [ _delta_deleted_item(entry) for entry in entries if ( (entry.resource_type == "access_system_account" and entry.resource_id not in visible_accounts) or (entry.resource_type == "access_system_role" and entry.resource_id not in visible_roles) ) ] return SystemAccountListDeltaResponse( accounts=[_system_account_item(session, account) for account in visible_accounts.values()], roles=[_role_summary(session, role) for role in visible_roles.values()], deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/system/accounts/delta", response_model=SystemAccountListDeltaResponse) def list_system_accounts_delta( since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), ): del principal if since is None: return _full_system_accounts_delta_response(session) return _system_accounts_delta_response(session, since=since, limit=limit) @router.get("/system/accounts", response_model=SystemAccountListResponse) def list_system_accounts( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:accounts:read", "system:access:read")), ): ensure_default_roles(session, None) session.commit() system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all() accounts = session.query(Account).order_by(Account.email.asc()).all() return SystemAccountListResponse( accounts=[_system_account_item(session, account) for account in accounts], roles=[_role_summary(session, role) for role in system_roles], ) @router.patch("/system/accounts/{account_id}", response_model=SystemAccountItem) def update_system_account( account_id: str, payload: SystemAccountUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ): if "display_name" in payload.model_fields_set and not has_scope(principal, "system:accounts:update"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:update") if payload.is_active is not None and not has_scope(principal, "system:accounts:suspend"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:accounts:suspend") if payload.role_ids is not None: _require_system_role_assignment(principal) account = session.get(Account, account_id) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") previous_role_ids = _current_system_role_ids(session, account) if payload.role_ids is not None else set() if "display_name" in payload.model_fields_set: account.display_name = payload.display_name.strip() if payload.display_name else None if payload.is_active is not None: account.is_active = payload.is_active session.add(account) try: if payload.role_ids is not None: _require_system_role_assignment(principal) assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids) set_system_roles(session, account=account, role_ids=payload.role_ids) else: session.flush() assert_system_owner_exists(session) if not account.is_active: tenant_ids = [ row[0] for row in session.query(User.tenant_id) .join(Tenant, Tenant.id == User.tenant_id) .filter(User.account_id == account.id, User.is_active.is_(True), Tenant.is_active.is_(True)) .distinct() .all() ] for tenant_id in tenant_ids: assert_tenant_owner_exists(session, tenant_id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, resource_type="access_system_account", resource_id=account.id, operation="updated", tenant_id=None, principal=principal, ) if payload.role_ids is not None: _record_many_access_changes( session, collection=ACCESS_SYSTEM_ROLES_COLLECTION, resource_type="access_system_role", resource_ids=previous_role_ids | set(payload.role_ids), operation="updated", tenant_id=None, principal=principal, ) audit_from_principal( session, principal, action="system_account.updated", scope="system", object_type="account", object_id=account.id, details=payload.model_dump(exclude_unset=True), ) session.commit() return _system_account_item(session, account) @router.put("/system/accounts/{account_id}/roles", response_model=SystemAccountItem) def update_system_account_roles( account_id: str, payload: SystemAccountRolesUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_any_scope("system:roles:assign", "system:access:assign")), ): account = session.get(Account, account_id) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") previous_role_ids = _current_system_role_ids(session, account) try: _require_system_role_assignment(principal) assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids) set_system_roles(session, account=account, role_ids=payload.role_ids) assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, resource_type="access_system_account", resource_id=account.id, operation="updated", tenant_id=None, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_SYSTEM_ROLES_COLLECTION, resource_type="access_system_role", resource_ids=previous_role_ids | set(payload.role_ids), operation="updated", tenant_id=None, principal=principal, ) audit_from_principal( session, principal, action="system_access.updated", scope="system", object_type="account", object_id=account.id, details={"role_ids": payload.role_ids}, ) session.commit() return _system_account_item(session, account) @router.post("/system/accounts", response_model=SystemAccountCreateResponse, status_code=status.HTTP_201_CREATED) def create_system_account( payload: SystemAccountCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:accounts:create")), ): try: account, created, temporary_password = get_or_create_account( session, email=payload.email, display_name=payload.display_name, password=payload.password, password_reset_required=payload.password_reset_required, ) if not created: raise AdminConflictError("A global account with this email already exists.") account.is_active = payload.is_active if payload.role_ids: _require_system_role_assignment(principal) assert_can_delegate_system_roles(session, actor_scopes=principal.scopes, role_ids=payload.role_ids) set_system_roles(session, account=account, role_ids=payload.role_ids) if payload.memberships: _require_permission(principal, "system:access:assign") _set_system_memberships(session, account, [item.model_dump() for item in payload.memberships]) assert_system_owner_exists(session) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, resource_type="access_system_account", resource_id=account.id, operation="created", tenant_id=None, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_SYSTEM_ROLES_COLLECTION, resource_type="access_system_role", resource_ids=payload.role_ids, operation="updated", tenant_id=None, principal=principal, ) for membership in payload.memberships: user = session.query(User).filter(User.account_id == account.id, User.tenant_id == membership.tenant_id).one_or_none() if user: _record_access_change( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_id=user.id, operation="created", tenant_id=membership.tenant_id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_ids=membership.group_ids, operation="updated", tenant_id=membership.tenant_id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_ids=membership.role_ids, operation="updated", tenant_id=membership.tenant_id, principal=principal, ) audit_from_principal( session, principal, action="system_account.created", scope="system", object_type="account", object_id=account.id, details={"email": account.email, "tenant_ids": [item.tenant_id for item in payload.memberships], "role_ids": payload.role_ids}, ) session.commit() return SystemAccountCreateResponse(account=_system_account_item(session, account), temporary_password=temporary_password) @router.put("/system/accounts/{account_id}/memberships", response_model=SystemAccountItem) def update_system_account_memberships( account_id: str, payload: SystemAccountMembershipsUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("system:accounts:update")), ): account = session.get(Account, account_id) if account is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") previous_users = {user.tenant_id: user for user in session.query(User).filter(User.account_id == account.id).all()} requested_tenant_ids = {item.tenant_id for item in payload.memberships} try: _require_permission(principal, "system:access:assign") _set_system_memberships(session, account, [item.model_dump() for item in payload.memberships]) for tenant_id in {item.tenant_id for item in payload.memberships}: assert_tenant_owner_exists(session, tenant_id) except (AdminConflictError, AdminValidationError) as exc: raise _http_admin_error(exc) from exc _record_access_change( session, collection=ACCESS_SYSTEM_ACCOUNTS_COLLECTION, resource_type="access_system_account", resource_id=account.id, operation="updated", tenant_id=None, principal=principal, ) for membership in payload.memberships: user = session.query(User).filter(User.account_id == account.id, User.tenant_id == membership.tenant_id).one_or_none() if user: _record_access_change( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_id=user.id, operation="created" if membership.tenant_id not in previous_users else "updated", tenant_id=membership.tenant_id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_GROUPS_COLLECTION, resource_type="access_group", resource_ids=membership.group_ids, operation="updated", tenant_id=membership.tenant_id, principal=principal, ) _record_many_access_changes( session, collection=ACCESS_ROLES_COLLECTION, resource_type="access_role", resource_ids=membership.role_ids, operation="updated", tenant_id=membership.tenant_id, principal=principal, ) for tenant_id, user in previous_users.items(): if tenant_id not in requested_tenant_ids: _record_access_change( session, collection=ACCESS_USERS_COLLECTION, resource_type="access_user", resource_id=user.id, operation="updated", tenant_id=tenant_id, principal=principal, ) audit_from_principal( session, principal, action="system_memberships.updated", scope="system", object_type="account", object_id=account.id, details={"tenant_ids": [item.tenant_id for item in payload.memberships]}, ) session.commit() return _system_account_item(session, account) def _full_api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool) -> ApiKeyListDeltaResponse: query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) keys = query.order_by(ApiKey.created_at.desc()).all() return ApiKeyListDeltaResponse( api_keys=[_api_key_item(session, item) for item in keys], deleted=[], watermark=_access_delta_watermark(session, tenant.id, (ACCESS_API_KEYS_COLLECTION,)), has_more=False, full=True, ) def _api_keys_delta_response(session: Session, tenant: Tenant, *, include_revoked: bool, since: str, limit: int) -> ApiKeyListDeltaResponse: entries, has_more = _access_delta_entries(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), since=since, limit=limit) if entries is None: return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) changed_ids = _changed_ids(entries, "access_api_key") query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) visible = { item.id: item for item in ( query.filter(ApiKey.id.in_(changed_ids)).all() if changed_ids else [] ) } deleted = [ _delta_deleted_item(entry) for entry in entries if entry.resource_type == "access_api_key" and entry.resource_id not in visible ] return ApiKeyListDeltaResponse( api_keys=[_api_key_item(session, item) for item in visible.values()], deleted=deleted, watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_API_KEYS_COLLECTION,), entries=entries, has_more=has_more), has_more=has_more, full=False, ) @router.get("/api-keys/delta", response_model=ApiKeyListDeltaResponse) def list_api_keys_delta( tenant_id: str | None = Query(default=None), include_revoked: bool = Query(default=False), since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) if since is None: return _full_api_keys_delta_response(session, tenant, include_revoked=include_revoked) return _api_keys_delta_response(session, tenant, include_revoked=include_revoked, since=since, limit=limit) @router.get("/api-keys", response_model=ApiKeyListResponse) def list_api_keys( tenant_id: str | None = Query(default=None), include_revoked: bool = Query(default=False), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:read")), ): tenant = _resolve_tenant(session, principal, tenant_id) query = session.query(ApiKey).filter(ApiKey.tenant_id == tenant.id) if not include_revoked: query = query.filter(ApiKey.revoked_at.is_(None)) keys = query.order_by(ApiKey.created_at.desc()).all() return ApiKeyListResponse(api_keys=[_api_key_item(session, item) for item in keys]) @router.post("/api-keys", response_model=AdminApiKeyCreateResponse, status_code=status.HTTP_201_CREATED) def create_tenant_api_key( payload: AdminApiKeyCreateRequest, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:create")), ): tenant = _resolve_tenant(session, principal, tenant_id) try: assert_api_keys_allowed(session, tenant) except AdminConflictError as exc: raise _http_admin_error(exc) from exc user_id = payload.user_id or principal.user.id user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none() if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found") user_scopes = collect_user_scopes(session, user, include_system=False) requested = payload.scopes or ["campaign:read"] invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)] if invalid: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"API key scopes exceed the user's tenant permissions: {', '.join(invalid)}", ) created = create_api_key( session, user=user, name=payload.name.strip(), scopes=sorted(set(requested)), expires_at=payload.expires_at, ) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="api_key.created", object_type="api_key", object_id=created.model.id, details={"name": created.model.name, "prefix": created.model.prefix, "scopes": created.model.scopes, "owner_user_id": user.id}, ) _record_access_change( session, collection=ACCESS_API_KEYS_COLLECTION, resource_type="access_api_key", resource_id=created.model.id, operation="created", tenant_id=tenant.id, principal=principal, ) session.commit() return AdminApiKeyCreateResponse(**_api_key_item(session, created.model).model_dump(), secret=created.secret) @router.post("/api-keys/{api_key_id}/revoke", response_model=ApiKeyAdminItem) def revoke_api_key( api_key_id: str, tenant_id: str | None = Query(default=None), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("admin:api_keys:revoke")), ): tenant = _resolve_tenant(session, principal, tenant_id) item = session.query(ApiKey).filter(ApiKey.id == api_key_id, ApiKey.tenant_id == tenant.id).one_or_none() if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found") if item.revoked_at is None: item.revoked_at = utc_now() session.add(item) audit_event( session, tenant_id=tenant.id, user_id=principal.user.id, action="api_key.revoked", object_type="api_key", object_id=item.id, details={"name": item.name, "prefix": item.prefix}, ) _record_access_change( session, collection=ACCESS_API_KEYS_COLLECTION, resource_type="access_api_key", resource_id=item.id, operation="updated", tenant_id=tenant.id, principal=principal, ) session.commit() return _api_key_item(session, item)