Release v0.1.5
This commit is contained in:
779
src/govoplan_access/backend/api/v1/routes.py
Normal file
779
src/govoplan_access/backend/api/v1/routes.py
Normal file
@@ -0,0 +1,779 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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,
|
||||
ApiKeyListResponse,
|
||||
GroupCreateRequest,
|
||||
GroupListResponse,
|
||||
GroupSummary,
|
||||
GroupUpdateRequest,
|
||||
PermissionCatalogResponse,
|
||||
PermissionItem,
|
||||
RoleCreateRequest,
|
||||
RoleListResponse,
|
||||
RoleSummary,
|
||||
RoleUpdateRequest,
|
||||
SystemAccountCreateRequest,
|
||||
SystemAccountCreateResponse,
|
||||
SystemAccountItem,
|
||||
SystemAccountListResponse,
|
||||
SystemAccountMembershipsUpdateRequest,
|
||||
SystemAccountRolesUpdateRequest,
|
||||
SystemAccountUpdateRequest,
|
||||
UserCreateRequest,
|
||||
UserCreateResponse,
|
||||
UserAdminItem,
|
||||
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.db.models import Account, ApiKey, Group, Role, Tenant, User
|
||||
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"])
|
||||
|
||||
|
||||
@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("/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
|
||||
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")
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
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")
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@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
|
||||
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")
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@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
|
||||
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")
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
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
|
||||
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")
|
||||
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
|
||||
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
|
||||
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")
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@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_ENTITY,
|
||||
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},
|
||||
)
|
||||
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},
|
||||
)
|
||||
session.commit()
|
||||
return _api_key_item(session, item)
|
||||
Reference in New Issue
Block a user