450 lines
16 KiB
Python
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,
|
|
)
|