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:
umbra audio
2026-09-26 19:58:17 -07:00
parent 6482921d0b
commit 0b9c4d4220
19 changed files with 1660 additions and 47 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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),

View File

@@ -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

View File

@@ -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')

View File

@@ -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`?

View File

@@ -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",
]

View File

@@ -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,
*,

View File

@@ -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,

View File

@@ -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",
]

View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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*['\"]")

View 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

View File

@@ -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

View File

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