Add organization tenancy and same-origin frontend mounting
The administrator interface needs organizations to manage, and the slice had none: users.organization_id was a bare UUID pointing at nothing, so there was no tenant to create, list, or validate an account against. Tenancy: - Organization record with name, slug, and status active|suspended|archived, matching the control-plane vocabulary rather than reusing the account one. - Reads are open to any administrator; every write is platform-admin only, so the tenant boundary stays under a single authority. An organization admin is pinned to the organization on their own principal. - A derived slug is normalized from the display name, but a slug the operator typed must already be canonical, so nothing is silently rewritten behind their back. archived is terminal. - GET /api/uadmin/summary for the dashboard, recomputed per request from the same scoped queries the list endpoints use, so a summary cannot disclose a number the caller could not reach by listing. Two integrity gaps closed: 1. Account creation accepted any UUID as an organization. It now requires the organization to exist and to be active. 2. Suspending an organization revoked member sessions but did not stop new logins, because each member's own account status stays active. Both authenticate and resolve_session now refuse a member of a non-active organization, and a missing organization record blocks access rather than granting it. A test asserts the member is still active yet locked out. Migration, still unapplied: section 4 creates organizations with CREATE TABLE IF NOT EXISTS so a control-plane table is skipped, never redefined, and the rollback drops it only when this migration actually created it. The membership divergence is recorded as the top open review question rather than decided: user_roles is the control plane's membership source, this phase uses a column, and the migration adds no membership column so the choice stays open. Serving: UMBRATRANS_STATIC_ROOT mounts the browser client at / after the API routes, so the session cookie and CSRF header work without CORS. HTML responses get their own CSP allowing only same-origin script and style, which is why the frontend carries no inline script or style. Verification: 204 tests pass (21 new organization tests plus the existing suite), ruff check and format clean. Also exercised against a real uvicorn server rather than only the test client: bootstrap, forced password change, organization creation, one-time password issue, member lockout on organization suspension, and rejection of an account assigned to a suspended tenant.
This commit is contained in:
@@ -99,9 +99,30 @@ last_seen_at timestamptz not null default now()
|
||||
|
||||
The session token is random and only its hash is stored. The browser receives an `HttpOnly`, `Secure` in production, `SameSite=Lax` cookie.
|
||||
|
||||
### `organizations`
|
||||
|
||||
Tenants that own accounts and projects. Until this was added, `users.organization_id` was a bare UUID pointing at nothing, so the administrator interface had no tenant to manage and account creation could not validate its own assignment.
|
||||
|
||||
```text
|
||||
id uuid primary key
|
||||
name text not null check (char_length(name) between 2 and 120)
|
||||
slug citext not null unique
|
||||
status text not null default 'active' check (status in ('active', 'suspended', 'archived'))
|
||||
created_at timestamptz not null default now()
|
||||
updated_at timestamptz not null default now()
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- A slug derived from the display name is normalized; a slug the operator typed must already be canonical, so nothing is silently rewritten behind their back.
|
||||
- `archived` is terminal. Restoring an archived tenant is a separate, reviewed operation, because its name and slug are historically significant.
|
||||
- Leaving `active` revokes the live sessions of every member, and both `authenticate` and `resolve_session` refuse a member of a non-active organization. Revoking sessions alone was not enough: each member's own account status stays `active`, so without those checks a suspended tenant could still mint new sessions.
|
||||
- A missing organization record blocks access rather than granting it.
|
||||
|
||||
**Open review question, not a settled decision.** The control plane derives organization membership from `user_roles` with `scope = 'organization'`, and also supports project-scoped membership. This phase models membership as a single column on the user record, which means one organization per account and no project membership. The migration creates the `organizations` table but adds no membership column, so the choice stays open until the PostgreSQL adapter is written. It is recorded in `umbratrans_web/migrations/README.md`.
|
||||
|
||||
### Existing tables used unchanged
|
||||
|
||||
- `organizations`
|
||||
- `projects`
|
||||
- `roles`
|
||||
- `permissions`
|
||||
@@ -109,7 +130,7 @@ The session token is random and only its hash is stored. The browser receives an
|
||||
- `invitations`
|
||||
- `audit_events`
|
||||
|
||||
`user_roles` remains the membership source of truth. Do not add a second `project_members` table.
|
||||
`user_roles` is untouched by this phase. Do not add a second `project_members` table.
|
||||
|
||||
## Phase 1 API
|
||||
|
||||
@@ -128,11 +149,11 @@ GET /api/auth/me
|
||||
### Administrator account management
|
||||
|
||||
```text
|
||||
GET /api/uadmin/summary
|
||||
GET /api/uadmin/accounts
|
||||
POST /api/uadmin/accounts
|
||||
GET /api/uadmin/accounts/{user_id}
|
||||
PATCH /api/uadmin/accounts/{user_id}/status
|
||||
POST /api/uadmin/accounts/{user_id}/resend-enrollment
|
||||
POST /api/uadmin/accounts/{user_id}/revoke-sessions
|
||||
```
|
||||
|
||||
@@ -147,6 +168,28 @@ Creating an account accepts:
|
||||
|
||||
Neither path accepts a password from a client. `POST /api/uadmin/accounts` with `enrollment_mode: "temporary_password"` asks the service to generate the value; the request schema has no password field, so a submitted one is rejected before it reaches a handler. The generated value is returned once in that response for the administrator to hand to the account owner, and the first administrator instead uses the operator command's hidden local prompt.
|
||||
|
||||
`GET /api/uadmin/summary` returns the dashboard counts. They are recomputed on every request from the same scoped queries the list endpoints use, so a summary can never disclose a number the caller could not reach by listing.
|
||||
|
||||
### Administrator organization management
|
||||
|
||||
```text
|
||||
GET /api/uadmin/organizations
|
||||
POST /api/uadmin/organizations
|
||||
GET /api/uadmin/organizations/{organization_id}
|
||||
PATCH /api/uadmin/organizations/{organization_id}
|
||||
PATCH /api/uadmin/organizations/{organization_id}/status
|
||||
```
|
||||
|
||||
`GET` is available to any administrator. Every write is platform-administrator-only, which keeps the tenant boundary under a single authority. An organization administrator is pinned to the organization on their own principal: a browser-supplied organization filter cannot widen a list, and a foreign organization identifier is refused with the same generic error as any other forbidden target.
|
||||
|
||||
### Administrator audit
|
||||
|
||||
```text
|
||||
GET /api/uadmin/audit
|
||||
```
|
||||
|
||||
Scoped to the caller's organization, with an allow-listed, redacted `detail` object. No endpoint can return a password, a hash, or a session token.
|
||||
|
||||
## First administrator bootstrap
|
||||
|
||||
The first administrator is provisioned through a separate operator-only command, not the public admin API.
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
# UmbraTrans web account management — phase-1 identity slice
|
||||
# UmbraTrans web account and organization management — phase-1 slice
|
||||
|
||||
First backend slice for `docs/umbratrans-backend-design.md`: accounts, both
|
||||
enrollment modes, Argon2id credentials, server-side sessions, administrator
|
||||
lifecycle actions, and sanitized audit events, plus a FastAPI application
|
||||
factory.
|
||||
Backend for `docs/umbratrans-backend-design.md`: accounts and organizations,
|
||||
both enrollment modes, Argon2id credentials, server-side sessions,
|
||||
administrator lifecycle actions, sanitized audit events, and a FastAPI
|
||||
application factory that can also serve the browser application from the same
|
||||
origin.
|
||||
|
||||
**This slice does not connect to PostgreSQL.** The reviewed migration in
|
||||
`migrations/` has not been applied, and the production repository adapter is
|
||||
deliberately absent, so the application fails closed instead of starting
|
||||
against an unreviewed store.
|
||||
|
||||
## Tenancy
|
||||
|
||||
`organizations` is a first-class record with `name`, `slug`, and a status of
|
||||
`active`, `suspended`, or `archived`. Account creation validates the target
|
||||
organization and refuses a non-active one.
|
||||
|
||||
Phase 1 keeps membership as a single organization per user rather than the
|
||||
`user_roles` join the control plane uses. That is a deliberate simplification,
|
||||
recorded as an open review question in `migrations/README.md`; the migration
|
||||
creates the table and adds no membership column, so the decision is not
|
||||
silently settled.
|
||||
|
||||
Suspending or archiving an organization revokes its members' live sessions, and
|
||||
both `authenticate` and `resolve_session` refuse a member of a non-active
|
||||
organization. Revoking sessions alone is not sufficient: each member's own
|
||||
account status stays `active` and could otherwise mint a new session.
|
||||
|
||||
## Same-origin frontend
|
||||
|
||||
Set `UMBRATRANS_STATIC_ROOT` and the application serves the browser client from
|
||||
`/` after the API routes are registered, so the session cookie and the CSRF
|
||||
header work without CORS. There is deliberately no CORS middleware. A missing
|
||||
or non-directory root is refused rather than silently serving an API-only
|
||||
application.
|
||||
|
||||
**This slice does not connect to PostgreSQL.** The reviewed migration in
|
||||
`migrations/` has not been applied, and the production repository adapter is
|
||||
@@ -14,18 +45,18 @@ against an unreviewed store.
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `models.py` | Frozen dataclasses and enums. Hash fields are `repr=False`; `AccountView` is the only account shape that may cross the API. |
|
||||
| `security.py` | Argon2id hashing, password policy, session/CSRF tokens, log and audit redaction. |
|
||||
| `models.py` | Frozen dataclasses and enums. Hash fields are `repr=False`; `AccountView` and `OrganizationView` are the only shapes that may cross the API. |
|
||||
| `security.py` | Argon2id hashing, password policy, one-time password generation, session/CSRF tokens, log and audit redaction. |
|
||||
| `config.py` | Fail-closed settings. Production requires secure cookies, CSRF, and a non-development repository. |
|
||||
| `errors.py` | Domain errors with stable codes. Messages are generic so responses cannot enumerate accounts. |
|
||||
| `repository.py` | `AccountRepository` protocol plus the thread-safe in-memory development repository. |
|
||||
| `dev_repository.py` | Optional local JSON store: `0600` file, `0700` directory, atomic writes, refuses production. |
|
||||
| `service.py` | `AccountService`: enrollment, authentication, first-password change, sessions, administration, audit. |
|
||||
| `service.py` | `AccountService`: enrollment, authentication, first-password change, sessions, organizations, administration, audit. |
|
||||
| `schemas.py` | Pydantic request/response models. Request models forbid unknown fields. |
|
||||
| `app.py` | Application factory, security middleware, CSRF, error mapping, dependencies. |
|
||||
| `app.py` | Application factory, security middleware, CSRF, error mapping, static mounting, dependencies. |
|
||||
| `routes_auth.py`, `routes_uadmin.py` | `/api/auth/*` and `/api/uadmin/*`. |
|
||||
| `cli.py` | Hidden-prompt operator command and a loopback development server. |
|
||||
| `migrations/` | Proposed, unapplied phase-1 identity SQL and its rollback. |
|
||||
| `migrations/` | Proposed, unapplied phase-1 identity and tenancy SQL and its rollback. |
|
||||
|
||||
## Enrollment boundary
|
||||
|
||||
@@ -59,11 +90,17 @@ POST /api/auth/change-first-password
|
||||
POST /api/auth/logout
|
||||
POST /api/auth/logout-everywhere
|
||||
GET /api/auth/me
|
||||
GET /api/uadmin/summary
|
||||
GET /api/uadmin/accounts
|
||||
POST /api/uadmin/accounts (either enrollment mode)
|
||||
GET /api/uadmin/accounts/{id}
|
||||
PATCH /api/uadmin/accounts/{id}/status
|
||||
POST /api/uadmin/accounts/{id}/revoke-sessions
|
||||
GET /api/uadmin/organizations
|
||||
POST /api/uadmin/organizations (platform admin only)
|
||||
GET /api/uadmin/organizations/{id}
|
||||
PATCH /api/uadmin/organizations/{id} (platform admin only)
|
||||
PATCH /api/uadmin/organizations/{id}/status (platform admin only)
|
||||
GET /api/uadmin/audit
|
||||
```
|
||||
|
||||
@@ -120,7 +157,7 @@ material.
|
||||
## Not in this slice
|
||||
|
||||
- The PostgreSQL adapter, the OIDC callback and subject mapping, MFA
|
||||
enforcement, project membership, invitations, and audit export.
|
||||
enforcement, `user_roles` membership, invitations, and audit export.
|
||||
- Connection to `umbracontrol`, application of `migrations/0001_*`, and the
|
||||
first administrator in any approved environment.
|
||||
- Serving the static prototype: `web/` is unchanged and is not mounted.
|
||||
|
||||
@@ -23,12 +23,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from http.cookies import SimpleCookie
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, FastAPI, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
@@ -211,6 +213,7 @@ class ApiSecurityMiddleware:
|
||||
|
||||
path = scope.get("path", "")
|
||||
is_api = path.startswith("/api")
|
||||
is_html = not is_api and scope.get("method", "GET").upper() == "GET"
|
||||
method = scope.get("method", "GET").upper()
|
||||
|
||||
if is_api and self._settings.require_csrf and method in UNSAFE_METHODS and path not in CSRF_EXEMPT_PATHS:
|
||||
@@ -239,6 +242,17 @@ class ApiSecurityMiddleware:
|
||||
response_headers["cache-control"] = "no-store"
|
||||
response_headers["content-security-policy"] = "default-src 'none'; frame-ancestors 'none'"
|
||||
response_headers["vary"] = "Cookie"
|
||||
elif is_html:
|
||||
# The browser application. Same restrictive framing and
|
||||
# sniffing policy as the API, plus a policy that only allows
|
||||
# its own origin: no inline script, no remote origins.
|
||||
response_headers["cache-control"] = "no-store"
|
||||
response_headers["content-security-policy"] = (
|
||||
"default-src 'self'; base-uri 'none'; form-action 'self'; "
|
||||
"frame-ancestors 'none'; object-src 'none'; img-src 'self' data:; "
|
||||
"style-src 'self'; script-src 'self'"
|
||||
)
|
||||
response_headers["vary"] = "Cookie"
|
||||
if issued_csrf is not None:
|
||||
response_headers.append(
|
||||
"set-cookie",
|
||||
@@ -420,9 +434,31 @@ def create_app(
|
||||
resolved_settings.require_csrf,
|
||||
resolved_settings.cookie_secure,
|
||||
)
|
||||
if resolved_settings.static_root is not None:
|
||||
_mount_frontend(app, resolved_settings.static_root)
|
||||
LOGGER.info("frontend mounted from %s", resolved_settings.static_root)
|
||||
return app
|
||||
|
||||
|
||||
def _mount_frontend(app: FastAPI, static_root: Path) -> None:
|
||||
"""Serve the browser application from the same origin as the API.
|
||||
|
||||
Same-origin is not a convenience here. The session cookie is ``HttpOnly``
|
||||
and the CSRF token travels as a header, and the application deliberately
|
||||
installs no CORS middleware, so a separately hosted frontend could not call
|
||||
the API at all. Mounting after the routers are registered keeps every
|
||||
``/api`` route ahead of the static files.
|
||||
|
||||
A missing or non-directory root is refused rather than silently serving an
|
||||
API-only application, because that would look like a broken deployment.
|
||||
"""
|
||||
|
||||
root = static_root.expanduser()
|
||||
if not root.is_dir():
|
||||
raise ConfigurationError("static_root_missing", f"Static root is not a directory: {root}")
|
||||
app.mount("/", StaticFiles(directory=str(root), html=True), name="frontend")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ApiSecurityMiddleware",
|
||||
"CSRF_EXEMPT_PATHS",
|
||||
|
||||
@@ -121,6 +121,7 @@ class Settings:
|
||||
password_max_length: int = 1024
|
||||
repository_kind: str = "memory"
|
||||
repository_path: Path | None = None
|
||||
static_root: Path | None = None
|
||||
allowed_hosts: tuple[str, ...] = ()
|
||||
last_seen_write_interval_seconds: int = 30
|
||||
token_pepper: bytes | None = field(default=None, repr=False, compare=False)
|
||||
@@ -141,6 +142,8 @@ class Settings:
|
||||
raise ConfigurationError("invalid_samesite", "Cookie SameSite must be lax or strict.")
|
||||
if self.repository_path is not None and not isinstance(self.repository_path, Path):
|
||||
object.__setattr__(self, "repository_path", Path(self.repository_path))
|
||||
if self.static_root is not None and not isinstance(self.static_root, Path):
|
||||
object.__setattr__(self, "static_root", Path(self.static_root))
|
||||
if self.token_pepper is not None and len(self.token_pepper) < MIN_PEPPER_BYTES:
|
||||
raise ConfigurationError("weak_token_pepper", f"Token pepper must be at least {MIN_PEPPER_BYTES} bytes.")
|
||||
if any(host.strip() in {"*", ""} for host in self.allowed_hosts):
|
||||
@@ -234,6 +237,11 @@ def load_settings(environ: Mapping[str, str] | None = None) -> Settings:
|
||||
if raw_path:
|
||||
repository_path = Path(raw_path).expanduser()
|
||||
|
||||
static_root: Path | None = None
|
||||
raw_static = env.get("UMBRATRANS_STATIC_ROOT")
|
||||
if raw_static:
|
||||
static_root = Path(raw_static).expanduser()
|
||||
|
||||
allowed_hosts = tuple(host.strip() for host in env.get("UMBRATRANS_ALLOWED_HOSTS", "").split(",") if host.strip())
|
||||
|
||||
raw_pepper = env.get("UMBRATRANS_TOKEN_PEPPER")
|
||||
@@ -270,6 +278,7 @@ def load_settings(environ: Mapping[str, str] | None = None) -> Settings:
|
||||
),
|
||||
repository_kind=repository_kind,
|
||||
repository_path=repository_path,
|
||||
static_root=static_root,
|
||||
allowed_hosts=allowed_hosts,
|
||||
token_pepper=pepper,
|
||||
)
|
||||
|
||||
@@ -34,12 +34,18 @@ from .models import (
|
||||
AuditEvent,
|
||||
CredentialRecord,
|
||||
CredentialType,
|
||||
Organization,
|
||||
OrganizationStatus,
|
||||
RoleName,
|
||||
SessionRecord,
|
||||
User,
|
||||
)
|
||||
from .repository import InMemoryAccountRepository
|
||||
|
||||
#: Payload version. Organizations were added after the first files were
|
||||
#: written, and a missing ``organizations`` key loads as an empty list, so an
|
||||
#: older development file still opens. The version is bumped only when an
|
||||
#: existing file would load into a wrong state.
|
||||
SCHEMA_VERSION = 1
|
||||
DIRECTORY_MODE = 0o700
|
||||
FILE_MODE = 0o600
|
||||
@@ -118,6 +124,10 @@ class JsonDevelopmentRepository(InMemoryAccountRepository):
|
||||
f"Unsupported development repository schema version: {version!r}.",
|
||||
)
|
||||
with self._lock:
|
||||
for item in payload.get("organizations", []):
|
||||
organization = _organization_from_payload(item)
|
||||
self._organizations[organization.id] = organization
|
||||
self._organizations_by_slug[organization.slug_key] = organization.id
|
||||
for item in payload.get("users", []):
|
||||
self._users[UUID(item["id"])] = _user_from_payload(item)
|
||||
self._users_by_key[item["username_key"]] = UUID(item["id"])
|
||||
@@ -136,6 +146,9 @@ class JsonDevelopmentRepository(InMemoryAccountRepository):
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"environment": str(self._settings.environment),
|
||||
"organizations": [
|
||||
_organization_to_payload(organization) for organization in self._organizations.values()
|
||||
],
|
||||
"users": [_user_to_payload(user) for user in self._users.values()],
|
||||
"credentials": [_credential_to_payload(item) for item in self._credentials.values()],
|
||||
"sessions": [_session_to_payload(item) for item in self._sessions.values()],
|
||||
@@ -185,6 +198,16 @@ class JsonDevelopmentRepository(InMemoryAccountRepository):
|
||||
self.flush()
|
||||
return result
|
||||
|
||||
def create_organization(self, organization: Organization) -> Organization:
|
||||
result = super().create_organization(organization)
|
||||
self.flush()
|
||||
return result
|
||||
|
||||
def save_organization(self, organization: Organization) -> Organization:
|
||||
result = super().save_organization(organization)
|
||||
self.flush()
|
||||
return result
|
||||
|
||||
def save_user(self, user: User) -> User:
|
||||
result = super().save_user(user)
|
||||
self.flush()
|
||||
@@ -211,6 +234,30 @@ class JsonDevelopmentRepository(InMemoryAccountRepository):
|
||||
return result
|
||||
|
||||
|
||||
def _organization_to_payload(organization: Organization) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(organization.id),
|
||||
"name": organization.name,
|
||||
"slug": organization.slug,
|
||||
"status": str(organization.status),
|
||||
"created_at": _utc(organization.created_at) if organization.created_at else None,
|
||||
"updated_at": _utc(organization.updated_at) if organization.updated_at else None,
|
||||
"version": organization.version,
|
||||
}
|
||||
|
||||
|
||||
def _organization_from_payload(payload: Mapping[str, Any]) -> Organization:
|
||||
return Organization(
|
||||
id=UUID(payload["id"]),
|
||||
name=payload["name"],
|
||||
slug=payload["slug"],
|
||||
status=OrganizationStatus(payload["status"]),
|
||||
created_at=_parse_datetime(payload["created_at"]) if payload.get("created_at") else None,
|
||||
updated_at=_parse_datetime(payload["updated_at"]) if payload.get("updated_at") else None,
|
||||
version=int(payload.get("version", 1)),
|
||||
)
|
||||
|
||||
|
||||
def _user_to_payload(user: User) -> dict[str, Any]:
|
||||
return {
|
||||
"id": str(user.id),
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
-- signed off by a human operator, and until the review questions in
|
||||
-- migrations/README.md are answered against the *actual* production schema.
|
||||
--
|
||||
-- SCOPE (phase-1 identity only; nothing else may be added to this file)
|
||||
-- SCOPE (phase-1 identity and tenancy only; nothing else may be added here)
|
||||
--
|
||||
-- ADD COLUMN users.username (nullable, unique, citext)
|
||||
-- ADD TABLE user_credentials (local_password / oidc credentials)
|
||||
-- ADD TABLE organizations (tenants, additive; skipped if present)
|
||||
-- ADD COLUMN sessions.mfa_verified_at (nullable)
|
||||
-- ADD COLUMN sessions.idle_deadline_at (nullable)
|
||||
-- ADD COLUMN sessions.last_seen_at (nullable, backfilled, then NOT NULL)
|
||||
@@ -22,8 +23,13 @@
|
||||
--
|
||||
-- * No project_membership table. user_roles stays the membership source
|
||||
-- of truth.
|
||||
-- * No organizations/projects/roles/permissions/invitations/audit_events
|
||||
-- change. They are used unchanged.
|
||||
-- * No users.organization_id column. The control plane derives membership
|
||||
-- from user_roles; the application slice uses a column for phase 1 and
|
||||
-- the divergence is an open review question, not a settled decision.
|
||||
-- * No projects/roles/permissions/invitations/audit_events change. They are
|
||||
-- used unchanged.
|
||||
-- * No organization row is inserted. Section 4 creates the table if it is
|
||||
-- absent and verifies it if it is present; it never seeds a tenant.
|
||||
-- * No password, hash, token, or connection string may appear in this file.
|
||||
-- * No backfill of existing accounts. Existing rows keep a NULL username
|
||||
-- until an operator supplies an explicit mapping (see BACKFILL GATE).
|
||||
@@ -107,6 +113,15 @@
|
||||
-- -- confirm nothing named user_credentials already exists
|
||||
-- SELECT to_regclass('public.user_credentials') AS user_credentials;
|
||||
--
|
||||
-- -- does public.organizations already exist? Section 4 is a no-op when it
|
||||
-- -- does, so record its current shape and row count either way
|
||||
-- SELECT to_regclass('public.organizations') AS organizations;
|
||||
-- SELECT column_name, data_type, is_nullable
|
||||
-- FROM information_schema.columns
|
||||
-- WHERE table_schema = 'public' AND table_name = 'organizations'
|
||||
-- ORDER BY ordinal_position;
|
||||
-- SELECT count(*) FROM public.organizations;
|
||||
--
|
||||
-- -- row counts used as the post-apply baseline
|
||||
-- SELECT (SELECT count(*) FROM users) AS users, (SELECT count(*) FROM sessions) AS sessions;
|
||||
--
|
||||
@@ -256,7 +271,59 @@ CREATE INDEX IF NOT EXISTS sessions_idle_deadline_at_idx
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. RLS AND GRANTS (REVIEWED, NOT EXECUTED)
|
||||
-- 4. organizations
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Assumptions: the control plane already has a public.organizations table
|
||||
-- (see 001_control_plane.sql) with id, name, slug, status, created_at and
|
||||
-- updated_at. If it already exists this section is a no-op: CREATE TABLE IF
|
||||
-- NOT EXISTS skips it and the statements below verify the shape instead of
|
||||
-- changing it. That is the expected case, because this file is additive by
|
||||
-- design and must not redefine a table the audio system already reads.
|
||||
--
|
||||
-- The application needs organizations to exist as first-class rows for the
|
||||
-- administrator interface to manage tenants and to validate an account's
|
||||
-- organization assignment. Without this section that assignment pointed at
|
||||
-- nothing at all.
|
||||
--
|
||||
-- Deliberately NOT changed here:
|
||||
-- * users.organization_id is NOT added by this file. The control plane
|
||||
-- derives organization membership from user_roles (scope='organization'),
|
||||
-- not from a column on users. The application slice models membership as
|
||||
-- a column for phase 1; reconciling the two is recorded as an open review
|
||||
-- question in migrations/README.md rather than silently decided here.
|
||||
-- * No organization row is inserted. Seeding a tenant is an operator action.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.organizations (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
slug citext NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT organizations_status_check
|
||||
CHECK (status IN ('active', 'suspended', 'archived')),
|
||||
CONSTRAINT organizations_name_length_check
|
||||
CHECK (char_length(name) BETWEEN 2 AND 120)
|
||||
);
|
||||
|
||||
-- A duplicate-free tenant handle. Built then attached, so the scan does not
|
||||
-- hold an ACCESS EXCLUSIVE lock for its duration.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS organizations_slug_key
|
||||
ON public.organizations (slug);
|
||||
|
||||
ALTER TABLE public.organizations
|
||||
ADD CONSTRAINT organizations_slug_key
|
||||
UNIQUE USING INDEX organizations_slug_key;
|
||||
|
||||
COMMENT ON TABLE public.organizations IS
|
||||
'Tenants that own projects and accounts. Phase 1 inserts no rows; the control plane may already contain some.';
|
||||
|
||||
-- The administrator dashboard filters by status on every load.
|
||||
CREATE INDEX IF NOT EXISTS organizations_status_idx
|
||||
ON public.organizations (status);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. RLS AND GRANTS (REVIEWED, NOT EXECUTED)
|
||||
-- ---------------------------------------------------------------------
|
||||
-- The statements below are intentionally provided as comments. The
|
||||
-- production policy helper functions, the application role names, and the
|
||||
@@ -306,11 +373,26 @@ CREATE INDEX IF NOT EXISTS sessions_idle_deadline_at_idx
|
||||
-- \d+ public.users
|
||||
-- \d+ public.user_credentials
|
||||
-- \d+ public.sessions
|
||||
-- \d+ public.organizations
|
||||
--
|
||||
-- -- the new unique rule is enforced and case-insensitive
|
||||
-- SELECT indexname, indexdef FROM pg_indexes
|
||||
-- WHERE tablename = 'users' AND indexname = 'users_username_key';
|
||||
--
|
||||
-- -- the organizations slug rule survived, whether the table pre-existed or
|
||||
-- -- was created by section 4
|
||||
-- SELECT indexname, indexdef FROM pg_indexes
|
||||
-- WHERE tablename = 'organizations' AND indexname = 'organizations_slug_key';
|
||||
--
|
||||
-- -- the status vocabulary the application expects is the one the check
|
||||
-- -- constraint enforces
|
||||
-- SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
|
||||
-- WHERE conrelid = 'public.organizations'::regclass AND contype = 'c'
|
||||
-- ORDER BY conname;
|
||||
--
|
||||
-- -- this migration inserted no tenant
|
||||
-- SELECT count(*) FROM public.organizations;
|
||||
--
|
||||
-- -- no duplicate, non-null usernames can exist
|
||||
-- SELECT lower(username::text) AS key, count(*)
|
||||
-- FROM public.users WHERE username IS NOT NULL
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- =====================================================================
|
||||
-- Rollback for: UmbraTrans phase-1 identity migration
|
||||
-- (users.username, user_credentials, additive session columns)
|
||||
-- Rollback for: UmbraTrans phase-1 identity and tenancy migration
|
||||
-- (users.username, user_credentials, organizations,
|
||||
-- additive session columns)
|
||||
-- =====================================================================
|
||||
--
|
||||
-- STATUS: PROPOSED / NOT APPLIED / NOT APPROVED
|
||||
@@ -14,6 +15,10 @@
|
||||
-- forward migration lose their only authentication material and cannot
|
||||
-- sign in until an operator re-provisions them through the operator
|
||||
-- path. There is no recovery short of restoring a backup.
|
||||
-- * DROP TABLE public.organizations DESTROYS every tenant row. The DROP is
|
||||
-- guarded below so it only fires when the forward migration actually
|
||||
-- created the table, because the control plane very likely already had
|
||||
-- one. If organizations pre-existed, its rows are left alone.
|
||||
-- * DROP CONSTRAINT users_username_key / the backing index removes case
|
||||
-- insensitive uniqueness. The username VALUES are kept by this rollback;
|
||||
-- drop the column only if the mapping must be discarded.
|
||||
@@ -46,6 +51,7 @@ SET LOCAL idle_in_transaction_session_timeout = '30s';
|
||||
-- SELECT count(*) AS credentials_to_be_dropped FROM public.user_credentials;
|
||||
-- SELECT credential_type, count(*) FROM public.user_credentials GROUP BY 1;
|
||||
-- SELECT count(*) AS live_sessions FROM public.sessions WHERE revoked_at IS NULL;
|
||||
-- SELECT count(*) AS organizations FROM public.organizations;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. Additive session columns
|
||||
@@ -71,7 +77,41 @@ ALTER TABLE public.sessions
|
||||
DROP TABLE IF EXISTS public.user_credentials;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. users.username
|
||||
-- 4. organizations
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Dropped only when this migration created the table. The forward file uses
|
||||
-- CREATE TABLE IF NOT EXISTS, so in the expected case the control plane owned
|
||||
-- organizations first and its rows, slug constraint, and dependent projects
|
||||
-- rows must survive the rollback untouched.
|
||||
--
|
||||
-- Drop the second guard's condition to force the drop. Do not do that without
|
||||
-- a backup: projects.organization_id and user_roles.organization_id reference
|
||||
-- this table.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'public'
|
||||
AND t.relname = 'organizations'
|
||||
AND c.conname = 'organizations_status_check'
|
||||
AND pg_get_constraintdef(c.oid) LIKE '%archived%'
|
||||
) THEN
|
||||
-- The forward migration's own check constraint is absent, which means
|
||||
-- the table was not created by this migration.
|
||||
RAISE NOTICE 'organizations was not created by 0001; leaving it in place';
|
||||
ELSE
|
||||
DROP INDEX IF EXISTS public.organizations_status_idx;
|
||||
DROP TABLE IF EXISTS public.organizations;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. users.username
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Values are preserved by default. Uncomment the second statement only when
|
||||
-- the mapping must be discarded as well.
|
||||
@@ -86,7 +126,7 @@ DROP INDEX IF EXISTS public.users_username_key;
|
||||
-- explicitly reviewed change.
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. POST-ROLLBACK VERIFICATION
|
||||
-- 6. POST-ROLLBACK VERIFICATION
|
||||
-- ---------------------------------------------------------------------
|
||||
--
|
||||
-- \d+ public.users
|
||||
@@ -96,7 +136,10 @@ DROP INDEX IF EXISTS public.users_username_key;
|
||||
-- SELECT count(*) FROM pg_policies WHERE schemaname = 'public'
|
||||
-- AND tablename = 'user_credentials'; -- 0
|
||||
-- SELECT count(*) FROM public.sessions WHERE idle_deadline_at IS NOT NULL; -- 0
|
||||
--
|
||||
-- -- either organizations is gone, or it is exactly as it was before the
|
||||
-- -- forward migration; compare against the pre-flight row count
|
||||
-- SELECT to_regclass('public.organizations') AS organizations;
|
||||
|
||||
-- -- the pre-existing schema must be unchanged apart from this rollback
|
||||
-- SELECT column_name, data_type FROM information_schema.columns
|
||||
-- WHERE table_schema = 'public' AND table_name IN ('users', 'sessions')
|
||||
|
||||
@@ -8,12 +8,12 @@ identity change before an operator runs it under a change ticket.
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `0001_phase1_identity.sql` | Forward migration: `users.username`, `user_credentials`, additive `sessions` columns |
|
||||
| `0001_phase1_identity.sql` | Forward migration: `users.username`, `user_credentials`, `organizations`, additive `sessions` columns |
|
||||
| `0001_phase1_identity_rollback.sql` | Destructive rollback with prerequisites and verification queries |
|
||||
|
||||
## What the forward migration changes
|
||||
|
||||
Exactly three things, matching `docs/umbratrans-backend-design.md`:
|
||||
Exactly four things, matching `docs/umbratrans-backend-design.md`:
|
||||
|
||||
1. `users.username` — nullable, unique, `citext` (case-insensitive). Existing
|
||||
rows stay `NULL` until an operator supplies an explicit mapping.
|
||||
@@ -22,19 +22,42 @@ Exactly three things, matching `docs/umbratrans-backend-design.md`:
|
||||
that is required for `local_password` and forbidden for `oidc`,
|
||||
`must_change_password`, `failed_login_count`, `locked_until`,
|
||||
`last_login_at`, `password_changed_at`, and timestamps.
|
||||
3. `sessions` — additive columns `mfa_verified_at`, `idle_deadline_at`,
|
||||
3. `organizations` — created with `CREATE TABLE IF NOT EXISTS` and the slug
|
||||
constraint attached, so it is a no-op when the control plane already owns
|
||||
the table. The application's phase-1 service validates
|
||||
`users.organization_id` against it, so without it that column referenced
|
||||
nothing.
|
||||
4. `sessions` — additive columns `mfa_verified_at`, `idle_deadline_at`,
|
||||
`last_seen_at`. Nothing is dropped, renamed, or retyped.
|
||||
|
||||
It creates no credential rows, backfills no username, and changes no
|
||||
organization, project, role, permission, invitation, or audit table.
|
||||
It creates no credential rows, backfills no username, inserts no organization,
|
||||
and changes no project, role, permission, invitation, or audit table.
|
||||
|
||||
## Review questions the file cannot answer itself
|
||||
|
||||
These are BLOCKER-level unknowns. This workspace has no access to the
|
||||
production schema, so the file states its assumptions instead of guessing.
|
||||
|
||||
- **Membership model divergence (the important one).** The control plane
|
||||
derives organization membership from `user_roles` with
|
||||
`scope = 'organization'`, and supports a project-scoped membership the
|
||||
application slice does not model. The application currently stores
|
||||
membership as a column on the user record. One of these has to change
|
||||
before the PostgreSQL adapter is written:
|
||||
- keep the column and accept that a user has at most one organization and no
|
||||
project membership, or
|
||||
- move the service onto `user_roles`, which means reading and writing
|
||||
role assignments and reconciling `RoleName` with the `roles` table.
|
||||
|
||||
This migration deliberately does not decide it. It creates the
|
||||
`organizations` table and adds no membership column, so the choice stays
|
||||
open and reviewable.
|
||||
- Does `users` have a `uuid` primary key named `id`, and no existing column
|
||||
named `username`?
|
||||
- Does `organizations` already exist in the control plane, and if so does its
|
||||
`status` check constraint already allow `archived`? The rollback keys on
|
||||
that constraint, and a mismatch would make the guarded drop fall through to
|
||||
the `RAISE NOTICE` branch and leave the table in place.
|
||||
- What is the name of the "current user" RLS helper (`app.current_user_id()`,
|
||||
a request-scoped GUC, or something else), and can a client influence it?
|
||||
- Are `users` and `sessions` already under RLS, and with `FORCE`?
|
||||
|
||||
@@ -32,10 +32,15 @@ MIN_USERNAME_LENGTH = 3
|
||||
MAX_DISPLAY_NAME_LENGTH = 120
|
||||
MAX_EMAIL_LENGTH = 254
|
||||
MAX_REASON_LENGTH = 200
|
||||
MAX_ORGANIZATION_NAME_LENGTH = 120
|
||||
MIN_ORGANIZATION_NAME_LENGTH = 2
|
||||
MAX_ORGANIZATION_SLUG_LENGTH = 63
|
||||
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$")
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s.]+(?:\.[^@\s.]+)+$")
|
||||
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
_SLUG_SEPARATOR_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
#: Detail keys an audit event may carry. Anything else is dropped, so a future
|
||||
#: caller cannot accidentally record a password or a token by adding a key.
|
||||
@@ -44,8 +49,11 @@ ALLOWED_AUDIT_DETAIL_KEYS = frozenset(
|
||||
"credential_type",
|
||||
"enrollment_mode",
|
||||
"lock_state",
|
||||
"member_count",
|
||||
"must_change_password",
|
||||
"organization_id",
|
||||
"organization_name",
|
||||
"organization_slug",
|
||||
"previous_status",
|
||||
"reason",
|
||||
"revoked_sessions",
|
||||
@@ -94,6 +102,31 @@ class RoleName(StrEnum):
|
||||
MEMBER = "member"
|
||||
|
||||
|
||||
class OrganizationStatus(StrEnum):
|
||||
"""Lifecycle of a tenant.
|
||||
|
||||
Mirrors the control-plane ``organizations.status`` check constraint
|
||||
(``active``, ``suspended``, ``archived``) rather than the account status
|
||||
set, so an organization and its members never share a vocabulary.
|
||||
"""
|
||||
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
#: Allowed organization status transitions. ``archived`` is terminal: a
|
||||
#: deliberate, separately reviewed operation restores an archived organization,
|
||||
#: because its name and slug are historically significant.
|
||||
ALLOWED_ORGANIZATION_TRANSITIONS: Mapping[OrganizationStatus, frozenset[OrganizationStatus]] = MappingProxyType(
|
||||
{
|
||||
OrganizationStatus.ACTIVE: frozenset({OrganizationStatus.SUSPENDED, OrganizationStatus.ARCHIVED}),
|
||||
OrganizationStatus.SUSPENDED: frozenset({OrganizationStatus.ACTIVE, OrganizationStatus.ARCHIVED}),
|
||||
OrganizationStatus.ARCHIVED: frozenset(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
#: Allowed account status transitions. ``deactivated`` may only be reversed by
|
||||
#: a platform administrator, which the service checks separately.
|
||||
ALLOWED_STATUS_TRANSITIONS: Mapping[AccountStatus, frozenset[AccountStatus]] = MappingProxyType(
|
||||
@@ -158,6 +191,62 @@ def normalize_reason(value: str | None) -> str | None:
|
||||
return text
|
||||
|
||||
|
||||
def normalize_organization_name(value: str) -> str:
|
||||
text = " ".join(value.split())
|
||||
if not MIN_ORGANIZATION_NAME_LENGTH <= len(text) <= MAX_ORGANIZATION_NAME_LENGTH or _CONTROL_CHARS_RE.search(text):
|
||||
raise InputError(
|
||||
"invalid_organization_name",
|
||||
f"Organization name must be {MIN_ORGANIZATION_NAME_LENGTH}-{MAX_ORGANIZATION_NAME_LENGTH} characters.",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def normalize_organization_slug(value: str) -> str:
|
||||
"""Return the canonical stored form of an organization slug.
|
||||
|
||||
The slug is the operator-facing handle used in URLs and is stored
|
||||
case-insensitively as ``citext``, so the canonical form is lower case with
|
||||
single hyphens between alphanumeric groups.
|
||||
"""
|
||||
|
||||
text = _SLUG_SEPARATOR_RE.sub("-", value.strip().lower()).strip("-")
|
||||
if not text or len(text) > MAX_ORGANIZATION_SLUG_LENGTH or not _SLUG_RE.fullmatch(text):
|
||||
raise InputError(
|
||||
"invalid_organization_slug",
|
||||
"Organization slug must be lowercase letters, digits, and single hyphens.",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def require_canonical_organization_slug(value: str) -> str:
|
||||
"""Validate an operator-supplied slug without rewriting it.
|
||||
|
||||
A derived slug may be normalized out of any display name, but a slug the
|
||||
operator typed is a handle they will paste into URLs and scripts. Silently
|
||||
turning ``Not A Slug`` into ``not-a-slug`` hands back something they did
|
||||
not write, so a supplied slug must already be canonical.
|
||||
"""
|
||||
|
||||
text = value.strip()
|
||||
if not text or len(text) > MAX_ORGANIZATION_SLUG_LENGTH or not _SLUG_RE.fullmatch(text):
|
||||
raise InputError(
|
||||
"invalid_organization_slug",
|
||||
"Organization slug must be lowercase letters, digits, and single hyphens.",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def slugify_organization_name(value: str) -> str:
|
||||
"""Derive a candidate slug from a display name.
|
||||
|
||||
Used when an administrator does not supply one. Raises the same
|
||||
:class:`InputError` as :func:`normalize_organization_slug` when the name
|
||||
contains nothing slug-safe, so the caller reports one consistent message.
|
||||
"""
|
||||
|
||||
return normalize_organization_slug(value)
|
||||
|
||||
|
||||
def utc_isoformat(value: datetime) -> str:
|
||||
"""Serialize an aware datetime as UTC with a ``Z`` suffix."""
|
||||
|
||||
@@ -313,6 +402,48 @@ class AccountView:
|
||||
active_session_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Organization:
|
||||
"""A tenant that owns projects and, through it, accounts.
|
||||
|
||||
Phase 1 keeps organization membership as ``User.organization_id`` rather
|
||||
than a ``user_roles`` join. That is a deliberate simplification recorded in
|
||||
``migrations/README.md`` as an open review question, because the control
|
||||
plane derives membership from ``user_roles`` with a project scope this
|
||||
slice does not model.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
status: OrganizationStatus = OrganizationStatus.ACTIVE
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
version: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "status", OrganizationStatus(self.status))
|
||||
object.__setattr__(self, "name", normalize_organization_name(self.name))
|
||||
object.__setattr__(self, "slug", normalize_organization_slug(self.slug))
|
||||
|
||||
@property
|
||||
def slug_key(self) -> str:
|
||||
return self.slug.casefold()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationView:
|
||||
"""The only organization representation that may cross the API boundary."""
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
status: OrganizationStatus
|
||||
member_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
"""A resolved caller identity. Never built from browser-supplied values."""
|
||||
@@ -349,11 +480,15 @@ class AuthenticationResult:
|
||||
__all__ = [
|
||||
"ADMIN_ROLES",
|
||||
"ALLOWED_AUDIT_DETAIL_KEYS",
|
||||
"ALLOWED_ORGANIZATION_TRANSITIONS",
|
||||
"ALLOWED_STATUS_TRANSITIONS",
|
||||
"MAX_DISPLAY_NAME_LENGTH",
|
||||
"MAX_EMAIL_LENGTH",
|
||||
"MAX_ORGANIZATION_NAME_LENGTH",
|
||||
"MAX_ORGANIZATION_SLUG_LENGTH",
|
||||
"MAX_REASON_LENGTH",
|
||||
"MAX_USERNAME_LENGTH",
|
||||
"MIN_ORGANIZATION_NAME_LENGTH",
|
||||
"MIN_USERNAME_LENGTH",
|
||||
"AccountStatus",
|
||||
"AccountView",
|
||||
@@ -364,14 +499,21 @@ __all__ = [
|
||||
"CredentialType",
|
||||
"EnrollmentMode",
|
||||
"IssuedSession",
|
||||
"Organization",
|
||||
"OrganizationStatus",
|
||||
"OrganizationView",
|
||||
"Principal",
|
||||
"RoleName",
|
||||
"SessionRecord",
|
||||
"User",
|
||||
"normalize_display_name",
|
||||
"normalize_email",
|
||||
"normalize_organization_name",
|
||||
"normalize_organization_slug",
|
||||
"normalize_reason",
|
||||
"normalize_username",
|
||||
"require_canonical_organization_slug",
|
||||
"slugify_organization_name",
|
||||
"username_key",
|
||||
"utc_isoformat",
|
||||
]
|
||||
|
||||
@@ -22,6 +22,8 @@ from .models import (
|
||||
AccountStatus,
|
||||
AuditEvent,
|
||||
CredentialRecord,
|
||||
Organization,
|
||||
OrganizationStatus,
|
||||
SessionRecord,
|
||||
User,
|
||||
username_key,
|
||||
@@ -43,6 +45,27 @@ class AccountRepository(Protocol):
|
||||
def get_user_by_username(self, key: str) -> User | None:
|
||||
"""Look up an account by its case-insensitive username key."""
|
||||
|
||||
def count_users(self, organization_id: UUID | None = None) -> int:
|
||||
"""Count accounts, optionally within one organization."""
|
||||
|
||||
def find_organizations(
|
||||
self,
|
||||
*,
|
||||
status: OrganizationStatus | None = None,
|
||||
organization_ids: tuple[UUID, ...] | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Organization], int]:
|
||||
"""Return a page of organizations and the total match count."""
|
||||
|
||||
def get_organization(self, organization_id: UUID) -> Organization | None: ...
|
||||
|
||||
def create_organization(self, organization: Organization) -> Organization:
|
||||
"""Insert a new organization, or raise :class:`ConflictError` on a duplicate slug."""
|
||||
|
||||
def save_organization(self, organization: Organization) -> Organization:
|
||||
"""Persist a modified organization, or raise :class:`NotFoundError`."""
|
||||
|
||||
def find_users(
|
||||
self,
|
||||
*,
|
||||
@@ -106,6 +129,8 @@ class InMemoryAccountRepository:
|
||||
self._lock = threading.RLock()
|
||||
self._users: dict[UUID, User] = {}
|
||||
self._users_by_key: dict[str, UUID] = {}
|
||||
self._organizations: dict[UUID, Organization] = {}
|
||||
self._organizations_by_slug: dict[str, UUID] = {}
|
||||
self._credentials: dict[UUID, CredentialRecord] = {}
|
||||
self._sessions: dict[UUID, SessionRecord] = {}
|
||||
self._sessions_by_token: dict[str, UUID] = {}
|
||||
@@ -145,6 +170,68 @@ class InMemoryAccountRepository:
|
||||
user_id = self._users_by_key.get(username_key(key))
|
||||
return self._users.get(user_id) if user_id else None
|
||||
|
||||
def count_users(self, organization_id: UUID | None = None) -> int:
|
||||
with self._lock:
|
||||
if organization_id is None:
|
||||
return len(self._users)
|
||||
return sum(1 for user in self._users.values() if user.organization_id == organization_id)
|
||||
|
||||
# -- organizations -------------------------------------------------------
|
||||
|
||||
def create_organization(self, organization: Organization) -> Organization:
|
||||
with self._lock:
|
||||
if organization.id in self._organizations:
|
||||
raise ConflictError("organization_exists", "An organization with this identifier already exists.")
|
||||
if organization.slug_key in self._organizations_by_slug:
|
||||
raise ConflictError("organization_slug_taken", "That organization slug is already in use.")
|
||||
self._organizations[organization.id] = organization
|
||||
self._organizations_by_slug[organization.slug_key] = organization.id
|
||||
return organization
|
||||
|
||||
def save_organization(self, organization: Organization) -> Organization:
|
||||
with self._lock:
|
||||
existing = self._organizations.get(organization.id)
|
||||
if existing is None:
|
||||
raise NotFoundError("organization_not_found")
|
||||
if existing.slug_key != organization.slug_key:
|
||||
if organization.slug_key in self._organizations_by_slug:
|
||||
raise ConflictError("organization_slug_taken", "That organization slug is already in use.")
|
||||
del self._organizations_by_slug[existing.slug_key]
|
||||
self._organizations_by_slug[organization.slug_key] = organization.id
|
||||
self._organizations[organization.id] = organization
|
||||
return organization
|
||||
|
||||
def get_organization(self, organization_id: UUID) -> Organization | None:
|
||||
with self._lock:
|
||||
return self._organizations.get(organization_id)
|
||||
|
||||
def get_organization_by_slug(self, slug: str) -> Organization | None:
|
||||
with self._lock:
|
||||
organization_id = self._organizations_by_slug.get(slug.casefold())
|
||||
return self._organizations.get(organization_id) if organization_id else None
|
||||
|
||||
def find_organizations(
|
||||
self,
|
||||
*,
|
||||
status: OrganizationStatus | None = None,
|
||||
organization_ids: tuple[UUID, ...] | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Organization], int]:
|
||||
with self._lock:
|
||||
matches = list(self._organizations.values())
|
||||
if organization_ids is not None:
|
||||
allowed = set(organization_ids)
|
||||
matches = [organization for organization in matches if organization.id in allowed]
|
||||
if status is not None:
|
||||
matches = [organization for organization in matches if organization.status is status]
|
||||
matches.sort(
|
||||
key=lambda organization: (organization.created_at or organization.updated_at, str(organization.id)),
|
||||
reverse=True,
|
||||
)
|
||||
total = len(matches)
|
||||
return matches[offset : offset + max(limit, 0)], total
|
||||
|
||||
def find_users(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 .models import AccountStatus, EnrollmentMode, OrganizationStatus, Principal, RoleName
|
||||
from .schemas import (
|
||||
AccountListResponse,
|
||||
AccountResponse,
|
||||
@@ -29,9 +29,16 @@ from .schemas import (
|
||||
AuditListResponse,
|
||||
CreateAccountRequest,
|
||||
CreateAccountResponse,
|
||||
CreateOrganizationRequest,
|
||||
OrganizationListResponse,
|
||||
OrganizationResponse,
|
||||
OrganizationStatusChangeResponse,
|
||||
OrganizationSummaryResponse,
|
||||
RevokeSessionsResponse,
|
||||
SetAccountStatusRequest,
|
||||
SetOrganizationStatusRequest,
|
||||
StatusChangeResponse,
|
||||
UpdateOrganizationRequest,
|
||||
)
|
||||
from .service import AccountService
|
||||
|
||||
@@ -157,6 +164,112 @@ def revoke_sessions(
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit", response_model=AuditListResponse)
|
||||
def list_audit_events(
|
||||
request: Request,
|
||||
|
||||
@@ -24,6 +24,8 @@ from .models import (
|
||||
CredentialSummary,
|
||||
CredentialType,
|
||||
EnrollmentMode,
|
||||
OrganizationStatus,
|
||||
OrganizationView,
|
||||
RoleName,
|
||||
)
|
||||
|
||||
@@ -248,6 +250,81 @@ class StatusChangeResponse(BaseModel):
|
||||
revoked_sessions: int
|
||||
|
||||
|
||||
class OrganizationResponse(BaseModel):
|
||||
"""An organization as returned by the administrator API."""
|
||||
|
||||
id: UUID
|
||||
name: str
|
||||
slug: str
|
||||
status: OrganizationStatus
|
||||
member_count: int
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@classmethod
|
||||
def from_view(cls, view: OrganizationView) -> OrganizationResponse:
|
||||
return cls(
|
||||
id=view.id,
|
||||
name=view.name,
|
||||
slug=view.slug,
|
||||
status=view.status,
|
||||
member_count=view.member_count,
|
||||
created_at=view.created_at,
|
||||
updated_at=view.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class OrganizationListResponse(BaseModel):
|
||||
items: list[OrganizationResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class CreateOrganizationRequest(RequestModel):
|
||||
"""Create a tenant. Platform administrators only.
|
||||
|
||||
``slug`` is optional and derived from ``name`` when omitted. A supplied
|
||||
slug is validated as written, so a rejected value reports the slug rule.
|
||||
"""
|
||||
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
slug: str | None = Field(default=None, max_length=63)
|
||||
|
||||
|
||||
class UpdateOrganizationRequest(RequestModel):
|
||||
"""Rename or re-slug an organization. At least one field is required."""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
slug: str | None = Field(default=None, max_length=63)
|
||||
|
||||
|
||||
class SetOrganizationStatusRequest(RequestModel):
|
||||
status: OrganizationStatus
|
||||
reason: str | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
class OrganizationStatusChangeResponse(BaseModel):
|
||||
organization: OrganizationResponse
|
||||
revoked_sessions: int
|
||||
|
||||
|
||||
class OrganizationSummaryResponse(BaseModel):
|
||||
"""Counts for the administrator dashboard.
|
||||
|
||||
Derived from the caller's visible scope on every request, so it can never
|
||||
report a number the caller could not obtain from the list endpoints.
|
||||
"""
|
||||
|
||||
total_organizations: int
|
||||
active_organizations: int
|
||||
total_accounts: int
|
||||
active_accounts: int
|
||||
suspended_accounts: int
|
||||
pending_first_login: int
|
||||
organization_admin_count: int
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountListResponse",
|
||||
"AccountResponse",
|
||||
@@ -262,9 +339,16 @@ __all__ = [
|
||||
"LoginRequest",
|
||||
"LoginResponse",
|
||||
"LogoutResponse",
|
||||
"CreateOrganizationRequest",
|
||||
"OrganizationListResponse",
|
||||
"OrganizationResponse",
|
||||
"OrganizationStatusChangeResponse",
|
||||
"OrganizationSummaryResponse",
|
||||
"RevokeSessionsResponse",
|
||||
"SessionUserResponse",
|
||||
"SetAccountStatusRequest",
|
||||
"SetOrganizationStatusRequest",
|
||||
"StatusChangeResponse",
|
||||
"UpdateOrganizationRequest",
|
||||
"UserResponse",
|
||||
]
|
||||
|
||||
@@ -52,6 +52,7 @@ from .errors import (
|
||||
StateTransitionError,
|
||||
)
|
||||
from .models import (
|
||||
ALLOWED_ORGANIZATION_TRANSITIONS,
|
||||
ALLOWED_STATUS_TRANSITIONS,
|
||||
AccountStatus,
|
||||
AccountView,
|
||||
@@ -62,14 +63,20 @@ from .models import (
|
||||
CredentialType,
|
||||
EnrollmentMode,
|
||||
IssuedSession,
|
||||
Organization,
|
||||
OrganizationStatus,
|
||||
OrganizationView,
|
||||
Principal,
|
||||
RoleName,
|
||||
SessionRecord,
|
||||
User,
|
||||
normalize_display_name,
|
||||
normalize_email,
|
||||
normalize_organization_name,
|
||||
normalize_reason,
|
||||
normalize_username,
|
||||
require_canonical_organization_slug,
|
||||
slugify_organization_name,
|
||||
username_key,
|
||||
)
|
||||
from .repository import AccountRepository
|
||||
@@ -94,6 +101,9 @@ ADMIN_ENROLLMENT_SOURCE = "admin-api"
|
||||
ACTION_ACCOUNT_CREATED = "account.created"
|
||||
ACTION_ACCOUNT_PROVISIONED_LOCAL = "account.provisioned_local"
|
||||
ACTION_ACCOUNT_STATUS_CHANGED = "account.status_changed"
|
||||
ACTION_ORGANIZATION_CREATED = "organization.created"
|
||||
ACTION_ORGANIZATION_UPDATED = "organization.updated"
|
||||
ACTION_ORGANIZATION_STATUS_CHANGED = "organization.status_changed"
|
||||
ACTION_AUTH_LOGIN = "auth.login"
|
||||
ACTION_AUTH_FIRST_PASSWORD_CHANGED = "auth.first_password_changed"
|
||||
ACTION_AUTH_LOGOUT = "auth.logout"
|
||||
@@ -239,6 +249,7 @@ class AccountService:
|
||||
)
|
||||
if not isinstance(password, str) or not password:
|
||||
raise InputError("password_required", "A one-time password is required for this enrollment mode.")
|
||||
self._validate_organization_for_assignment(organization_id)
|
||||
self._passwords.policy.validate(password, username=username)
|
||||
password_hash = self._passwords.hash_password(password)
|
||||
user, credential = self._insert_account(
|
||||
@@ -301,6 +312,7 @@ class AccountService:
|
||||
if actor is None: # pragma: no cover - _require_admin raises first
|
||||
raise ForbiddenError("administrator_required")
|
||||
self._authorize_creation(actor, role=role, organization_id=organization_id)
|
||||
self._validate_organization_for_assignment(organization_id)
|
||||
temporary_password = self._passwords.generate_temporary_password(username=normalize_username(username))
|
||||
user, credential = self._insert_account(
|
||||
username=username,
|
||||
@@ -354,6 +366,7 @@ class AccountService:
|
||||
if actor is None: # pragma: no cover - _require_admin raises first
|
||||
raise ForbiddenError("administrator_required")
|
||||
self._authorize_creation(actor, role=role, organization_id=organization_id)
|
||||
self._validate_organization_for_assignment(organization_id)
|
||||
user, credential = self._insert_account(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
@@ -491,6 +504,20 @@ class AccountService:
|
||||
)
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
if self._organization_blocks_access(user):
|
||||
# A suspended or archived tenant must not be able to mint new
|
||||
# sessions. Revoking the existing ones is not sufficient on its own,
|
||||
# because each member's own account status is still active.
|
||||
self._audit(
|
||||
action=ACTION_AUTH_LOGIN,
|
||||
outcome="denied",
|
||||
target_user_id=user.id,
|
||||
organization_id=user.organization_id,
|
||||
request_id=request_id,
|
||||
detail={"reason": "organization_not_active", "status": user.status},
|
||||
)
|
||||
raise InvalidCredentialsError()
|
||||
|
||||
if not self._passwords.verify_password(credential.password_hash, password):
|
||||
failed_count = credential.failed_login_count + 1
|
||||
locked_until: datetime | None = None
|
||||
@@ -647,6 +674,20 @@ class AccountService:
|
||||
detail={"status": user.status, "revoked_sessions": revoked},
|
||||
)
|
||||
raise InvalidCredentialsError()
|
||||
if self._organization_blocks_access(user):
|
||||
# Defense in depth. set_organization_status already revokes member
|
||||
# sessions, so reaching this means the organization was suspended
|
||||
# without going through that path; refuse and clean up anyway.
|
||||
revoked = self._repository.revoke_sessions(user.id, reason="organization_not_active", now=now)
|
||||
self._audit(
|
||||
action=ACTION_SESSION_INVALIDATED,
|
||||
outcome="failure",
|
||||
target_user_id=user.id,
|
||||
organization_id=user.organization_id,
|
||||
request_id=request_id,
|
||||
detail={"reason": "organization_not_active", "revoked_sessions": revoked},
|
||||
)
|
||||
raise InvalidCredentialsError()
|
||||
credential = self._repository.get_credential(user.id)
|
||||
must_change = bool(credential and credential.must_change_password)
|
||||
self._touch_session(session, now=now)
|
||||
@@ -814,6 +855,218 @@ class AccountService:
|
||||
)
|
||||
return revoked
|
||||
|
||||
# -- organizations -------------------------------------------------------
|
||||
|
||||
def list_organizations(
|
||||
self,
|
||||
actor: Principal,
|
||||
*,
|
||||
status: OrganizationStatus | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[OrganizationView], int]:
|
||||
"""List organizations visible to the caller.
|
||||
|
||||
A platform administrator sees every organization. An organization
|
||||
administrator is pinned to the single organization on their own
|
||||
principal, so a browser-supplied filter cannot widen the result.
|
||||
"""
|
||||
|
||||
self._require_admin(actor)
|
||||
scope: tuple[UUID, ...] | None = None
|
||||
if actor.role is not RoleName.PLATFORM_ADMIN and actor.organization_id is not None:
|
||||
scope = (actor.organization_id,)
|
||||
organizations, total = self._repository.find_organizations(
|
||||
status=status, organization_ids=scope, limit=limit, offset=offset
|
||||
)
|
||||
return [self._organization_view(organization) for organization in organizations], total
|
||||
|
||||
def get_organization(self, actor: Principal, organization_id: UUID) -> OrganizationView:
|
||||
self._require_admin(actor)
|
||||
organization = self._require_organization(organization_id)
|
||||
self._authorize_organization_target(actor, organization)
|
||||
return self._organization_view(organization)
|
||||
|
||||
def create_organization(
|
||||
self,
|
||||
actor: Principal,
|
||||
*,
|
||||
name: str,
|
||||
slug: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> OrganizationView:
|
||||
"""Create a tenant. Platform administrators only.
|
||||
|
||||
An organization administrator cannot create one, which keeps the
|
||||
tenant boundary under a single authority.
|
||||
"""
|
||||
|
||||
self._require_platform_admin(actor)
|
||||
now = self._now()
|
||||
canonical_name = normalize_organization_name(name)
|
||||
# A missing slug is derived from the name. A supplied slug is validated
|
||||
# as written so a rejected value reports the slug rule, not the name.
|
||||
canonical_slug = (
|
||||
require_canonical_organization_slug(slug) if slug is not None else slugify_organization_name(name)
|
||||
)
|
||||
organization = Organization(
|
||||
id=uuid4(),
|
||||
name=canonical_name,
|
||||
slug=canonical_slug,
|
||||
status=OrganizationStatus.ACTIVE,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._repository.create_organization(organization)
|
||||
self._audit(
|
||||
action=ACTION_ORGANIZATION_CREATED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
target_user_id=None,
|
||||
organization_id=organization.id,
|
||||
request_id=request_id,
|
||||
detail={
|
||||
"organization_name": organization.name,
|
||||
"organization_slug": organization.slug,
|
||||
"status": organization.status,
|
||||
},
|
||||
)
|
||||
LOGGER.info("organization created organization_id=%s slug=%s", organization.id, organization.slug)
|
||||
return self._organization_view(organization)
|
||||
|
||||
def update_organization(
|
||||
self,
|
||||
actor: Principal,
|
||||
organization_id: UUID,
|
||||
*,
|
||||
name: str | None = None,
|
||||
slug: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> OrganizationView:
|
||||
"""Rename an organization or change its slug. Platform administrators only.
|
||||
|
||||
The slug is a historical handle, so changing it is audited as its own
|
||||
event rather than folded into a rename.
|
||||
"""
|
||||
|
||||
self._require_platform_admin(actor)
|
||||
organization = self._require_organization(organization_id)
|
||||
if name is None and slug is None:
|
||||
raise InputError("no_changes", "Supply a name, a slug, or both.")
|
||||
updated = organization
|
||||
if name is not None:
|
||||
updated = replace(updated, name=normalize_organization_name(name), updated_at=self._now())
|
||||
if slug is not None:
|
||||
updated = replace(updated, slug=require_canonical_organization_slug(slug), updated_at=self._now())
|
||||
updated = replace(updated, version=organization.version + 1)
|
||||
self._repository.save_organization(updated)
|
||||
self._audit(
|
||||
action=ACTION_ORGANIZATION_UPDATED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=organization.id,
|
||||
request_id=request_id,
|
||||
detail={
|
||||
"organization_name": updated.name,
|
||||
"organization_slug": updated.slug,
|
||||
"previous_status": organization.status,
|
||||
},
|
||||
)
|
||||
LOGGER.info("organization updated organization_id=%s slug=%s", organization.id, updated.slug)
|
||||
return self._organization_view(updated)
|
||||
|
||||
def set_organization_status(
|
||||
self,
|
||||
actor: Principal,
|
||||
organization_id: UUID,
|
||||
status: str,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> tuple[OrganizationView, int]:
|
||||
"""Suspend, reactivate, or archive an organization.
|
||||
|
||||
Leaving ``active`` revokes every live session belonging to a member of
|
||||
that organization, because a suspended tenant must not keep serving
|
||||
already-authenticated browsers.
|
||||
"""
|
||||
|
||||
self._require_platform_admin(actor)
|
||||
organization = self._require_organization(organization_id)
|
||||
new_status = _organization_status(status)
|
||||
if new_status is organization.status:
|
||||
raise StateTransitionError("status_unchanged", "The organization already has that status.")
|
||||
if new_status not in ALLOWED_ORGANIZATION_TRANSITIONS[organization.status]:
|
||||
raise StateTransitionError()
|
||||
|
||||
now = self._now()
|
||||
updated = replace(organization, status=new_status, updated_at=now, version=organization.version + 1)
|
||||
self._repository.save_organization(updated)
|
||||
|
||||
revoked = 0
|
||||
if new_status is not OrganizationStatus.ACTIVE:
|
||||
members, _ = self._repository.find_users(organization_id=organization.id, limit=10_000)
|
||||
for member in members:
|
||||
if member.status is AccountStatus.ACTIVE:
|
||||
revoked += self._repository.revoke_sessions(
|
||||
member.id, reason=f"organization_{new_status.value}", now=now
|
||||
)
|
||||
|
||||
self._audit(
|
||||
action=ACTION_ORGANIZATION_STATUS_CHANGED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=organization.id,
|
||||
request_id=request_id,
|
||||
detail={
|
||||
"status": new_status,
|
||||
"previous_status": organization.status,
|
||||
"organization_name": organization.name,
|
||||
"organization_slug": organization.slug,
|
||||
"reason": normalize_reason(reason),
|
||||
"revoked_sessions": revoked,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"organization status changed organization_id=%s status=%s revoked_sessions=%s",
|
||||
organization.id,
|
||||
new_status,
|
||||
revoked,
|
||||
)
|
||||
return self._organization_view(updated), revoked
|
||||
|
||||
def organization_summary(self, actor: Principal) -> dict[str, int]:
|
||||
"""Counts for the administrator dashboard, within the caller's scope.
|
||||
|
||||
Recomputed on every call from the same scoped queries the list
|
||||
endpoints use, so a summary can never disclose a number the caller
|
||||
could not reach by listing.
|
||||
"""
|
||||
|
||||
self._require_admin(actor)
|
||||
scope: UUID | None = None
|
||||
organization_scope: tuple[UUID, ...] | None = None
|
||||
if actor.role is not RoleName.PLATFORM_ADMIN and actor.organization_id is not None:
|
||||
scope = actor.organization_id
|
||||
organization_scope = (actor.organization_id,)
|
||||
|
||||
organizations, _ = self._repository.find_organizations(organization_ids=organization_scope, limit=10_000)
|
||||
accounts, _ = self._repository.find_users(organization_id=scope, limit=10_000)
|
||||
pending_first_login = 0
|
||||
for account in accounts:
|
||||
credential = self._repository.get_credential(account.id)
|
||||
if credential is not None and credential.must_change_password:
|
||||
pending_first_login += 1
|
||||
return {
|
||||
"total_organizations": len(organizations),
|
||||
"active_organizations": sum(1 for item in organizations if item.status is OrganizationStatus.ACTIVE),
|
||||
"total_accounts": len(accounts),
|
||||
"active_accounts": sum(1 for item in accounts if item.status is AccountStatus.ACTIVE),
|
||||
"suspended_accounts": sum(1 for item in accounts if item.status is AccountStatus.SUSPENDED),
|
||||
"pending_first_login": pending_first_login,
|
||||
"organization_admin_count": sum(1 for item in accounts if item.role is RoleName.ORGANIZATION_ADMIN),
|
||||
}
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
actor: Principal,
|
||||
@@ -881,6 +1134,56 @@ class AccountService:
|
||||
raise NotFoundError("account_not_found")
|
||||
return user
|
||||
|
||||
def _require_organization(self, organization_id: UUID) -> Organization:
|
||||
organization = self._repository.get_organization(organization_id)
|
||||
if organization is None:
|
||||
raise NotFoundError("organization_not_found")
|
||||
return organization
|
||||
|
||||
def _organization_blocks_access(self, user: User) -> bool:
|
||||
"""Report whether the user's organization forbids access.
|
||||
|
||||
An account with no organization is platform-scoped and is never
|
||||
blocked. A missing organization record counts as blocking: an
|
||||
unresolvable tenant reference must fail closed rather than grant
|
||||
access by accident.
|
||||
"""
|
||||
|
||||
if user.organization_id is None:
|
||||
return False
|
||||
organization = self._repository.get_organization(user.organization_id)
|
||||
if organization is None:
|
||||
return True
|
||||
return organization.status is not OrganizationStatus.ACTIVE
|
||||
|
||||
def _validate_organization_for_assignment(self, organization_id: UUID | None) -> None:
|
||||
"""Reject an account assignment to an organization that cannot hold it.
|
||||
|
||||
Without this, any caller-supplied UUID becomes a silent tenant
|
||||
reference, because nothing else in phase 1 backs
|
||||
``user.organization_id``.
|
||||
"""
|
||||
|
||||
if organization_id is None:
|
||||
return
|
||||
organization = self._require_organization(organization_id)
|
||||
if organization.status is not OrganizationStatus.ACTIVE:
|
||||
raise InputError(
|
||||
"organization_not_active",
|
||||
"Accounts can only be assigned to an active organization.",
|
||||
)
|
||||
|
||||
def _organization_view(self, organization: Organization) -> OrganizationView:
|
||||
return OrganizationView(
|
||||
id=organization.id,
|
||||
name=organization.name,
|
||||
slug=organization.slug,
|
||||
status=organization.status,
|
||||
member_count=self._repository.count_users(organization.id),
|
||||
created_at=organization.created_at,
|
||||
updated_at=organization.updated_at,
|
||||
)
|
||||
|
||||
def _require_admin(self, actor: Principal | None) -> None:
|
||||
if actor is None:
|
||||
raise ForbiddenError("administrator_required")
|
||||
@@ -889,6 +1192,17 @@ class AccountService:
|
||||
if not actor.is_admin:
|
||||
raise ForbiddenError()
|
||||
|
||||
def _require_platform_admin(self, actor: Principal | None) -> None:
|
||||
self._require_admin(actor)
|
||||
if actor is None or actor.role is not RoleName.PLATFORM_ADMIN: # pragma: no cover - _require_admin raises first
|
||||
raise ForbiddenError("platform_admin_only")
|
||||
|
||||
def _authorize_organization_target(self, actor: Principal, organization: Organization) -> None:
|
||||
if actor.role is RoleName.PLATFORM_ADMIN:
|
||||
return
|
||||
if actor.organization_id != organization.id:
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
def _admin_scope(self, actor: Principal, requested_organization: UUID | None) -> UUID | None:
|
||||
"""Return the organization filter an administrator may use.
|
||||
|
||||
@@ -1003,6 +1317,13 @@ def _account_status(value: str) -> AccountStatus:
|
||||
raise InputError("invalid_status", "Unknown account status.") from error
|
||||
|
||||
|
||||
def _organization_status(value: str) -> OrganizationStatus:
|
||||
try:
|
||||
return OrganizationStatus(value)
|
||||
except ValueError as error:
|
||||
raise InputError("invalid_organization_status", "Unknown organization status.") from error
|
||||
|
||||
|
||||
def _role(value: str) -> RoleName:
|
||||
try:
|
||||
return RoleName(value)
|
||||
@@ -1027,6 +1348,9 @@ __all__ = [
|
||||
"ACTION_AUTH_LOGIN",
|
||||
"ACTION_AUTH_LOGOUT",
|
||||
"ACTION_AUTH_LOGOUT_EVERYWHERE",
|
||||
"ACTION_ORGANIZATION_CREATED",
|
||||
"ACTION_ORGANIZATION_STATUS_CHANGED",
|
||||
"ACTION_ORGANIZATION_UPDATED",
|
||||
"ACTION_SESSION_EXPIRED",
|
||||
"ACTION_SESSION_INVALIDATED",
|
||||
"ACTION_SESSION_REVOKED",
|
||||
|
||||
@@ -14,15 +14,24 @@ import string
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from umbratrans_web.app import create_app
|
||||
from umbratrans_web.config import Settings, suite_settings
|
||||
from umbratrans_web.models import AccountView, Principal, RoleName
|
||||
from umbratrans_web.models import (
|
||||
AccountView,
|
||||
Organization,
|
||||
OrganizationStatus,
|
||||
Principal,
|
||||
RoleName,
|
||||
normalize_organization_name,
|
||||
normalize_organization_slug,
|
||||
)
|
||||
from umbratrans_web.repository import InMemoryAccountRepository
|
||||
from umbratrans_web.security import utc_now
|
||||
from umbratrans_web.service import AccountService, OperatorContext
|
||||
|
||||
ALPHABET = string.ascii_letters + string.digits + "-_.@"
|
||||
@@ -38,6 +47,34 @@ def random_password(length: int = 24) -> str:
|
||||
return "".join(secrets.choice(ALPHABET) for _ in range(length))
|
||||
|
||||
|
||||
def ensure_organization(
|
||||
service: AccountService,
|
||||
*,
|
||||
name: str = "Test Organization",
|
||||
slug: str | None = None,
|
||||
status: OrganizationStatus = OrganizationStatus.ACTIVE,
|
||||
) -> Organization:
|
||||
"""Seed an organization directly in the repository.
|
||||
|
||||
Bypasses the service because creating a tenant requires a platform
|
||||
administrator principal, and provisioning that first administrator is
|
||||
exactly what these fixtures exist to set up. Service-level authorization
|
||||
for organization writes is covered in ``test_service.py`` and
|
||||
``test_api_uadmin.py``.
|
||||
"""
|
||||
|
||||
now = utc_now()
|
||||
organization = Organization(
|
||||
id=uuid4(),
|
||||
name=normalize_organization_name(name),
|
||||
slug=normalize_organization_slug(slug) if slug is not None else normalize_organization_slug(name),
|
||||
status=status,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
return service.repository.create_organization(organization) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeededAccount:
|
||||
"""A test account plus the plaintext password used to create it."""
|
||||
@@ -226,6 +263,7 @@ __all__ = [
|
||||
"activated_admin",
|
||||
"admin_principal",
|
||||
"client",
|
||||
"ensure_organization",
|
||||
"member_principal",
|
||||
"provision_local",
|
||||
"provision_oidc",
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from umbratrans_web.service import AccountService
|
||||
|
||||
from .conftest import ApiHarness, activated_admin, provision_local, random_password
|
||||
from .conftest import ApiHarness, activated_admin, ensure_organization, provision_local, random_password
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -251,8 +251,8 @@ def test_generated_temporary_passwords_are_unique_per_account(
|
||||
|
||||
|
||||
def test_organization_admin_cannot_provision_outside_its_scope(api: ApiHarness, service: AccountService) -> None:
|
||||
org_a = uuid4()
|
||||
org_b = uuid4()
|
||||
org_a = ensure_organization(service, name="Org A").id
|
||||
org_b = ensure_organization(service, name="Org B").id
|
||||
activated_admin(service, username="Umbraadmin")
|
||||
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
|
||||
api.login_ok(org_admin)
|
||||
@@ -334,8 +334,8 @@ def test_list_and_get_accounts(api: ApiHarness, admin: object, service: AccountS
|
||||
|
||||
|
||||
def test_organization_admin_scope_is_enforced(api: ApiHarness, service: AccountService) -> None:
|
||||
org_a = uuid4()
|
||||
org_b = uuid4()
|
||||
org_a = ensure_organization(service, name="Org A").id
|
||||
org_b = ensure_organization(service, name="Org B").id
|
||||
activated_admin(service, username="Umbraadmin")
|
||||
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
|
||||
inside = provision_local(service, username="Inside.User", role="member", organization_id=org_a)
|
||||
@@ -492,8 +492,8 @@ def test_audit_endpoint_returns_sanitized_events(api: ApiHarness, admin: object,
|
||||
|
||||
|
||||
def test_audit_is_scoped_to_the_callers_organization(api: ApiHarness, service: AccountService) -> None:
|
||||
org_a = uuid4()
|
||||
org_b = uuid4()
|
||||
org_a = ensure_organization(service, name="Org A").id
|
||||
org_b = ensure_organization(service, name="Org B").id
|
||||
activated_admin(service, username="Umbraadmin")
|
||||
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org_a)
|
||||
other = provision_local(service, username="Other.User", role="member", organization_id=org_b)
|
||||
|
||||
@@ -59,15 +59,30 @@ def test_forward_migration_stays_in_phase_one_identity_scope() -> None:
|
||||
# Out of scope for this migration.
|
||||
for forbidden in (
|
||||
"public.project_membership",
|
||||
"CREATE TABLE public.organizations",
|
||||
"ALTER TABLE public.organizations",
|
||||
"ALTER TABLE public.projects",
|
||||
"ALTER TABLE public.audit_events",
|
||||
"ALTER TABLE public.user_roles",
|
||||
"ALTER TABLE public.roles",
|
||||
):
|
||||
assert forbidden not in text
|
||||
|
||||
|
||||
def test_organizations_section_is_additive_only() -> None:
|
||||
"""Section 4 must not redefine or destroy a table the control plane owns."""
|
||||
|
||||
text = FORWARD.read_text(encoding="utf-8")
|
||||
assert "CREATE TABLE IF NOT EXISTS public.organizations" in text
|
||||
# Conditional creation, so a pre-existing table is skipped rather than
|
||||
# replaced. A bare CREATE TABLE would be a redefinition.
|
||||
assert "CREATE TABLE public.organizations" not in text
|
||||
assert "DROP TABLE" not in text
|
||||
assert "INSERT INTO" not in text
|
||||
# The only organizations DDL is attaching the slug constraint. Anything
|
||||
# else there needs its own review.
|
||||
alters = re.findall(r"ALTER TABLE public\.organizations\s+ADD CONSTRAINT \w+", text)
|
||||
assert len(alters) == 1
|
||||
|
||||
|
||||
def test_forward_migration_contains_no_secret_material() -> None:
|
||||
phc_hash = re.compile(r"\$argon2(?:id|i|d)\$v=\d+\$")
|
||||
assignment = re.compile(r"(?i)password\s*=\s*['\"]")
|
||||
|
||||
488
umbratrans_web/tests/test_organizations.py
Normal file
488
umbratrans_web/tests/test_organizations.py
Normal file
@@ -0,0 +1,488 @@
|
||||
"""Organization service tests: tenancy lifecycle, scoping, and session revocation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from umbratrans_web.errors import (
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
InputError,
|
||||
InvalidCredentialsError,
|
||||
NotFoundError,
|
||||
StateTransitionError,
|
||||
)
|
||||
from umbratrans_web.models import AccountStatus, OrganizationStatus, RoleName
|
||||
from umbratrans_web.service import AccountService
|
||||
|
||||
from .conftest import (
|
||||
ApiHarness,
|
||||
activated_admin,
|
||||
admin_principal,
|
||||
ensure_organization,
|
||||
provision_local,
|
||||
provision_oidc,
|
||||
random_password,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform_admin(service: AccountService) -> object:
|
||||
"""An activated platform administrator principal, without HTTP."""
|
||||
|
||||
return admin_principal(activated_admin(service, username="Umbraadmin"))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Naming
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_slug_is_derived_from_the_name(service: AccountService, platform_admin: object) -> None:
|
||||
organization = service.create_organization(platform_admin, name="Northstar Media") # type: ignore[arg-type]
|
||||
assert organization.slug == "northstar-media"
|
||||
|
||||
|
||||
def test_supplied_slug_is_validated_as_written(service: AccountService, platform_admin: object) -> None:
|
||||
organization = service.create_organization( # type: ignore[arg-type]
|
||||
platform_admin, name="Harbor House", slug="harbor-house-2"
|
||||
)
|
||||
assert organization.slug == "harbor-house-2"
|
||||
|
||||
|
||||
def test_rejected_slug_reports_the_slug_rule(service: AccountService, platform_admin: object) -> None:
|
||||
with pytest.raises(InputError) as error:
|
||||
service.create_organization(platform_admin, name="Bad Slug", slug="Not A Slug") # type: ignore[arg-type]
|
||||
assert error.value.code == "invalid_organization_slug"
|
||||
|
||||
|
||||
def test_a_name_with_nothing_slug_safe_is_rejected(service: AccountService, platform_admin: object) -> None:
|
||||
with pytest.raises(InputError) as error:
|
||||
service.create_organization(platform_admin, name="///") # type: ignore[arg-type]
|
||||
assert error.value.code in {"invalid_organization_name", "invalid_organization_slug"}
|
||||
|
||||
|
||||
def test_duplicate_slug_is_rejected(service: AccountService, platform_admin: object) -> None:
|
||||
service.create_organization(platform_admin, name="Harbor House", slug="harbor-house") # type: ignore[arg-type]
|
||||
with pytest.raises(ConflictError) as error:
|
||||
service.create_organization(platform_admin, name="Other House", slug="harbor-house") # type: ignore[arg-type]
|
||||
assert error.value.code == "organization_slug_taken"
|
||||
|
||||
|
||||
def test_a_supplied_slug_must_already_be_canonical(service: AccountService, platform_admin: object) -> None:
|
||||
"""A slug the operator typed is never silently rewritten."""
|
||||
|
||||
with pytest.raises(InputError) as error:
|
||||
service.create_organization(platform_admin, name="Harbor House", slug="Harbor-House") # type: ignore[arg-type]
|
||||
assert error.value.code == "invalid_organization_slug"
|
||||
assert service.repository.get_organization_by_slug("harbor-house") is None
|
||||
|
||||
|
||||
def test_slug_lookup_is_case_insensitive(service: AccountService, platform_admin: object) -> None:
|
||||
created = service.create_organization(platform_admin, name="Harbor House", slug="harbor-house") # type: ignore[arg-type]
|
||||
stored = service.repository.get_organization_by_slug("HARBOR-HOUSE")
|
||||
assert stored is not None
|
||||
assert stored.id == created.id
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Authorization
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_only_a_platform_admin_may_create_an_organization(service: AccountService) -> None:
|
||||
org = ensure_organization(service, name="Org A")
|
||||
org_admin = admin_principal(
|
||||
activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=org.id)
|
||||
)
|
||||
with pytest.raises(ForbiddenError):
|
||||
service.create_organization(org_admin, name="Sneaky Org")
|
||||
assert service.repository.get_organization_by_slug("sneaky-org") is None
|
||||
|
||||
|
||||
def test_an_organization_admin_sees_only_their_own_organization(service: AccountService) -> None:
|
||||
mine = ensure_organization(service, name="Mine")
|
||||
theirs = ensure_organization(service, name="Theirs")
|
||||
org_admin = admin_principal(
|
||||
activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
||||
)
|
||||
|
||||
organizations, total = service.list_organizations(org_admin)
|
||||
assert total == 1
|
||||
assert organizations[0].id == mine.id
|
||||
|
||||
with pytest.raises(ForbiddenError):
|
||||
service.get_organization(org_admin, theirs.id)
|
||||
|
||||
|
||||
def test_a_platform_admin_sees_every_organization(service: AccountService, platform_admin: object) -> None:
|
||||
for name in ("Alpha", "Beta", "Gamma"):
|
||||
ensure_organization(service, name=name)
|
||||
organizations, total = service.list_organizations(platform_admin) # type: ignore[arg-type]
|
||||
assert total == 3
|
||||
assert {organization.slug for organization in organizations} == {"alpha", "beta", "gamma"}
|
||||
|
||||
|
||||
def test_members_are_forbidden_from_organization_routes(service: AccountService) -> None:
|
||||
org = ensure_organization(service, name="Org A")
|
||||
member = admin_principal(provision_local(service, username="Member.User", role="member", organization_id=org.id))
|
||||
with pytest.raises(ForbiddenError):
|
||||
service.list_organizations(member) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_a_temporary_password_session_cannot_manage_organizations(service: AccountService) -> None:
|
||||
pending = provision_local(service, username="Pending.Admin", role="platform_admin")
|
||||
result = service.authenticate(username=pending.username, password=pending.password)
|
||||
from umbratrans_web.models import Principal
|
||||
|
||||
principal = Principal(
|
||||
user_id=pending.user_id,
|
||||
username=pending.username,
|
||||
role=RoleName.PLATFORM_ADMIN,
|
||||
session_id=result.session.session_id,
|
||||
must_change_password=True,
|
||||
)
|
||||
from umbratrans_web.errors import PasswordChangeRequiredError
|
||||
|
||||
with pytest.raises(PasswordChangeRequiredError):
|
||||
service.create_organization(principal, name="Blocked Org")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rename_and_re_slug(service: AccountService, platform_admin: object) -> None:
|
||||
organization = service.create_organization(platform_admin, name="Old Name", slug="old-name") # type: ignore[arg-type]
|
||||
renamed = service.update_organization(platform_admin, organization.id, name="New Name") # type: ignore[arg-type]
|
||||
assert renamed.name == "New Name"
|
||||
assert renamed.slug == "old-name"
|
||||
|
||||
reslugged = service.update_organization(platform_admin, organization.id, slug="new-name") # type: ignore[arg-type]
|
||||
assert reslugged.slug == "new-name"
|
||||
stored = service.repository.get_organization(organization.id)
|
||||
assert stored is not None
|
||||
# One bump per update call, so two updates leave it at 3.
|
||||
assert stored.version == 3
|
||||
|
||||
|
||||
def test_update_requires_at_least_one_field(service: AccountService, platform_admin: object) -> None:
|
||||
organization = service.create_organization(platform_admin, name="Untouched") # type: ignore[arg-type]
|
||||
with pytest.raises(InputError) as error:
|
||||
service.update_organization(platform_admin, organization.id) # type: ignore[arg-type]
|
||||
assert error.value.code == "no_changes"
|
||||
|
||||
|
||||
def test_suspension_revokes_member_sessions(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Revoke Org")
|
||||
member = provision_local(service, username="Revoke.Member", role="member", organization_id=org.id)
|
||||
service.authenticate(username=member.username, password=member.password)
|
||||
assert service.repository.count_sessions(member.user_id) == 1
|
||||
|
||||
organization, revoked = service.set_organization_status(platform_admin, org.id, "suspended") # type: ignore[arg-type]
|
||||
assert organization.status is OrganizationStatus.SUSPENDED
|
||||
assert revoked == 1
|
||||
assert service.repository.count_sessions(member.user_id) == 0
|
||||
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
service.authenticate(username=member.username, password=member.password)
|
||||
|
||||
|
||||
def test_reactivation_restores_login(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Restore Org")
|
||||
member = provision_local(service, username="Restore.Member", role="member", organization_id=org.id)
|
||||
service.set_organization_status(platform_admin, org.id, "suspended") # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(InvalidCredentialsError):
|
||||
service.authenticate(username=member.username, password=member.password)
|
||||
|
||||
service.set_organization_status(platform_admin, org.id, "active") # type: ignore[arg-type]
|
||||
# The credential is untouched, so the same password works again.
|
||||
assert service.authenticate(username=member.username, password=member.password) is not None
|
||||
|
||||
|
||||
def test_archived_is_terminal(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Archive Org")
|
||||
archived, _ = service.set_organization_status(platform_admin, org.id, "archived") # type: ignore[arg-type]
|
||||
assert archived.status is OrganizationStatus.ARCHIVED
|
||||
with pytest.raises(StateTransitionError):
|
||||
service.set_organization_status(platform_admin, org.id, "active") # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_unchanged_status_is_rejected(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Steady Org")
|
||||
with pytest.raises(StateTransitionError) as error:
|
||||
service.set_organization_status(platform_admin, org.id, "active") # type: ignore[arg-type]
|
||||
assert error.value.code == "status_unchanged"
|
||||
|
||||
|
||||
def test_unknown_organization_is_not_found(service: AccountService, platform_admin: object) -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
with pytest.raises(NotFoundError) as error:
|
||||
service.get_organization(platform_admin, uuid4()) # type: ignore[arg-type]
|
||||
assert error.value.code == "organization_not_found"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Membership validation
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_account_cannot_be_assigned_to_an_unknown_organization(
|
||||
service: AccountService, platform_admin: object
|
||||
) -> None:
|
||||
from uuid import uuid4
|
||||
|
||||
with pytest.raises(NotFoundError) as error:
|
||||
service.create_oidc_account(
|
||||
username="Orphan",
|
||||
display_name="Orphan",
|
||||
organization_id=uuid4(),
|
||||
actor=platform_admin, # type: ignore[arg-type]
|
||||
)
|
||||
assert error.value.code == "organization_not_found"
|
||||
|
||||
|
||||
def test_an_account_cannot_be_assigned_to_a_suspended_organization(
|
||||
service: AccountService, platform_admin: object
|
||||
) -> None:
|
||||
org = ensure_organization(service, name="Dormant Org", status=OrganizationStatus.SUSPENDED)
|
||||
with pytest.raises(InputError) as error:
|
||||
service.create_oidc_account(
|
||||
username="Late.Joiner",
|
||||
display_name="Late Joiner",
|
||||
organization_id=org.id,
|
||||
actor=platform_admin, # type: ignore[arg-type]
|
||||
)
|
||||
assert error.value.code == "organization_not_active"
|
||||
|
||||
|
||||
def test_member_count_reflects_real_membership(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Counted Org")
|
||||
assert service.get_organization(platform_admin, org.id).member_count == 0 # type: ignore[arg-type]
|
||||
provision_oidc(service, platform_admin, username="Count.One", organization_id=org.id) # type: ignore[arg-type]
|
||||
provision_local(service, username="Count.Two", role="member", organization_id=org.id)
|
||||
assert service.get_organization(platform_admin, org.id).member_count == 2 # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Summary
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_summary_is_scoped_to_an_organization_admin(service: AccountService) -> None:
|
||||
mine = ensure_organization(service, name="Mine")
|
||||
ensure_organization(service, name="Theirs")
|
||||
org_admin = admin_principal(
|
||||
activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
||||
)
|
||||
provision_local(service, username="Mine.Member", role="member", organization_id=mine.id)
|
||||
provision_local(service, username="Theirs.Member", role="member", organization_id=None)
|
||||
|
||||
summary = service.organization_summary(org_admin)
|
||||
assert summary["total_organizations"] == 1
|
||||
# The admin's own account plus the one member, not the unrelated member.
|
||||
assert summary["total_accounts"] == 2
|
||||
|
||||
|
||||
def test_summary_counts_pending_first_logins(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Pending Org")
|
||||
provision_local(service, username="Still.Temp", role="member", organization_id=org.id)
|
||||
# A completed first-login change clears the flag.
|
||||
pending = provision_local(service, username="Already.Changed", role="member", organization_id=org.id)
|
||||
result = service.authenticate(username=pending.username, password=pending.password)
|
||||
service.change_first_password(
|
||||
service.resolve_session(result.session.token),
|
||||
current_password=pending.password,
|
||||
new_password=random_password(30),
|
||||
)
|
||||
|
||||
summary = service.organization_summary(platform_admin) # type: ignore[arg-type]
|
||||
assert summary["pending_first_login"] == 1
|
||||
assert summary["total_accounts"] == 3
|
||||
assert summary["active_accounts"] == 3
|
||||
|
||||
|
||||
def test_summary_reports_suspended_accounts(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Mixed Org")
|
||||
member = provision_local(service, username="To.Suspend", role="member", organization_id=org.id)
|
||||
service.set_account_status(
|
||||
platform_admin,
|
||||
member.user_id,
|
||||
"suspended", # type: ignore[arg-type]
|
||||
)
|
||||
summary = service.organization_summary(platform_admin) # type: ignore[arg-type]
|
||||
assert summary["suspended_accounts"] == 1
|
||||
assert summary["active_accounts"] == 1
|
||||
assert summary["total_accounts"] == 2
|
||||
|
||||
|
||||
def test_suspended_account_stays_visible_in_the_member_count(service: AccountService, platform_admin: object) -> None:
|
||||
org = ensure_organization(service, name="Still Counted")
|
||||
member = provision_local(service, username="Suspended.Member", role="member", organization_id=org.id)
|
||||
service.set_account_status(platform_admin, member.user_id, AccountStatus.SUSPENDED.value) # type: ignore[arg-type]
|
||||
# Membership is not erased by suspension; the count stays truthful.
|
||||
assert service.get_organization(platform_admin, org.id).member_count == 1 # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# HTTP surface
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signed_in_admin(api: ApiHarness, service: AccountService) -> ApiHarness:
|
||||
"""A harness carrying an activated platform administrator session."""
|
||||
|
||||
api.login_ok(activated_admin(service, username="Umbraadmin"))
|
||||
return api
|
||||
|
||||
|
||||
def test_organization_routes_require_a_session(client: TestClient) -> None:
|
||||
for method, path in [
|
||||
("get", "/api/uadmin/summary"),
|
||||
("get", "/api/uadmin/organizations"),
|
||||
("get", f"/api/uadmin/organizations/{uuid4()}"),
|
||||
]:
|
||||
response = getattr(client, method)(path)
|
||||
assert response.status_code == 401, path
|
||||
assert response.json()["error"]["code"] == "authentication_required"
|
||||
|
||||
for method, path in [
|
||||
("post", "/api/uadmin/organizations"),
|
||||
("patch", f"/api/uadmin/organizations/{uuid4()}"),
|
||||
("patch", f"/api/uadmin/organizations/{uuid4()}/status"),
|
||||
]:
|
||||
response = getattr(client, method)(path, json={})
|
||||
assert response.status_code in {401, 403}, path
|
||||
|
||||
|
||||
def test_platform_admin_manages_an_organization_over_http(signed_in_admin: ApiHarness) -> None:
|
||||
created = signed_in_admin.post(
|
||||
"/api/uadmin/organizations",
|
||||
json={"name": "Northstar Media", "slug": "northstar-media"},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
body = created.json()
|
||||
assert (body["name"], body["slug"], body["status"], body["member_count"]) == (
|
||||
"Northstar Media",
|
||||
"northstar-media",
|
||||
"active",
|
||||
0,
|
||||
)
|
||||
|
||||
organization_id = body["id"]
|
||||
assert signed_in_admin.get(f"/api/uadmin/organizations/{organization_id}").status_code == 200
|
||||
assert signed_in_admin.get("/api/uadmin/organizations").json()["total"] == 1
|
||||
|
||||
renamed = signed_in_admin.patch(f"/api/uadmin/organizations/{organization_id}", json={"name": "Northstar Studios"})
|
||||
assert renamed.status_code == 200
|
||||
assert renamed.json()["name"] == "Northstar Studios"
|
||||
assert renamed.json()["slug"] == "northstar-media"
|
||||
|
||||
suspended = signed_in_admin.patch(
|
||||
f"/api/uadmin/organizations/{organization_id}/status", json={"status": "suspended"}
|
||||
)
|
||||
assert suspended.status_code == 200
|
||||
assert suspended.json()["organization"]["status"] == "suspended"
|
||||
|
||||
# archived is terminal, so the reverse move is refused.
|
||||
assert (
|
||||
signed_in_admin.patch(
|
||||
f"/api/uadmin/organizations/{organization_id}/status", json={"status": "archived"}
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
conflict = signed_in_admin.patch(f"/api/uadmin/organizations/{organization_id}/status", json={"status": "active"})
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["error"]["code"] == "invalid_state_transition"
|
||||
|
||||
|
||||
def test_duplicate_slug_over_http_is_a_conflict(signed_in_admin: ApiHarness) -> None:
|
||||
signed_in_admin.post("/api/uadmin/organizations", json={"name": "Harbor House", "slug": "harbor-house"})
|
||||
duplicate = signed_in_admin.post("/api/uadmin/organizations", json={"name": "Other", "slug": "harbor-house"})
|
||||
assert duplicate.status_code == 409
|
||||
assert duplicate.json()["error"]["code"] == "organization_slug_taken"
|
||||
|
||||
|
||||
def test_organization_admin_is_read_only_and_scoped(api: ApiHarness, service: AccountService) -> None:
|
||||
mine = ensure_organization(service, name="Mine")
|
||||
theirs = ensure_organization(service, name="Theirs")
|
||||
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
||||
api.login_ok(org_admin)
|
||||
|
||||
listed = api.get("/api/uadmin/organizations").json()
|
||||
assert listed["total"] == 1
|
||||
assert listed["items"][0]["id"] == str(mine.id)
|
||||
assert str(theirs.id) not in api.get("/api/uadmin/organizations").text
|
||||
|
||||
assert api.get(f"/api/uadmin/organizations/{theirs.id}").status_code == 403
|
||||
assert api.post("/api/uadmin/organizations", json={"name": "Sneaky"}).status_code == 403
|
||||
assert api.patch(f"/api/uadmin/organizations/{mine.id}", json={"name": "Renamed"}).status_code == 403
|
||||
assert api.patch(f"/api/uadmin/organizations/{mine.id}/status", json={"status": "suspended"}).status_code == 403
|
||||
# Reading its own organization still works.
|
||||
assert api.get(f"/api/uadmin/organizations/{mine.id}").status_code == 200
|
||||
|
||||
|
||||
def test_members_cannot_read_organizations(api: ApiHarness, service: AccountService) -> None:
|
||||
org = ensure_organization(service, name="Org A")
|
||||
member = activated_admin(service, username="Plain.Member", role="member", organization_id=org.id)
|
||||
api.login_ok(member)
|
||||
assert api.get("/api/uadmin/organizations").status_code == 403
|
||||
assert api.get("/api/uadmin/summary").status_code == 403
|
||||
|
||||
|
||||
def test_account_creation_validates_the_organization(signed_in_admin: ApiHarness) -> None:
|
||||
unknown = signed_in_admin.post(
|
||||
"/api/uadmin/accounts",
|
||||
json={
|
||||
"username": "Orphan",
|
||||
"display_name": "Orphan",
|
||||
"enrollment_mode": "temporary_password",
|
||||
"organization_id": str(uuid4()),
|
||||
},
|
||||
)
|
||||
assert unknown.status_code == 404
|
||||
assert unknown.json()["error"]["code"] == "organization_not_found"
|
||||
|
||||
organization = signed_in_admin.post("/api/uadmin/organizations", json={"name": "Dormant"}).json()
|
||||
signed_in_admin.patch(f"/api/uadmin/organizations/{organization['id']}/status", json={"status": "suspended"})
|
||||
dormant = signed_in_admin.post(
|
||||
"/api/uadmin/accounts",
|
||||
json={
|
||||
"username": "Late.Joiner",
|
||||
"display_name": "Late",
|
||||
"enrollment_mode": "temporary_password",
|
||||
"organization_id": organization["id"],
|
||||
},
|
||||
)
|
||||
assert dormant.status_code == 422
|
||||
assert dormant.json()["error"]["code"] == "organization_not_active"
|
||||
|
||||
|
||||
def test_suspending_an_organization_locks_its_members_out_over_http(
|
||||
signed_in_admin: ApiHarness, service: AccountService
|
||||
) -> None:
|
||||
org = ensure_organization(service, name="Lockout Org")
|
||||
member = provision_local(service, username="Locked.Member", role="member", organization_id=org.id)
|
||||
signed_in_admin.patch(f"/api/uadmin/organizations/{org.id}/status", json={"status": "suspended"})
|
||||
# The account itself is still active; only the tenant is suspended.
|
||||
assert service.repository.get_user(member.user_id).status is AccountStatus.ACTIVE
|
||||
response = signed_in_admin.login(member.username, member.password)
|
||||
assert response.status_code == 401
|
||||
assert response.json()["error"]["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
def test_summary_over_http_is_scoped(api: ApiHarness, service: AccountService) -> None:
|
||||
mine = ensure_organization(service, name="Mine")
|
||||
ensure_organization(service, name="Theirs")
|
||||
org_admin = activated_admin(service, username="Org.Admin", role="organization_admin", organization_id=mine.id)
|
||||
api.login_ok(org_admin)
|
||||
summary = api.get("/api/uadmin/summary").json()
|
||||
assert summary["total_organizations"] == 1
|
||||
assert summary["total_accounts"] == 1
|
||||
@@ -19,7 +19,7 @@ from umbratrans_web.models import AccountStatus
|
||||
from umbratrans_web.repository import AccountRepository, InMemoryAccountRepository, build_repository
|
||||
from umbratrans_web.service import AccountService
|
||||
|
||||
from .conftest import provision_local, random_password
|
||||
from .conftest import ensure_organization, provision_local, random_password
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# In-memory repository
|
||||
@@ -66,10 +66,11 @@ def test_saving_an_unknown_user_fails(repository: InMemoryAccountRepository, ser
|
||||
|
||||
|
||||
def test_find_users_filters_and_paginates(repository: InMemoryAccountRepository, service: AccountService) -> None:
|
||||
org = uuid4()
|
||||
org = ensure_organization(service, name="Filter Org").id
|
||||
elsewhere = ensure_organization(service, name="Other Org").id
|
||||
for index in range(3):
|
||||
provision_local(service, username=f"Member{index}", role="member", organization_id=org)
|
||||
provision_local(service, username="Elsewhere", role="member", organization_id=uuid4())
|
||||
provision_local(service, username="Elsewhere", role="member", organization_id=elsewhere)
|
||||
|
||||
page, total = repository.find_users(organization_id=org, limit=2, offset=0)
|
||||
assert total == 3
|
||||
|
||||
@@ -23,6 +23,7 @@ from umbratrans_web.service import AccountService, OperatorContext
|
||||
|
||||
from .conftest import (
|
||||
admin_principal,
|
||||
ensure_organization,
|
||||
member_principal,
|
||||
provision_local,
|
||||
provision_oidc,
|
||||
@@ -457,8 +458,8 @@ def test_members_cannot_use_administrator_operations(service: AccountService) ->
|
||||
|
||||
|
||||
def test_organization_admin_cannot_cross_organizations(service: AccountService) -> None:
|
||||
org_a = uuid4()
|
||||
org_b = uuid4()
|
||||
org_a = ensure_organization(service, name="Org A").id
|
||||
org_b = ensure_organization(service, name="Org B").id
|
||||
org_admin_account = provision_local(
|
||||
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org_a
|
||||
)
|
||||
@@ -479,7 +480,7 @@ def test_organization_admin_cannot_cross_organizations(service: AccountService)
|
||||
|
||||
|
||||
def test_organization_admin_cannot_manage_a_platform_admin(service: AccountService) -> None:
|
||||
org = uuid4()
|
||||
org = ensure_organization(service, name="Scoped Org").id
|
||||
admin = provision_local(service, username="Umbraadmin")
|
||||
org_admin_account = provision_local(
|
||||
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org
|
||||
@@ -491,7 +492,7 @@ def test_organization_admin_cannot_manage_a_platform_admin(service: AccountServi
|
||||
|
||||
|
||||
def test_organization_admin_cannot_promote_to_platform_admin(service: AccountService) -> None:
|
||||
org = uuid4()
|
||||
org = ensure_organization(service, name="Scoped Org").id
|
||||
org_admin_account = provision_local(
|
||||
service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user