Files
UmbraTrans/umbratrans_web/routes_uadmin.py
2026-10-18 19:01:05 -07:00

450 lines
16 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Administrator routes: ``/api/uadmin/*``.
Every route requires a session whose role is ``platform_admin`` or
``organization_admin``. Organization administrators are pinned to their own
organization by the service, so a forged organization identifier in a query
string or body has no effect.
``POST /api/uadmin/accounts`` provisions either enrollment mode. OIDC
enrollment stores no local credential. Temporary-password enrollment generates
the one-time password inside the service: the request body has no password
field, and the value is returned exactly once in the creation response. The
separate operator bootstrap command in :mod:`umbratrans_web.cli` remains the
path for the first administrator, where the password comes from a hidden
local prompt.
"""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from .app import get_service, request_id, require_admin
from .models import AccountStatus, EnrollmentMode, OrganizationStatus, Principal, ProjectStatus, RoleName
from .schemas import (
AccountListResponse,
AccountResponse,
AuditEventResponse,
AuditListResponse,
CreateAccountRequest,
CreateAccountResponse,
CreateOrganizationRequest,
CreateProjectRequest,
OrganizationListResponse,
OrganizationResponse,
OrganizationStatusChangeResponse,
OrganizationSummaryResponse,
PasswordResetRequestedResponse,
ProjectAssignmentResponse,
ProjectListResponse,
ProjectResponse,
RevokeSessionsResponse,
SetAccountStatusRequest,
SetOrganizationStatusRequest,
SetProjectStatusRequest,
StatusChangeResponse,
UpdateOrganizationRequest,
)
from .service import AccountService
router = APIRouter()
MAX_PAGE_SIZE = 200
DEFAULT_PAGE_SIZE = 50
@router.get("/accounts", response_model=AccountListResponse)
def list_accounts(
request: Request,
principal: Principal = Depends(require_admin),
status: AccountStatus | None = Query(default=None),
role: RoleName | None = Query(default=None),
organization_id: UUID | None = Query(default=None),
limit: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
) -> AccountListResponse:
"""List accounts within the caller's administrative scope."""
service: AccountService = get_service(request)
accounts, total = service.list_accounts(
principal,
organization_id=organization_id,
status=status,
role=role,
limit=limit,
offset=offset,
)
return AccountListResponse(
items=[AccountResponse.from_view(account) for account in accounts],
total=total,
limit=limit,
offset=offset,
)
@router.post("/accounts", response_model=CreateAccountResponse, status_code=201)
def create_account(
payload: CreateAccountRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> CreateAccountResponse:
"""Provision an account in either enrollment mode.
``oidc`` creates the account with no local credential. For
``temporary_password`` the service generates the one-time password in
process; the request body has no password field at all, and the generated
value comes back exactly once in this response for the administrator to
hand to the account owner. The new account may do nothing but replace it.
"""
service: AccountService = get_service(request)
# Role and organization scope are enforced by the service, not by trusting
# the request body: an organization administrator cannot create a
# platform administrator or an account outside its own organization.
if payload.enrollment_mode is EnrollmentMode.OIDC:
account = service.create_oidc_account(
username=payload.username,
display_name=payload.display_name,
role=payload.role,
email=payload.email,
organization_id=payload.organization_id,
actor=principal,
request_id=request_id(request),
)
return CreateAccountResponse.from_oidc(account)
issued = service.provision_admin_temporary_password(
username=payload.username,
display_name=payload.display_name,
role=payload.role,
email=payload.email,
organization_id=payload.organization_id,
actor=principal,
request_id=request_id(request),
)
return CreateAccountResponse.from_temporary_password(issued.account, issued.temporary_password)
@router.get("/accounts/{user_id}", response_model=AccountResponse)
def get_account(
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> AccountResponse:
service: AccountService = get_service(request)
return AccountResponse.from_view(service.get_account(principal, user_id))
@router.patch("/accounts/{user_id}/status", response_model=StatusChangeResponse)
def set_account_status(
user_id: UUID,
payload: SetAccountStatusRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> StatusChangeResponse:
"""Suspend, reactivate, or deactivate an account.
A non-active status revokes every session for that account immediately.
"""
service: AccountService = get_service(request)
account, revoked = service.set_account_status(
principal,
user_id,
payload.status,
reason=payload.reason,
request_id=request_id(request),
)
return StatusChangeResponse(account=AccountResponse.from_view(account), revoked_sessions=revoked)
@router.post("/accounts/{user_id}/revoke-sessions", response_model=RevokeSessionsResponse)
def revoke_sessions(
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> RevokeSessionsResponse:
service: AccountService = get_service(request)
return RevokeSessionsResponse(
revoked_sessions=service.revoke_sessions(principal, user_id, request_id=request_id(request))
)
@router.post("/accounts/{user_id}/password-reset", response_model=PasswordResetRequestedResponse)
def request_password_reset(
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> PasswordResetRequestedResponse:
"""Mail a password-reset link to an account.
The administrator does not receive the new password and never sets one; the
link goes to the address on the account and only its holder can complete it.
An administrator who could set someone's password could also sign in as them
indefinitely, and a password is not something to hand to a colleague.
Scoped like every other administrative action, so an organization
administrator can reset only within their own tenant.
"""
service: AccountService = get_service(request)
service.request_password_reset(principal, user_id, request_id=request_id(request))
return PasswordResetRequestedResponse(
message="A password reset link has been sent to the address on that account. "
"It works once and expires in one hour."
)
@router.get("/summary", response_model=OrganizationSummaryResponse)
def summary(
request: Request,
principal: Principal = Depends(require_admin),
) -> OrganizationSummaryResponse:
"""Dashboard counts for the caller's administrative scope."""
service: AccountService = get_service(request)
return OrganizationSummaryResponse(**service.organization_summary(principal))
@router.get("/organizations", response_model=OrganizationListResponse)
def list_organizations(
request: Request,
principal: Principal = Depends(require_admin),
status: OrganizationStatus | None = Query(default=None),
limit: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
) -> OrganizationListResponse:
"""List organizations. An organization administrator sees only their own."""
service: AccountService = get_service(request)
organizations, total = service.list_organizations(principal, status=status, limit=limit, offset=offset)
return OrganizationListResponse(
items=[OrganizationResponse.from_view(organization) for organization in organizations],
total=total,
limit=limit,
offset=offset,
)
@router.post("/organizations", response_model=OrganizationResponse, status_code=201)
def create_organization(
payload: CreateOrganizationRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> OrganizationResponse:
"""Create a tenant. Platform administrators only; the service refuses others."""
service: AccountService = get_service(request)
organization = service.create_organization(
principal,
name=payload.name,
slug=payload.slug,
request_id=request_id(request),
)
return OrganizationResponse.from_view(organization)
@router.get("/organizations/{organization_id}", response_model=OrganizationResponse)
def get_organization(
organization_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> OrganizationResponse:
service: AccountService = get_service(request)
return OrganizationResponse.from_view(service.get_organization(principal, organization_id))
@router.patch("/organizations/{organization_id}", response_model=OrganizationResponse)
def update_organization(
organization_id: UUID,
payload: UpdateOrganizationRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> OrganizationResponse:
"""Rename or re-slug an organization. Platform administrators only."""
service: AccountService = get_service(request)
organization = service.update_organization(
principal,
organization_id,
name=payload.name,
slug=payload.slug,
request_id=request_id(request),
)
return OrganizationResponse.from_view(organization)
@router.patch("/organizations/{organization_id}/status", response_model=OrganizationStatusChangeResponse)
def set_organization_status(
organization_id: UUID,
payload: SetOrganizationStatusRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> OrganizationStatusChangeResponse:
"""Suspend, reactivate, or archive an organization.
Leaving ``active`` revokes the live sessions of that organization's
members, and the response reports how many were revoked.
"""
service: AccountService = get_service(request)
organization, revoked = service.set_organization_status(
principal,
organization_id,
payload.status,
reason=payload.reason,
request_id=request_id(request),
)
return OrganizationStatusChangeResponse(
organization=OrganizationResponse.from_view(organization),
revoked_sessions=revoked,
)
# -- projects ----------------------------------------------------------------
#
# Creating, populating, and moving a project through its lifecycle are
# administrator acts. *Reading* them is not, and is therefore not here: a member
# sees their organization's projects through GET /api/workspace, which is scoped
# the same way but requires no administrator role. Keeping the read on the
# self-service path is what stops a member's dashboard from needing a second set of
# rules.
@router.get("/projects", response_model=ProjectListResponse)
def list_projects(
request: Request,
principal: Principal = Depends(require_admin),
status: list[ProjectStatus] | None = Query(default=None),
organization_id: UUID | None = Query(default=None),
limit: int = Query(default=MAX_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
) -> ProjectListResponse:
"""List projects in the caller's organization.
Organization-wide, not assignment-filtered: an administrator setting up work
needs to see the projects nobody is on, or they would never be given to
anyone. See :mod:`umbratrans_web.tenancy`.
"""
service: AccountService = get_service(request)
projects, total = service.list_projects(
principal, organization_id=organization_id, statuses=status, limit=limit, offset=offset
)
return ProjectListResponse(
projects=[ProjectResponse.from_view(project) for project in projects], total=total
)
@router.post("/projects", response_model=ProjectResponse, status_code=201)
def create_project(
payload: CreateProjectRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectResponse:
"""Create a project. Pinned to the caller's organization; see the service."""
service: AccountService = get_service(request)
project = service.create_project(
principal,
name=payload.name,
slug=payload.slug,
organization_id=payload.organization_id,
request_id=request_id(request),
)
return ProjectResponse.from_view(project)
@router.get("/projects/{project_id}", response_model=ProjectResponse)
def get_project(
project_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectResponse:
service: AccountService = get_service(request)
return ProjectResponse.from_view(service.get_project(principal, project_id))
@router.patch("/projects/{project_id}/status", response_model=ProjectResponse)
def set_project_status(
project_id: UUID,
payload: SetProjectStatusRequest,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectResponse:
"""Move a project through its lifecycle. Archived is terminal."""
service: AccountService = get_service(request)
project = service.set_project_status(
principal, project_id, payload.status, reason=payload.reason, request_id=request_id(request)
)
return ProjectResponse.from_view(project)
@router.put("/projects/{project_id}/members/{user_id}", response_model=ProjectAssignmentResponse)
def assign_to_project(
project_id: UUID,
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectAssignmentResponse:
"""Say an account is working on a project.
A ``PUT`` because the desired state is "this account is assigned", which is
true whether or not it already was. Repeating it is not an error, so a client
retrying after a dropped response gets the answer it wanted.
"""
service: AccountService = get_service(request)
before = service.get_project(principal, project_id)
project = service.assign_account_to_project(principal, project_id, user_id, request_id=request_id(request))
return ProjectAssignmentResponse(
project=ProjectResponse.from_view(project),
changed=before.assigned_account_ids != project.assigned_account_ids,
)
@router.delete("/projects/{project_id}/members/{user_id}", response_model=ProjectAssignmentResponse)
def unassign_from_project(
project_id: UUID,
user_id: UUID,
request: Request,
principal: Principal = Depends(require_admin),
) -> ProjectAssignmentResponse:
"""Remove an assignment.
The project stays visible to everyone in the organization; only the work stops
being anybody's. See :mod:`umbratrans_web.tenancy`.
"""
service: AccountService = get_service(request)
before = service.get_project(principal, project_id)
project = service.unassign_account_from_project(principal, project_id, user_id, request_id=request_id(request))
return ProjectAssignmentResponse(
project=ProjectResponse.from_view(project),
changed=before.assigned_account_ids != project.assigned_account_ids,
)
@router.get("/audit", response_model=AuditListResponse)
def list_audit_events(
request: Request,
principal: Principal = Depends(require_admin),
target_user_id: UUID | None = Query(default=None),
limit: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE),
offset: int = Query(default=0, ge=0),
) -> AuditListResponse:
"""Return sanitized audit events for the caller's administrative scope."""
service: AccountService = get_service(request)
events, total = service.list_audit_events(principal, target_user_id=target_user_id, limit=limit, offset=offset)
return AuditListResponse(
items=[AuditEventResponse.from_event(event) for event in events],
total=total,
limit=limit,
offset=offset,
)