3226 lines
129 KiB
Python
3226 lines
129 KiB
Python
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)
|