Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
178 lines
6.2 KiB
Python
178 lines
6.2 KiB
Python
"""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, Principal, RoleName
|
|
from .schemas import (
|
|
AccountListResponse,
|
|
AccountResponse,
|
|
AuditEventResponse,
|
|
AuditListResponse,
|
|
CreateAccountRequest,
|
|
CreateAccountResponse,
|
|
RevokeSessionsResponse,
|
|
SetAccountStatusRequest,
|
|
StatusChangeResponse,
|
|
)
|
|
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.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,
|
|
)
|