Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent f37c6668e5
commit efb69b3d2d
43 changed files with 6464 additions and 192 deletions

View File

@@ -0,0 +1,2 @@
"""Access-owned backend API routes."""

View File

@@ -0,0 +1,2 @@
"""Access-owned API v1 routes."""

View File

@@ -0,0 +1,11 @@
"""Compatibility import for access-owned admin routes.
The remaining platform admin routes are contributed by the `admin`, `tenancy`,
`policy`, and `audit` modules through their own manifests.
"""
from __future__ import annotations
from govoplan_access.backend.api.v1.routes import router
__all__ = ["router"]

View File

@@ -0,0 +1,251 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from govoplan_access.backend.admin.service import (
AdminConflictError,
AdminValidationError,
assert_tenant_owner_exists,
role_assignment_counts,
set_user_groups,
set_user_roles,
tenant_owner_user_ids,
)
from govoplan_access.backend.api.v1.admin_schemas import (
ApiKeyAdminItem,
GroupSummary,
RoleSummary,
SystemAccountItem,
UserAdminItem,
)
from govoplan_access.backend.security.sessions import (
collect_direct_user_roles,
collect_system_roles,
collect_user_groups,
collect_user_scopes,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
from govoplan_access.backend.db.models import (
Account,
ApiKey,
Group,
GroupRoleAssignment,
Role,
SystemRoleAssignment,
Tenant,
User,
UserGroupMembership,
)
from govoplan_core.security.permissions import effective_permission_count
def _http_admin_error(exc: Exception) -> HTTPException:
if isinstance(exc, AdminConflictError):
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
if isinstance(exc, AdminValidationError):
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
def _require_system_role_assignment(principal: ApiPrincipal) -> None:
if not (has_scope(principal, "system:roles:assign") or has_scope(principal, "system:access:assign")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: system:roles:assign")
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
def _resolve_tenant(
session: Session,
principal: ApiPrincipal,
tenant_id: str | None,
) -> Tenant:
target_id = tenant_id or principal.tenant_id
if target_id != principal.tenant_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Switch to the target tenant before using tenant-administration endpoints.",
)
tenant = session.get(Tenant, target_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
return tenant
def _role_summary(session: Session, role: Role) -> RoleSummary:
group_count = 0
if role.tenant_id is None:
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
permission_level = "system"
else:
user_count, group_count = role_assignment_counts(session, role.id)
permission_level = "tenant"
permissions = list(role.permissions or [])
return RoleSummary(
id=role.id,
slug=role.slug,
name=role.name,
description=role.description,
permissions=permissions,
effective_permission_count=effective_permission_count(permissions, level=permission_level),
is_builtin=role.is_builtin,
is_assignable=role.is_assignable,
user_assignments=user_count,
group_assignments=group_count,
level="system" if role.tenant_id is None else "tenant",
system_template_id=role.system_template_id,
system_required=role.system_required,
)
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary:
member_ids = [
row[0]
for row in session.query(UserGroupMembership.user_id)
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
.all()
]
roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
.order_by(Role.name.asc())
.all()
)
return GroupSummary(
id=group.id,
slug=group.slug,
name=group.name,
description=group.description,
is_active=group.is_active,
member_count=len(member_ids),
member_ids=member_ids if include_members else [],
roles=[_role_summary(session, role) for role in roles],
created_at=group.created_at,
updated_at=group.updated_at,
system_template_id=group.system_template_id,
system_required=group.system_required,
)
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem:
account = session.get(Account, user.account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user)
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
return UserAdminItem(
id=user.id,
account_id=account.id,
tenant_id=user.tenant_id,
email=account.email,
display_name=user.display_name or account.display_name,
is_active=user.is_active,
account_is_active=account.is_active,
password_reset_required=account.password_reset_required,
last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups],
roles=[_role_summary(session, role) for role in roles],
effective_scopes=collect_user_scopes(session, user, include_system=False),
is_owner=user.id in effective_owner_ids,
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
created_at=user.created_at,
updated_at=user.updated_at,
)
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
memberships = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id)
.order_by(Tenant.name.asc())
.all()
)
owner_ids_by_tenant = {
tenant.id: tenant_owner_user_ids(session, tenant.id)
for _, tenant in memberships
}
return SystemAccountItem(
account_id=account.id,
email=account.email,
display_name=account.display_name,
is_active=account.is_active,
memberships=[
{
"tenant_id": tenant.id,
"tenant_name": tenant.name,
"user_id": user.id,
"is_active": user.is_active and tenant.is_active,
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
"group_ids": [group.id for group in collect_user_groups(session, user)],
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
"is_last_active_owner": (
user.id in owner_ids_by_tenant[tenant.id]
and len(owner_ids_by_tenant[tenant.id]) == 1
),
}
for user, tenant in memberships
],
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
last_login_at=account.last_login_at,
)
def _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem:
user = session.get(User, item.user_id)
account = session.get(Account, user.account_id) if user else None
return ApiKeyAdminItem(
id=item.id,
user_id=item.user_id,
user_email=account.email if account else "Unknown account",
name=item.name,
prefix=item.prefix,
scopes=item.scopes or [],
expires_at=item.expires_at,
last_used_at=item.last_used_at,
revoked_at=item.revoked_at,
created_at=item.created_at,
)
def _set_system_memberships(session: Session, account: Account, requested: list[dict]) -> None:
desired = {item["tenant_id"]: item for item in requested}
existing = {item.tenant_id: item for item in session.query(User).filter(User.account_id == account.id).all()}
affected = set(existing) | set(desired)
for tenant_id, user in existing.items():
if tenant_id not in desired:
user.is_active = False
session.add(user)
for tenant_id, item in desired.items():
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise AdminValidationError(f"Unknown tenant: {tenant_id}")
user = existing.get(tenant_id)
if user is None:
user = User(
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
display_name=account.display_name,
is_active=bool(item.get("is_active", True)),
auth_provider=account.auth_provider,
password_hash=account.password_hash,
)
session.add(user)
session.flush()
else:
user.is_active = bool(item.get("is_active", True))
user.email = account.email
session.add(user)
set_user_roles(session, user=user, role_ids=item.get("role_ids", []))
set_user_groups(session, user=user, group_ids=item.get("group_ids", []))
session.flush()
for tenant_id in affected:
tenant = session.get(Tenant, tenant_id)
if tenant and tenant.is_active:
assert_tenant_owner_exists(session, tenant_id)

View File

@@ -0,0 +1,465 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
RETENTION_DAY_KEYS = (
"raw_campaign_json_retention_days",
"generated_eml_retention_days",
"stored_report_detail_retention_days",
"mock_mailbox_retention_days",
"audit_detail_retention_days",
)
RETENTION_POLICY_FIELD_KEYS = (
"store_raw_campaign_json",
*RETENTION_DAY_KEYS,
"audit_detail_level",
)
def default_allow_lower_level_limits() -> dict[str, bool]:
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
if value in (None, ""):
return default_allow_lower_level_limits() if fill_defaults else None
if not isinstance(value, dict):
raise ValueError("allow_lower_level_limits must be an object")
normalized = default_allow_lower_level_limits() if fill_defaults else {}
for key, allowed in value.items():
clean_key = str(key)
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
raise ValueError(f"Unknown retention policy field: {clean_key}")
normalized[clean_key] = bool(allowed)
return normalized
class PermissionItem(BaseModel):
scope: str
label: str
description: str
category: str
level: Literal["tenant", "system"]
class PermissionCatalogResponse(BaseModel):
permissions: list[PermissionItem]
class AdminOverviewResponse(BaseModel):
active_tenant_id: str
active_tenant_name: str
tenant_count: int | None = None
system_account_count: int | None = None
system_group_template_count: int | None = None
system_role_template_count: int | None = None
user_count: int
active_user_count: int
group_count: int
role_count: int
active_api_key_count: int
capabilities: list[str] = Field(default_factory=list)
class TenantAdminItem(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
default_locale: str = Field(default="en", min_length=1, max_length=20)
settings: dict[str, Any] = Field(default_factory=dict)
allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None
allow_api_keys: bool | None = None
effective_governance: dict[str, bool] = Field(default_factory=dict)
is_active: bool
counts: dict[str, int] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class TenantListResponse(BaseModel):
tenants: list[TenantAdminItem]
class TenantOwnerCandidate(BaseModel):
account_id: str
email: str
display_name: str | None = None
class TenantOwnerCandidateListResponse(BaseModel):
accounts: list[TenantOwnerCandidate]
class TenantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str
owner_account_id: str | None = None
description: str | None = None
default_locale: str = "en"
settings: dict[str, Any] = Field(default_factory=dict)
allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None
allow_api_keys: bool | None = None
class TenantUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
default_locale: str | None = Field(default=None, min_length=1, max_length=20)
settings: dict[str, Any] | None = None
allow_custom_groups: bool | None = None
allow_custom_roles: bool | None = None
allow_api_keys: bool | None = None
is_active: bool | None = None
class TenantSettingsItem(BaseModel):
id: str
slug: str
name: str
default_locale: str = Field(default="en", min_length=1, max_length=20)
settings: dict[str, Any] = Field(default_factory=dict)
class TenantSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
class RoleSummary(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
effective_permission_count: int = 0
is_builtin: bool = False
is_assignable: bool = True
user_assignments: int = 0
group_assignments: int = 0
level: Literal["tenant", "system"] = "tenant"
system_template_id: str | None = None
system_required: bool = False
class GroupSummary(BaseModel):
id: str
slug: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=255)
description: str | None = None
is_active: bool = True
member_count: int = 0
member_ids: list[str] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
system_template_id: str | None = None
system_required: bool = False
class UserAdminItem(BaseModel):
id: str
account_id: str
tenant_id: str
email: str = Field(min_length=3, max_length=320)
display_name: str | None = Field(default=None, max_length=255)
is_active: bool
account_is_active: bool
password_reset_required: bool = False
last_login_at: datetime | None = None
groups: list[GroupSummary] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
effective_scopes: list[str] = Field(default_factory=list)
is_owner: bool = False
is_last_active_owner: bool = False
created_at: datetime
updated_at: datetime
class UserListResponse(BaseModel):
users: list[UserAdminItem]
class UserCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
display_name: str | None = None
password: str | None = Field(default=None, min_length=10)
password_reset_required: bool = True
is_active: bool = True
group_ids: list[str] = Field(default_factory=list)
role_ids: list[str] = Field(default_factory=list)
class UserCreateResponse(BaseModel):
user: UserAdminItem
account_created: bool
temporary_password: str | None = None
class UserUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
is_active: bool | None = None
group_ids: list[str] | None = None
role_ids: list[str] | None = None
class GroupListResponse(BaseModel):
groups: list[GroupSummary]
class GroupCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str
description: str | None = None
is_active: bool = True
member_ids: list[str] = Field(default_factory=list)
role_ids: list[str] = Field(default_factory=list)
class GroupUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
is_active: bool | None = None
member_ids: list[str] | None = None
role_ids: list[str] | None = None
class RoleListResponse(BaseModel):
roles: list[RoleSummary]
class RoleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
slug: str
name: str = Field(min_length=1, max_length=255)
description: str | None = None
permissions: list[str] = Field(default_factory=list)
class RoleUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: str | None = None
permissions: list[str] = Field(default_factory=list)
is_assignable: bool = True
class SystemAccountItem(BaseModel):
account_id: str
email: str
display_name: str | None = None
is_active: bool
memberships: list[dict[str, Any]] = Field(default_factory=list)
roles: list[RoleSummary] = Field(default_factory=list)
last_login_at: datetime | None = None
class SystemAccountListResponse(BaseModel):
accounts: list[SystemAccountItem]
roles: list[RoleSummary]
class SystemAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
is_active: bool | None = None
role_ids: list[str] | None = None
class SystemAccountRolesUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
role_ids: list[str] = Field(default_factory=list)
class SystemMembershipUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
is_active: bool = True
role_ids: list[str] = Field(default_factory=list)
group_ids: list[str] = Field(default_factory=list)
class SystemAccountMembershipsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
memberships: list[SystemMembershipUpdate] = Field(default_factory=list)
class SystemAccountCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
display_name: str | None = None
password: str | None = Field(default=None, min_length=10)
password_reset_required: bool = True
is_active: bool = True
role_ids: list[str] = Field(default_factory=list)
memberships: list[SystemMembershipUpdate] = Field(default_factory=list)
class SystemAccountCreateResponse(BaseModel):
account: SystemAccountItem
temporary_password: str | None = None
class PolicySourceStepItem(BaseModel):
scope_type: str
scope_id: str | None = None
label: str
applied_fields: list[str] = Field(default_factory=list)
policy: dict[str, Any] = Field(default_factory=dict)
class PrivacyRetentionPolicyItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool = True
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=True)
class PrivacyRetentionPolicyPatchItem(BaseModel):
model_config = ConfigDict(extra="forbid")
store_raw_campaign_json: bool | None = None
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
generated_eml_retention_days: int | None = Field(default=None, ge=0)
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
audit_detail_retention_days: int | None = Field(default=None, ge=0)
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
allow_lower_level_limits: dict[str, bool] | None = None
@field_validator("allow_lower_level_limits", mode="before")
@classmethod
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
return normalize_allow_lower_level_limits(value, fill_defaults=False)
class PrivacyRetentionPolicyScopeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
policy: PrivacyRetentionPolicyPatchItem = Field(default_factory=PrivacyRetentionPolicyPatchItem)
class PrivacyRetentionPolicyScopeResponse(BaseModel):
scope_type: Literal["system", "tenant", "user", "group", "campaign"]
scope_id: str | None = None
policy: dict[str, Any]
effective_policy: PrivacyRetentionPolicyItem
parent_policy: PrivacyRetentionPolicyItem | None = None
effective_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
parent_policy_sources: list[PolicySourceStepItem] = Field(default_factory=list)
class RetentionRunRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
dry_run: bool = True
class RetentionRunResponse(BaseModel):
result: dict[str, Any]
class SystemSettingsItem(BaseModel):
default_locale: str = "en"
allow_tenant_custom_groups: bool = True
allow_tenant_custom_roles: bool = True
allow_tenant_api_keys: bool = True
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
settings: dict[str, Any] = Field(default_factory=dict)
class SystemSettingsUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
default_locale: str = Field(min_length=1, max_length=20)
allow_tenant_custom_groups: bool
allow_tenant_custom_roles: bool
allow_tenant_api_keys: bool
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
class ApiKeyAdminItem(BaseModel):
id: str
user_id: str
user_email: str
name: str
prefix: str
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime
class ApiKeyListResponse(BaseModel):
api_keys: list[ApiKeyAdminItem]
class AdminApiKeyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
user_id: str | None = None
scopes: list[str] = Field(default_factory=list)
expires_at: datetime | None = None
class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str
class AuditAdminItem(BaseModel):
id: str
scope: Literal["tenant", "system"] = "tenant"
tenant_id: str | None = None
actor_email: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class AuditAdminListResponse(BaseModel):
items: list[AuditAdminItem]
total: int
page: int = 1
page_size: int = 100
pages: int = 1

View File

@@ -0,0 +1,300 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_core.api.v1.schemas import (
GroupInfo,
LoginRequest,
LoginResponse,
MeResponse,
ProfileUpdateRequest,
RoleInfo,
SwitchTenantRequest,
TenantInfo,
TenantMembershipInfo,
UserInfo,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
from govoplan_access.backend.db.models import Account, Tenant, User
from govoplan_core.db.session import get_session
from govoplan_core.security.permissions import normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_access.backend.security.passwords import verify_password
from govoplan_access.backend.security.sessions import (
collect_system_roles,
collect_tenant_memberships,
collect_user_groups,
collect_user_roles,
collect_user_scopes,
create_auth_session,
switch_auth_session_tenant,
)
router = APIRouter(prefix="/auth", tags=["auth"])
def _cookie_samesite() -> str:
value = settings.auth_cookie_samesite.lower().strip()
if value not in {"lax", "strict", "none"}:
return "lax"
if value == "none" and not settings.auth_cookie_secure:
return "lax"
return value
def _set_auth_cookies(response: Response, created) -> None:
max_age = max(0, int((created.model.expires_at - utc_now()).total_seconds()))
common = {
"secure": settings.auth_cookie_secure,
"samesite": _cookie_samesite(),
"max_age": max_age,
"path": "/",
}
if settings.auth_cookie_domain:
common["domain"] = settings.auth_cookie_domain
response.set_cookie(settings.auth_session_cookie_name, created.token, httponly=True, **common)
response.set_cookie(settings.auth_csrf_cookie_name, created.csrf_token, httponly=False, **common)
def _clear_auth_cookies(response: Response) -> None:
kwargs = {"path": "/"}
if settings.auth_cookie_domain:
kwargs["domain"] = settings.auth_cookie_domain
response.delete_cookie(settings.auth_session_cookie_name, **kwargs)
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
def _tenant_info(tenant: Tenant) -> TenantInfo:
return TenantInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active,
default_locale=tenant.default_locale,
)
def _user_info(user: User, account: Account) -> UserInfo:
return UserInfo(
id=user.id,
account_id=account.id,
email=account.email,
display_name=account.display_name or user.display_name,
tenant_display_name=user.display_name,
is_tenant_admin=user.is_tenant_admin,
password_reset_required=account.password_reset_required,
)
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
return [
RoleInfo(
id=role.id,
slug=role.slug,
name=role.name,
permissions=role.permissions or [],
level=level,
)
for role in roles
]
def _groups_info(groups) -> list[GroupInfo]:
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]:
memberships: list[TenantMembershipInfo] = []
for user, tenant in collect_tenant_memberships(session, account):
roles = collect_user_roles(session, user)
memberships.append(
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[role.slug for role in roles],
)
)
return memberships
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = (
session.query(Account)
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none()
)
if account is None or not verify_password(payload.password, account.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = (
session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id)
.filter(
User.account_id == account.id,
User.is_active.is_(True),
Tenant.is_active.is_(True),
)
)
if payload.tenant_slug:
query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first()
if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
return account, row[0], row[1]
def _me_response(
session: Session,
*,
account: Account,
user: User,
tenant: Tenant,
effective_scopes: list[str] | None = None,
include_system: bool = True,
include_all_memberships: bool = True,
) -> MeResponse:
tenant_roles = collect_user_roles(session, user)
system_roles = collect_system_roles(session, account) if include_system else []
groups = collect_user_groups(session, user)
active_tenant = _tenant_info(tenant)
memberships = _tenant_memberships(session, account) if include_all_memberships else [
TenantMembershipInfo(
id=tenant.id,
slug=tenant.slug,
name=tenant.name,
is_active=tenant.is_active and user.is_active,
default_locale=tenant.default_locale,
roles=[role.slug for role in tenant_roles],
)
]
return MeResponse(
user=_user_info(user, account),
tenant=active_tenant,
active_tenant=active_tenant,
tenants=memberships,
scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system),
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
groups=_groups_info(groups),
)
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload)
me_payload = _me_response(session, account=account, user=user, tenant=tenant)
maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=maintenance_response_detail(maintenance_mode),
)
user_agent = request.headers.get("user-agent")
ip_address = request.client.host if request.client else None
created = create_auth_session(
session,
user=user,
hours=settings.auth_session_hours,
user_agent=user_agent,
ip_address=ip_address,
)
session.commit()
_set_auth_cookies(response, created)
return LoginResponse(
access_token=created.token,
expires_at=created.model.expires_at,
**me_payload.model_dump(),
)
@router.get("/me", response_model=MeResponse)
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
effective_scopes=principal.scopes,
include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None,
)
@router.patch("/profile", response_model=MeResponse)
def update_profile(
payload: ProfileUpdateRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
if "display_name" in payload.model_fields_set:
principal.account.display_name = payload.display_name.strip() if payload.display_name else None
session.add(principal.account)
if "tenant_display_name" in payload.model_fields_set:
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
session.add(principal.user)
audit_from_principal(
session,
principal,
action="profile.updated",
object_type="account",
object_id=principal.account.id,
details={"fields": sorted(payload.model_fields_set)},
)
tenant = session.get(Tenant, principal.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
session.commit()
return _me_response(
session,
account=principal.account,
user=principal.user,
tenant=tenant,
include_system=True,
include_all_memberships=True,
)
@router.post("/switch-tenant", response_model=MeResponse)
def switch_tenant(
payload: SwitchTenantRequest,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
try:
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
tenant = session.get(Tenant, membership.tenant_id)
if tenant is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
session.commit()
return _me_response(session, account=principal.account, user=membership, tenant=tenant)
@router.post("/logout")
def logout(
response: Response,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
if principal.auth_session is not None:
principal.auth_session.revoked_at = utc_now()
session.add(principal.auth_session)
session.commit()
_clear_auth_cookies(response)
return {"ok": True}

View 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)