diff --git a/Makefile b/Makefile index 0b5be21..e577b8a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test lint format format-check check +.PHONY: test lint format format-check check web-test web-lint web-check test: /home/umbra/.local/bin/uv run python -m unittest discover -s agent_comm/tests -v @@ -14,3 +14,13 @@ format-check: check: test lint format-check /home/umbra/.local/bin/uv run python -m compileall -q agent_comm + +web-test: + /home/umbra/.local/bin/uv run pytest umbratrans_web/tests + +web-lint: + /home/umbra/.local/bin/uv run ruff check umbratrans_web + +web-check: web-test web-lint + /home/umbra/.local/bin/uv run ruff format --check umbratrans_web + /home/umbra/.local/bin/uv run python -m compileall -q umbratrans_web diff --git a/README.md b/README.md index 34721ec..afdbac2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ This workspace contains the unprivileged `agent-comm` server requested by the Ol - `agent_comm/` — Python 3.11+ standard-library, stdio-only MCP server. - `agent_comm/tests/` — dependency-free unit tests. +- `umbratrans_web/` — phase-1 account/user-management backend slice (FastAPI, Argon2id, sessions, admin routes, in-memory and local JSON development repositories). It does not connect to PostgreSQL; see `umbratrans_web/README.md`. +- `umbratrans_web/migrations/` — proposed, unapplied phase-1 identity SQL and its rollback. - `opencode.json` — non-secret OpenCode registration for the confirmed NAS2 mailbox. - `agent-comm.local.example.json` — non-secret local fallback registration. - `.env.example` — non-secret environment template. @@ -54,6 +56,16 @@ make check The tests cover schema and size validation, atomic no-overwrite writes, restrictive modes, path traversal, symlink refusal, secret-pattern redaction, inbox processing, and read-only initialization. +The account-management slice has its own targets: + +```bash +cd /home/umbra/src/UmbraTrans +/home/umbra/.local/bin/uv run pytest umbratrans_web/tests +make web-check +``` + +Its tests use the in-memory repository and generated per-run passwords; they never read a production configuration, never connect to a database, and contain no credential material. + ## Backups `scripts/umbratrans_backup.sh` is installed root-owned as `/usr/local/sbin/umbratrans-backup` and scheduled by the enabled `umbratrans-backup.timer`. It writes only to the confirmed NAS2 `netbackup/UmbraTrans/backups` tree: diff --git a/docs/umbratrans-backend-design.md b/docs/umbratrans-backend-design.md new file mode 100644 index 0000000..939e0e8 --- /dev/null +++ b/docs/umbratrans-backend-design.md @@ -0,0 +1,222 @@ +# UmbraTrans backend design — phase 1 identity and account management + +**Status:** implementation-ready design; production migration not applied +**Transition state:** paused for the audio-system handoff +**Primary application:** `umbratrans.bdmurphy.me` +**Administrative surface:** `/uadmin/` + +## Objective + +Begin the UmbraTrans backend with the account and user-management system required by the new web UI. The audio system remains an independent producer and is not blocked by this phase. + +The first backend slice must provide: + +- enterprise user accounts; +- an administrator able to provision, suspend, reactivate, and list accounts; +- temporary local-password enrollment; +- OIDC enrollment as a supported future/parallel path; +- mandatory password change for temporary-password accounts; +- secure server-side sessions; +- organization and project membership boundaries; +- audit events for account and membership changes. + +No audiobook, translation, voice, or object-storage workflow is implemented in this first slice. + +## Authentication decision + +The UI supports both account-enrollment paths, but the first implementation will use an adapter boundary. + +### Local temporary password + +- An administrator provisions a user with a one-time temporary password. +- The password is never submitted by a client. The service generates it in process, hashes it immediately, and returns it exactly once in the creation response, so no request body, header, URL, or queue message can carry a secret. +- The first administrator is instead provisioned by an operator command that reads the password from a hidden local prompt, for the case where the operator must choose the value. +- The password is never logged, printed to a log, emailed, placed in a URL, or stored in plaintext. The one-time display to the provisioning administrator is the only time it leaves the process, and it is not retrievable again through any endpoint. +- Ambiguous glyphs are excluded from generated values so a single transcription does not fail; the local policy is length-first and is asserted on every generated value. +- Only an Argon2id hash is persisted. +- The credential record is marked `must_change_password = true`. +- The first login may authenticate only far enough to change the password; every other route, including the administrator surface, refuses the session until the change succeeds. +- The change rotates the session and revokes every other session for the account, so the one-time password is spent. +- Failed attempts are rate-limited and audited. + +### OIDC enrollment + +- An administrator provisions a user or invitation but does not create a local password. +- The identity provider completes enrollment. +- The callback maps the provider subject to the canonical user record. +- The same session, organization, project, and authorization model is used as for local credentials. + +The first implementation may ship local-password development flows while keeping the OIDC interface explicit and unconnected. No OIDC client secret belongs in the repository. + +## Data model for phase 1 + +The existing control-plane tables remain authoritative. Additive changes must be reviewed and migrated separately. + +### `users` + +Add a nullable, unique canonical login identifier during migration: + +```text +username citext UNIQUE +``` + +Existing accounts are backfilled only through an explicit mapping. New accounts require a username. + +### `user_credentials` + +A separate table avoids mixing authentication mechanisms with the user profile: + +```text +id uuid primary key +user_id uuid not null unique references users(id) on delete cascade +credential_type text not null check (credential_type in ('local_password', 'oidc')) +password_hash text null +must_change_password boolean not null default false +failed_login_count integer not null default 0 +locked_until timestamptz null +last_login_at timestamptz null +password_changed_at timestamptz null +created_at timestamptz not null default now() +updated_at timestamptz not null default now() +``` + +Rules: + +- `local_password` requires a password hash. +- `oidc` does not store a password hash. +- Password rotation replaces the hash and clears `must_change_password` only after a successful first-login change. +- Credential rows are never returned by the API. + +### `sessions` + +Add only the fields required for the first identity slice: + +```text +mfa_verified_at timestamptz null +idle_deadline_at timestamptz null +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. + +### Existing tables used unchanged + +- `organizations` +- `projects` +- `roles` +- `permissions` +- `user_roles` +- `invitations` +- `audit_events` + +`user_roles` remains the membership source of truth. Do not add a second `project_members` table. + +## Phase 1 API + +All routes require a server-side session and a resolved principal. The API never trusts browser-supplied user, organization, project, or role values. + +### Authentication + +```text +POST /api/auth/login +POST /api/auth/change-first-password +POST /api/auth/logout +POST /api/auth/logout-everywhere +GET /api/auth/me +``` + +### Administrator account management + +```text +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 +``` + +Creating an account accepts: + +- username; +- display name; +- email; +- organization; +- initial role; +- enrollment mode (`temporary_password` or `oidc`). + +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. + +## First administrator bootstrap + +The first administrator is provisioned through a separate operator-only command, not the public admin API. + +Requirements: + +- username: `Umbraadmin` (configurable as a non-secret bootstrap identifier); +- email: supplied by the operator at provisioning time; it is required by the existing `users.email` constraint and is never guessed; +- organization: selected or created by the operator at provisioning time; it is never guessed; +- password: entered through a hidden local prompt; +- Argon2id hash only; +- `must_change_password = true` on first credential; +- account status is explicitly audited; +- no password appears in shell history, environment variables, logs, reports, or the UI; +- the command refuses to run against production until the migration and backup gates pass. + +The supplied one-time value is not written to any file by the implementation and is not included in this document. + +## Security requirements + +- Argon2id password hashing with reviewed parameters. +- Constant-time verification through the password library. +- Rate-limited login and password-change endpoints. +- CSRF protection for cookie-authenticated state changes. +- Session rotation after login and first-password change. +- Session revocation on suspension, deactivation, and administrative removal. +- No local password in the `users` table. +- No service or administrator action accepts a trusted identity from the browser. +- Audit every account creation, suspension, reactivation, role change, enrollment, login failure, password change, and session revocation. +- Error responses do not disclose whether an account exists. +- Logs contain request IDs and sanitized account/project IDs, never credentials or session tokens. + +## Development and production separation + +The first implementation slice uses a development repository and tests. It must not connect to the production `umbracontrol` database until: + +1. the additive identity migration is reviewed; +2. a logical backup and restore test pass; +3. the migration role and application roles are verified; +4. RLS tests cover two organizations and two projects; +5. the account-management tests pass against PostgreSQL. + +Production PostgreSQL remains the authoritative store. Local development data stays below the workspace development data root and is never a substitute for production. + +## Implementation order + +1. Add the identity/account-management domain types and repository interface. **Done.** +2. Implement an in-memory development repository with deterministic tests. **Done.** +3. Implement Argon2id credential service, temporary-password enrollment, and first-login password change. **Done.** +4. Add FastAPI routes and secure cookie/session middleware. **Done.** +5. Add administrator account-management routes and audit events. **Done.** +6. Add the reviewed PostgreSQL migration and adapter. Migration drafted and unapplied; adapter not started. +7. Add PostgreSQL integration tests in an isolated database. +8. Connect the static prototype to the API. +9. Only then provision the first administrator in the approved environment. + +Steps 1 through 5 are implemented in `umbratrans_web/` against development +repositories only. The application fails closed rather than starting against an +unreviewed store, so nothing in this slice can touch `umbracontrol` by accident. + +## Acceptance criteria for phase 1 + +- An administrator can create a local-password account and an OIDC-enrollment account. **Done.** +- A generated one-time password is returned only by the creation response and is unrecoverable afterwards. **Done.** +- A temporary-password account cannot reach any route, including `/api/auth/me` and the administrator surface, before changing its password. **Done.** +- After the change, the one-time password no longer authenticates. **Done.** +- Passwords are never persisted or logged in plaintext, and a submitted password field is rejected by the schema. **Done.** +- A suspended user cannot create a session or use an existing session. **Done.** +- An administrator cannot access another organization through a forged ID, and cannot escalate to platform administrator. **Done.** +- Account-management actions create sanitized audit events with the requesting administrator as the actor. **Done.** +- A user can access only projects granted through active membership. *Pending: project membership is not in this slice.* +- The static UI can be connected to the API without changing the audio worker. +- Production migration and bootstrap remain blocked until explicitly approved. diff --git a/pyproject.toml b/pyproject.toml index 672ef3a..ed3eaa4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,19 +3,36 @@ name = "umbratrans-agent-comm" version = "0.1.0" description = "Local stdio-only sanitized agent communication server" requires-python = ">=3.11" -dependencies = [] +dependencies = [ + "argon2-cffi>=23.1,<26", + "fastapi>=0.115,<1", + "psycopg[binary]>=3.2,<4", + "uvicorn>=0.34,<1", +] [project.scripts] agent-comm = "agent_comm.server:main" +umbratrans-bootstrap-admin = "umbratrans_web.cli:main" [dependency-groups] -dev = ["ruff==0.16.9"] +dev = [ + "httpx>=0.28,<1", + "pytest>=8,<9", + "ruff==0.16.9", +] [tool.uv] package = true [tool.setuptools.packages.find] -include = ["agent_comm*"] +include = ["agent_comm*", "umbratrans_web*"] + +[tool.setuptools.package-data] +umbratrans_web = ["migrations/*.sql", "migrations/*.md"] + +[tool.pytest.ini_options] +testpaths = ["umbratrans_web/tests", "agent_comm/tests"] +python_files = ["test_*.py"] [tool.ruff] target-version = "py311" diff --git a/umbratrans_web/README.md b/umbratrans_web/README.md new file mode 100644 index 0000000..2bf13d4 --- /dev/null +++ b/umbratrans_web/README.md @@ -0,0 +1,126 @@ +# UmbraTrans web account management — phase-1 identity 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. + +**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. + +## Layout + +| 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. | +| `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. | +| `schemas.py` | Pydantic request/response models. Request models forbid unknown fields. | +| `app.py` | Application factory, security middleware, CSRF, error mapping, 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. | + +## Enrollment boundary + +Both modes are separated by type rather than by convention, and neither accepts +a password from a client: + +- `temporary_password`, operator path: `AccountService.create_account` requires + an `OperatorContext`, which only the operator CLI builds, plus a + `must_change_password = True` Argon2id credential. The plaintext exists only + inside the hidden prompt call and is never persisted, logged, or audited. +- `temporary_password`, administrator path: + `AccountService.provision_admin_temporary_password` generates the one-time + password in process, validates it against the local policy, hashes it, and + returns it once in the creation response as + `IssuedTemporaryPassword`. The value is excluded from that object's `repr` + and is not retrievable again by any endpoint. +- `oidc` requires an authenticated administrator principal and stores no local + password. An `oidc` credential with a hash is rejected by the model. + +`CreateAccountRequest` has no `password` field and refuses extra fields, so a +submitted password is rejected by the schema before a handler runs. Account +creation returns `CreateAccountResponse`, whose `temporary_password` field is +populated only for the generated-password mode. + +## API + +``` +GET /api/health +POST /api/auth/login +POST /api/auth/change-first-password +POST /api/auth/logout +POST /api/auth/logout-everywhere +GET /api/auth/me +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/audit +``` + +- Sessions are server-side. The cookie holds a random token; only its hash is + stored. The cookie is `HttpOnly`, `SameSite=Lax`, and `Secure` whenever + production settings are used. +- CSRF uses the double-submit pattern: the readable `umbratrans_csrf` cookie + plus an `X-CSRF-Token` header compared in constant time. `POST + /api/auth/login` is exempt because it carries credentials an attacker cannot + know. A safe request (for example `GET /api/health` or `GET /api/auth/me`) + issues the cookie and returns the token. +- No CORS middleware is installed; the API is same-origin behind the approved + reverse proxy. +- Error bodies are `{"error": {"code", "message"}}`. Validation failures report + field names only, because FastAPI's default payload would echo a submitted + password. +- `POST /api/auth/login` returns the CSRF token; `GET /api/auth/me` returns it + again, so a client can bootstrap without a separate endpoint. + +## Operator bootstrap + +```bash +cd /home/umbra/src/UmbraTrans +UMBRA_ENV=development \ + /home/umbra/.local/bin/uv run umbratrans-bootstrap-admin \ + --username Umbraadmin --repository-path .local/data/development/umbratrans-web/dev-accounts.json +``` + +The command reads the one-time password twice from a hidden TTY prompt, refuses +to run without a terminal, refuses production mode, and writes only to the +local development store. Nothing it prints contains the password. It has not +been run in this workspace. + +`python -m umbratrans_web bootstrap-admin` is equivalent. `python -m +umbratrans_web serve` starts the development API on `127.0.0.1` and refuses a +non-loopback bind or a production environment. + +## Development + +```bash +cd /home/umbra/src/UmbraTrans +/home/umbra/.local/bin/uv sync --python /usr/bin/python3 +/home/umbra/.local/bin/uv run pytest -q +/home/umbra/.local/bin/uv run ruff check . +/home/umbra/.local/bin/uv run ruff format --check . +/home/umbra/.local/bin/uv run python -m compileall -q umbratrans_web +``` + +The suite uses the in-memory repository and a reduced Argon2 cost for speed; +`tests/test_security.py` asserts the production parameter profile separately. +Test passwords are generated per run, so no fixture file carries credential +material. + +## Not in this slice + +- The PostgreSQL adapter, the OIDC callback and subject mapping, MFA + enforcement, project 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. diff --git a/umbratrans_web/__init__.py b/umbratrans_web/__init__.py new file mode 100644 index 0000000..dba6562 --- /dev/null +++ b/umbratrans_web/__init__.py @@ -0,0 +1,96 @@ +"""UmbraTrans web account management: phase-1 identity slice. + +This package implements the first backend slice described in +``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. + +It does not connect to PostgreSQL. The reviewed migration in +``umbratrans_web/migrations/`` is intentionally unapplied, and the production +repository adapter is not part of this slice, so the application fails closed +rather than starting without an audit-reviewed store. +""" + +from __future__ import annotations + +from .config import SERVICE_NAME, SERVICE_VERSION, Environment, Settings, load_settings, suite_settings +from .errors import ( + AuthenticationRequiredError, + ConfigurationError, + ConflictError, + CsrfError, + EnrollmentRefusedError, + ForbiddenError, + InputError, + InvalidCredentialsError, + NotFoundError, + OperatorRefusalError, + PasswordChangeRequiredError, + PasswordPolicyError, + RepositoryError, + StateTransitionError, + UmbraWebError, +) +from .models import ( + AccountStatus, + AccountView, + AuditEvent, + AuthenticationResult, + CredentialRecord, + CredentialType, + EnrollmentMode, + IssuedSession, + Principal, + RoleName, + SessionRecord, + User, +) +from .repository import AccountRepository, InMemoryAccountRepository +from .security import PasswordPolicy, PasswordService +from .service import AccountService, OperatorContext + +__version__ = SERVICE_VERSION + +__all__ = [ + "SERVICE_NAME", + "SERVICE_VERSION", + "AccountRepository", + "AccountService", + "AccountStatus", + "AccountView", + "AuditEvent", + "AuthenticationRequiredError", + "AuthenticationResult", + "AuditEvent", + "ConfigurationError", + "ConflictError", + "CredentialRecord", + "CredentialType", + "CsrfError", + "EnrollmentMode", + "EnrollmentRefusedError", + "Environment", + "ForbiddenError", + "InputError", + "InMemoryAccountRepository", + "InvalidCredentialsError", + "IssuedSession", + "NotFoundError", + "OperatorContext", + "OperatorRefusalError", + "PasswordChangeRequiredError", + "PasswordPolicy", + "PasswordPolicyError", + "PasswordService", + "Principal", + "RepositoryError", + "RoleName", + "SessionRecord", + "Settings", + "StateTransitionError", + "UmbraWebError", + "User", + "__version__", + "load_settings", + "suite_settings", +] diff --git a/umbratrans_web/__main__.py b/umbratrans_web/__main__.py new file mode 100644 index 0000000..85240e0 --- /dev/null +++ b/umbratrans_web/__main__.py @@ -0,0 +1,36 @@ +"""Module entry point: ``python -m umbratrans_web ``. + +``bootstrap-admin`` is the hidden-prompt operator command and is also installed +as the ``umbratrans-bootstrap-admin`` console script. ``serve`` runs the +development API on the loopback interface. +""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence + +from .cli import bootstrap_admin, serve + +COMMANDS = { + "bootstrap-admin": bootstrap_admin, + "serve": serve, +} + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(argv) if argv is not None else sys.argv[1:] + if not args or args[0] in {"-h", "--help"}: + print("usage: python -m umbratrans_web {" + ",".join(sorted(COMMANDS)) + "}", file=sys.stderr) + print("See each command's --help output. No command accepts a password argument.", file=sys.stderr) + return 0 + command, rest = args[0], args[1:] + handler = COMMANDS.get(command) + if handler is None: + print(f"unknown command: {command}", file=sys.stderr) + return 2 + return handler(rest) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/umbratrans_web/app.py b/umbratrans_web/app.py new file mode 100644 index 0000000..f3fbb63 --- /dev/null +++ b/umbratrans_web/app.py @@ -0,0 +1,448 @@ +"""FastAPI application factory, security middleware, and shared dependencies. + +Design decisions worth stating explicitly: + +* Handlers are synchronous ``def`` functions. FastAPI runs them in its worker + threadpool, so the CPU-bound Argon2 work does not block the event loop. +* The session token travels in an ``HttpOnly``, ``SameSite=Lax`` cookie that is + ``Secure`` whenever production settings are used. The raw token is never + written to a log, a response body, or the repository. +* CSRF protection uses the double-submit pattern: a readable cookie plus an + ``X-CSRF-Token`` header compared in constant time. ``POST /api/auth/login`` is + exempt because it carries credentials the attacker does not have; every other + state-changing API route requires the header. +* Error responses are a fixed ``{"error": {"code", "message"}}`` shape. FastAPI's + default validation payload echoes the submitted value, so a custom handler + strips it: a rejected password must not be reflected to the client. +* No CORS middleware is installed. The API is same-origin only; the approved + reverse proxy terminates TLS in production. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from http.cookies import SimpleCookie +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 starlette.datastructures import MutableHeaders +from starlette.middleware.trustedhost import TrustedHostMiddleware +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from .config import Settings, load_settings +from .errors import ( + AuthenticationRequiredError, + ConfigurationError, + CsrfError, + ForbiddenError, + InvalidCredentialsError, + PasswordChangeRequiredError, + PasswordPolicyError, + UmbraWebError, +) +from .models import Principal +from .repository import AccountRepository, build_repository +from .schemas import HealthResponse +from .security import generate_token, install_secret_redaction_filter, tokens_match +from .service import AccountService + +LOGGER = logging.getLogger("umbratrans_web.app") + +#: Unsafe methods require a CSRF token. +UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +#: The login route is exempt: it carries credentials the attacker cannot know, +#: and requiring a prior CSRF bootstrap would break the sign-in page. +CSRF_EXEMPT_PATHS = frozenset({"/api/auth/login"}) + +SESSION_REVOKED_CODES = frozenset({"authentication_required", "invalid_credentials"}) + + +def request_id(request: Request) -> str: + """Return the correlation id assigned to the current request.""" + + return cast(str, getattr(request.state, "request_id", "") or "") + + +def get_settings(request: Request) -> Settings: + return cast(Settings, request.app.state.settings) + + +def get_service(request: Request) -> AccountService: + return cast(AccountService, request.app.state.service) + + +def get_repository(request: Request) -> AccountRepository: + return cast(AccountRepository, request.app.state.repository) + + +def _cookie_value(header: str | None, name: str) -> str | None: + if not header: + return None + jar = SimpleCookie() + try: + jar.load(header) + except Exception: # noqa: BLE001 - malformed cookie header must not raise + return None + morsel = jar.get(name) + return morsel.value if morsel else None + + +def session_token(request: Request) -> str | None: + """Extract the session token from the request cookie.""" + + settings = get_settings(request) + return _cookie_value(request.headers.get("cookie"), settings.session_cookie_name) + + +def read_csrf_token(request: Request) -> str: + settings = get_settings(request) + return _cookie_value(request.headers.get("cookie"), settings.csrf_cookie_name) or "" + + +def build_cookie( + name: str, + value: str, + *, + max_age: int | None = None, + http_only: bool = False, + secure: bool = False, + same_site: str = "lax", + path: str = "/", +) -> str: + """Serialize one ``Set-Cookie`` header value.""" + + jar: SimpleCookie[str] = SimpleCookie() + jar[name] = value + morsel = jar[name] + morsel["path"] = path + if max_age is not None: + morsel["max-age"] = str(max_age) + morsel["expires"] = "Thu, 01 Jan 1970 00:00:00 GMT" + if http_only: + morsel["httponly"] = True + if secure: + morsel["secure"] = True + morsel["samesite"] = same_site.capitalize() + return jar.output(header="").strip() + + +def set_session_cookie(response: Response, *, token: str, settings: Settings) -> None: + response.set_cookie( + key=settings.session_cookie_name, + value=token, + max_age=settings.session_absolute_seconds, + httponly=True, + secure=settings.cookie_secure, + samesite=settings.cookie_samesite, + path=settings.cookie_path, + ) + + +def clear_session_cookie(response: Response, *, settings: Settings) -> None: + response.delete_cookie( + key=settings.session_cookie_name, + path=settings.cookie_path, + secure=settings.cookie_secure, + httponly=True, + samesite=settings.cookie_samesite, + ) + + +def set_csrf_cookie(response: Response, *, token: str, settings: Settings) -> None: + """Issue the readable double-submit cookie. Not ``HttpOnly`` by design.""" + + response.set_cookie( + key=settings.csrf_cookie_name, + value=token, + max_age=settings.session_absolute_seconds, + httponly=False, + secure=settings.cookie_secure, + samesite=settings.cookie_samesite, + path=settings.cookie_path, + ) + + +def clear_csrf_cookie(response: Response, *, settings: Settings) -> None: + response.delete_cookie( + key=settings.csrf_cookie_name, + path=settings.cookie_path, + secure=settings.cookie_secure, + httponly=False, + samesite=settings.cookie_samesite, + ) + + +def ensure_csrf_token(response: Response, *, settings: Settings, existing: str | None = None) -> str: + """Return the current CSRF token, issuing one if the request had none.""" + + token = existing or generate_token() + if not existing: + set_csrf_cookie(response, token=token, settings=settings) + return token + + +class ApiSecurityMiddleware: + """Request ids, response hardening headers, and CSRF enforcement. + + Implemented as raw ASGI so it also covers requests rejected before routing + and so response headers can be added without buffering the body. + """ + + def __init__(self, app: ASGIApp, settings: Settings) -> None: + self._app = app + self._settings = settings + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + headers = MutableHeaders(scope=scope) + correlation = headers.get("x-request-id") or uuid4().hex + if len(correlation) > 128 or not correlation.isprintable(): + correlation = uuid4().hex + state = scope.setdefault("state", {}) + state["request_id"] = correlation + + path = scope.get("path", "") + is_api = path.startswith("/api") + 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: + problem = self._check_csrf(headers) + if problem is not None: + response = JSONResponse( + status_code=problem.http_status, + content={"error": {"code": problem.code, "message": problem.message}}, + ) + await response(scope, receive, send) + return + + csrf_cookie = _cookie_value(headers.get("cookie"), self._settings.csrf_cookie_name) + should_issue_csrf = is_api and method not in UNSAFE_METHODS and not csrf_cookie + issued_csrf = generate_token() if should_issue_csrf else None + + async def send_wrapper(message: Message) -> None: + if message["type"] == "http.response.start": + response_headers = MutableHeaders(scope=message) + response_headers["x-request-id"] = correlation + response_headers["x-content-type-options"] = "nosniff" + response_headers["x-frame-options"] = "DENY" + response_headers["referrer-policy"] = "no-referrer" + response_headers["cross-origin-opener-policy"] = "same-origin" + if is_api: + response_headers["cache-control"] = "no-store" + response_headers["content-security-policy"] = "default-src 'none'; frame-ancestors 'none'" + response_headers["vary"] = "Cookie" + if issued_csrf is not None: + response_headers.append( + "set-cookie", + build_cookie( + self._settings.csrf_cookie_name, + issued_csrf, + max_age=self._settings.session_absolute_seconds, + http_only=False, + secure=self._settings.cookie_secure, + same_site=self._settings.cookie_samesite, + path=self._settings.cookie_path, + ), + ) + await send(message) + + await self._app(scope, receive, send_wrapper) + + def _check_csrf(self, headers: MutableHeaders) -> CsrfError | None: + cookie = _cookie_value(headers.get("cookie"), self._settings.csrf_cookie_name) + header = headers.get(self._settings.csrf_header_name) + if not cookie or not header: + return CsrfError("csrf_token_missing", "This request is missing its CSRF token. Reload and retry.") + if not tokens_match(header, cookie): + return CsrfError("csrf_token_invalid") + return None + + +def get_principal_allowing_password_change(request: Request) -> Principal: + """Resolve the session, tolerating a mandatory first-password change.""" + + token = session_token(request) + if not token: + raise AuthenticationRequiredError() + service = get_service(request) + try: + return service.resolve_session(token, request_id=request_id(request)) + except InvalidCredentialsError as error: + raise AuthenticationRequiredError() from error + + +def require_principal(principal: Principal = Depends(get_principal_allowing_password_change)) -> Principal: + """Require a fully usable session.""" + + if principal.must_change_password: + raise PasswordChangeRequiredError() + return principal + + +def require_admin(principal: Principal = Depends(require_principal)) -> Principal: + """Require a platform or organization administrator session.""" + + if not principal.is_admin: + raise ForbiddenError() + return principal + + +def error_payload(error: UmbraWebError) -> tuple[int, dict[str, dict[str, Any]]]: + """Map a domain error to a status and a fixed-shape body. + + Messages are only echoed for client errors (4xx). A 5xx always returns a + generic body so an internal message can never leak configuration. A + password-policy failure additionally names the unmet *rules*; it never + echoes the candidate value. + """ + + status = error.http_status + message = error.message if 400 <= status < 500 else "The request could not be completed." + body: dict[str, Any] = {"code": error.code, "message": message} + if isinstance(error, PasswordPolicyError) and error.unmet_requirements: + body["requirements"] = list(error.unmet_requirements) + return status, {"error": body} + + +def validation_fields(error: RequestValidationError) -> list[str]: + """Return the field names of a validation failure, never the values. + + FastAPI's default payload embeds the submitted input, which for this API + could be a password or a session token. Only the dotted field path is kept. + """ + + skip = {"body", "query", "path", "header", "cookie"} + names: set[str] = set() + for item in error.errors(): + parts = [str(part) for part in item.get("loc", ()) if str(part) not in skip] + names.add(".".join(parts) if parts else "payload") + return sorted(names) + + +def create_app( + *, + settings: Settings | None = None, + repository: AccountRepository | None = None, + service: AccountService | None = None, +) -> FastAPI: + """Build the phase-1 application. + + Production settings can only be constructed with the PostgreSQL adapter, + which is not part of this slice, so this factory cannot start an + unaudited production process. + """ + + resolved_settings = settings or load_settings() + if resolved_settings.is_production and repository is None: + raise ConfigurationError( + "production_repository_missing", + "Production requires an explicit PostgreSQL repository adapter, which is not part of this slice.", + ) + + resolved_repository = build_repository(resolved_settings, repository=repository) + resolved_service = service or AccountService(resolved_repository, resolved_settings) + + logging.getLogger("umbratrans_web").setLevel(logging.INFO) + install_secret_redaction_filter() + + app = FastAPI( + title="UmbraTrans account management", + version=resolved_settings.version, + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + app.state.settings = resolved_settings + app.state.repository = resolved_repository + app.state.service = resolved_service + + if resolved_settings.allowed_hosts: + app.add_middleware(TrustedHostMiddleware, allowed_hosts=list(resolved_settings.allowed_hosts)) + app.add_middleware(ApiSecurityMiddleware, settings=resolved_settings) + + @app.exception_handler(UmbraWebError) + async def _handle_domain_error(_request: Request, error: UmbraWebError) -> JSONResponse: + status, body = error_payload(error) + headers: dict[str, str] = {} + if isinstance(error, AuthenticationRequiredError) or error.code in SESSION_REVOKED_CODES: + headers["x-umbratrans-reauthenticate"] = "1" + return JSONResponse(status_code=status, content=body, headers=headers) + + @app.exception_handler(RequestValidationError) + async def _handle_validation_error(_request: Request, error: RequestValidationError) -> JSONResponse: + # FastAPI's default payload echoes the submitted input. Strip it: a + # rejected password must never be reflected back to the client. + return JSONResponse( + status_code=422, + content={ + "error": { + "code": "invalid_request", + "message": "The request payload is invalid.", + "fields": validation_fields(error), + } + }, + ) + + from .routes_auth import router as auth_router + from .routes_uadmin import router as uadmin_router + + app.include_router(auth_router, prefix="/api/auth", tags=["auth"]) + app.include_router(uadmin_router, prefix="/api/uadmin", tags=["uadmin"]) + + @app.get("/api/health", tags=["health"]) + def health() -> HealthResponse: + """Unauthenticated liveness probe. Contains no secret or database detail.""" + + fingerprint = resolved_settings.public_fingerprint() + return HealthResponse( + status="ok", + service=resolved_settings.service_name, + version=resolved_settings.version, + environment=str(fingerprint["environment"]), + repository=str(fingerprint["repository"]), + cookie_secure=bool(fingerprint["cookie_secure"]), + csrf_required=bool(fingerprint["csrf_required"]), + time=datetime.now(timezone.utc), + ) + + LOGGER.info( + "application created environment=%s repository=%s csrf_required=%s cookie_secure=%s", + resolved_settings.environment, + resolved_settings.repository_kind, + resolved_settings.require_csrf, + resolved_settings.cookie_secure, + ) + return app + + +__all__ = [ + "ApiSecurityMiddleware", + "CSRF_EXEMPT_PATHS", + "UNSAFE_METHODS", + "build_cookie", + "clear_csrf_cookie", + "clear_session_cookie", + "create_app", + "ensure_csrf_token", + "error_payload", + "get_principal_allowing_password_change", + "get_repository", + "get_service", + "get_settings", + "read_csrf_token", + "request_id", + "require_admin", + "require_principal", + "session_token", + "set_csrf_cookie", + "set_session_cookie", + "validation_fields", +] diff --git a/umbratrans_web/cli.py b/umbratrans_web/cli.py new file mode 100644 index 0000000..5a9cc30 --- /dev/null +++ b/umbratrans_web/cli.py @@ -0,0 +1,336 @@ +"""Hidden-prompt operator CLI for the first local administrator. + +Installed as the ``umbratrans-bootstrap-admin`` console script and also +reachable as ``python -m umbratrans_web bootstrap-admin``. + +Guarantees: + +* The one-time password is read only from a TTY hidden prompt + (:func:`getpass.getpass`). It is never read from an argument, an environment + variable, a file, or stdin, and it is never echoed, logged, or audited. +* The command refuses to run when ``UMBRA_ENV`` selects production, and it + writes only to a local development repository with restrictive file + permissions. +* Only the account identifier, role, status, and ``must_change_password`` state + are printed. The command prints nothing that could help recover the secret. + +Note on the bootstrap account: the default username is the documented, +non-secret bootstrap identifier ``Umbraadmin``. The password is supplied +interactively by the operator at run time; this repository intentionally +contains no such value. +""" + +from __future__ import annotations + +import argparse +import getpass +import hmac +import os +import stat +import sys +from collections.abc import Callable, Sequence +from dataclasses import replace +from pathlib import Path +from typing import Protocol, TextIO +from uuid import UUID + +from .config import ( + DEVELOPMENT_DATA_ROOT, + DEVELOPMENT_REPOSITORY_RELATIVE_PATH, + Settings, + load_settings, +) +from .errors import InputError, OperatorRefusalError, PasswordPolicyError, UmbraWebError +from .models import AccountView, RoleName +from .service import AccountService, OperatorContext + +BOOTSTRAP_USERNAME = "Umbraadmin" +BOOTSTRAP_DISPLAY_NAME = "Umbra Administrator" +EXIT_OK = 0 +EXIT_ERROR = 1 +EXIT_REFUSED_PRODUCTION = 3 +EXIT_NOT_INTERACTIVE = 4 +EXIT_PASSWORD_REJECTED = 5 + + +class _Tty(Protocol): + def isatty(self) -> bool: ... + + +class _InteractiveTerminalUnavailable(OperatorRefusalError): + pass + + +def _default_repository_path() -> Path: + return (DEVELOPMENT_DATA_ROOT / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve() + + +def _require_interactive(stdin: TextIO) -> None: + """Refuse to run without a terminal. + + A hidden prompt needs a real TTY. Without one, the command fails instead + of falling back to a value that could land in a shell history file. + """ + + try: + interactive = bool(stdin.isatty()) + except (AttributeError, ValueError, OSError) as error: + raise _InteractiveTerminalUnavailable( + "terminal_unavailable", "A hidden prompt requires an interactive terminal." + ) from error + if not interactive: + raise _InteractiveTerminalUnavailable( + "not_interactive", + "A hidden prompt requires an interactive terminal; refusing to read a password from a pipe or file.", + ) + + +def _prompt_hidden_password(prompt: str) -> str: + return getpass.getpass(prompt) + + +def build_parser() -> argparse.ArgumentParser: + """Build the argument parser for the bootstrap command.""" + + parser = argparse.ArgumentParser( + prog="umbratrans-bootstrap-admin", + description=( + "Provision the first local development administrator. The one-time password is read from a hidden " + "prompt and is never stored in plaintext. Production mode is refused." + ), + ) + parser.add_argument("--username", default=BOOTSTRAP_USERNAME, help="non-secret bootstrap username") + parser.add_argument("--display-name", default=BOOTSTRAP_DISPLAY_NAME, help="display name for the account") + parser.add_argument("--email", default=None, help="optional email address") + parser.add_argument( + "--organization-id", + default=None, + help="optional organization identifier the account belongs to", + ) + parser.add_argument( + "--role", + default=str(RoleName.PLATFORM_ADMIN), + choices=[str(role) for role in RoleName], + help="role for the bootstrap account", + ) + parser.add_argument( + "--repository-path", + default=None, + help=f"local development repository file (default: {_default_repository_path()})", + ) + return parser + + +def bootstrap_admin( + argv: Sequence[str] | None = None, + *, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + stderr: TextIO | None = None, + prompt: Callable[[str], str] = _prompt_hidden_password, +) -> int: + """Create the bootstrap administrator. Returns a process exit code.""" + + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + out: TextIO = stdout or sys.stdout + err: TextIO = stderr or sys.stderr + stream: TextIO = stdin or sys.stdin + + if _environment_is_production(): + print( + "Refusing to run: the bootstrap command is development-only. Production provisioning is blocked until " + "the identity migration, backup, and role gates are approved.", + file=err, + ) + return EXIT_REFUSED_PRODUCTION + + try: + settings = _bootstrap_settings(args.repository_path) + except OperatorRefusalError as refusal: + print(f"Refusing to run: {refusal.message}", file=err) + return EXIT_REFUSED_PRODUCTION + except UmbraWebError as error: + print(f"Refusing to run: {error.message}", file=err) + return EXIT_ERROR + + if settings.is_production: + print( + "Refusing to run: the bootstrap command is development-only. Production provisioning is blocked until " + "the identity migration, backup, and role gates are approved.", + file=err, + ) + return EXIT_REFUSED_PRODUCTION + + try: + _require_interactive(stream) + except OperatorRefusalError as refusal: + print(f"Refusing to run: {refusal.message}", file=err) + return EXIT_NOT_INTERACTIVE + + password = _read_confirmed_password(prompt, err=err) + if password is None: + return EXIT_PASSWORD_REJECTED + + try: + account = _provision(settings, args=args, password=password) + except PasswordPolicyError as error: + requirements = ", ".join(error.unmet_requirements) or "policy" + print(f"Refusing to run: the password does not meet the policy ({requirements}).", file=err) + return EXIT_PASSWORD_REJECTED + except (UmbraWebError, OSError) as error: + print(f"Failed to provision the bootstrap account: {_safe_error_text(error)}", file=err) + return EXIT_ERROR + + # Drop the reference as soon as the hash exists. Python cannot wipe an + # immutable string reliably, which is exactly why the prompt is the only + # place the value ever exists and why it is never written anywhere. + password = "" + + repository_path = settings.repository_path + mode = "" + if repository_path is not None and repository_path.exists(): + mode = oct(stat.S_IMODE(repository_path.stat().st_mode)) + print("Bootstrap account created.", file=out) + print(f" username: {account.username}", file=out) + print(f" account id: {account.id}", file=out) + print(f" role: {account.role}", file=out) + print(f" status: {account.status}", file=out) + print(f" must_change_password: {str(account.credential.must_change_password).lower()}", file=out) + if repository_path is not None: + print(f" development store: {repository_path} (mode {mode or 'created on first write'})", file=out) + print( + " The one-time password was not stored. The first login must change it before any other call succeeds.", + file=out, + ) + return EXIT_OK + + +def _environment_is_production(environ: dict[str, str] | None = None) -> bool: + """Report whether the environment selects production mode. + + Checked before any settings are built so that a production environment + always produces the dedicated refusal exit code, even when the remaining + production configuration is incomplete. + """ + + env = os.environ if environ is None else environ + return env.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"} + + +def _bootstrap_settings(repository_path: str | None) -> Settings: + """Load settings for the bootstrap run, forcing the development repository.""" + + settings = load_settings() + if settings.is_production: + raise OperatorRefusalError( + "production_refused", + "the bootstrap command must not run with production configuration", + ) + path = Path(repository_path).expanduser() if repository_path else _default_repository_path() + return replace(settings, repository_kind="json", repository_path=path) + + +def _read_confirmed_password(prompt: Callable[[str], str], *, err: TextIO) -> str | None: + """Prompt twice and require an exact match. Nothing is echoed.""" + + first = prompt("One-time password (input hidden): ") + second = prompt("Repeat one-time password: ") + if not first or not hmac.compare_digest(first, second): + print("Refusing to run: the two entries did not match.", file=err) + return None + return first + + +def _provision(settings: Settings, *, args: argparse.Namespace, password: str) -> AccountView: + """Create the account in the local development repository.""" + + from .dev_repository import JsonDevelopmentRepository + + repository = JsonDevelopmentRepository(settings) + service = AccountService(repository, settings) + organization_id = UUID(args.organization_id) if args.organization_id else None + return service.create_account( + enrollment_mode="temporary_password", + username=args.username, + display_name=args.display_name, + role=args.role, + email=args.email, + organization_id=organization_id, + password=password, + operator=OperatorContext(source="operator-cli", interactive=True), + ) + + +def _safe_error_text(error: BaseException) -> str: + """Render an error without echoing any submitted value.""" + + if isinstance(error, InputError): + return error.message + return type(error).__name__ + + +def serve( + argv: Sequence[str] | None = None, + *, + stdout: TextIO | None = None, + stderr: TextIO | None = None, +) -> int: + """Run the development application with uvicorn on the loopback interface.""" + + parser = argparse.ArgumentParser( + prog="umbratrans-web-serve", + description="Run the UmbraTrans account-management API for local development (loopback only by default).", + ) + parser.add_argument("--host", default="127.0.0.1", help="bind address (default: loopback)") + parser.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)") + parser.add_argument("--repository", default="memory", choices=["memory", "json"], help="development repository") + args = parser.parse_args(list(argv) if argv is not None else None) + + if os.environ.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"}: + print( + "Refusing to serve: this development server must not be started with production configuration.", + file=stderr or sys.stderr, + ) + return EXIT_REFUSED_PRODUCTION + if args.host not in {"127.0.0.1", "::1", "localhost"}: + print( + "Refusing to serve: bind to the loopback interface for development; production uses the approved proxy.", + file=stderr or sys.stderr, + ) + return EXIT_REFUSED_PRODUCTION + + import uvicorn + + from .app import create_app + + settings = load_settings({**os.environ, "UMBRATRANS_REPOSITORY": args.repository}) + if args.repository == "json": + settings = settings.with_development_path_defaults() + uvicorn.run( + create_app(settings=settings), + host=args.host, + port=args.port, + log_level="info", + ) + return EXIT_OK + + +def main(argv: Sequence[str] | None = None) -> int: + """Console-script entry point for ``umbratrans-bootstrap-admin``.""" + + return bootstrap_admin(argv) + + +__all__ = [ + "BOOTSTRAP_DISPLAY_NAME", + "BOOTSTRAP_USERNAME", + "EXIT_NOT_INTERACTIVE", + "EXIT_OK", + "EXIT_PASSWORD_REJECTED", + "EXIT_REFUSED_PRODUCTION", + "bootstrap_admin", + "build_parser", + "main", + "serve", +] diff --git a/umbratrans_web/config.py b/umbratrans_web/config.py new file mode 100644 index 0000000..fb7ead4 --- /dev/null +++ b/umbratrans_web/config.py @@ -0,0 +1,292 @@ +"""Process configuration for the UmbraTrans web slice. + +Configuration is fail-closed: + +* Production requires ``Secure`` cookies, CSRF enforcement, and a repository + adapter other than the local development stores. +* The local JSON development repository is refused outright in production. +* Wildcard host allow-lists and short token peppers are rejected. +* The production PostgreSQL adapter is not part of this slice, so requesting + production mode raises instead of silently starting an insecure process. + +The token pepper is excluded from ``repr`` and is never logged. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field, replace +from enum import StrEnum +from pathlib import Path + +from .errors import ConfigurationError +from .security import ( + ARGON2_HASH_LENGTH, + ARGON2_MEMORY_COST_KIB, + ARGON2_PARALLELISM, + ARGON2_SALT_LENGTH, + ARGON2_TIME_COST, + MIN_PEPPER_BYTES, +) + +SERVICE_NAME = "umbratrans-web" +SERVICE_VERSION = "0.1.0" + +DEFAULT_SESSION_COOKIE_NAME = "umbratrans_session" +DEFAULT_CSRF_COOKIE_NAME = "umbratrans_csrf" +DEFAULT_CSRF_HEADER_NAME = "x-csrf-token" +DEFAULT_SESSION_IDLE_SECONDS = 30 * 60 +DEFAULT_SESSION_ABSOLUTE_SECONDS = 12 * 60 * 60 +DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS = 5 +DEFAULT_LOCKOUT_SECONDS = 15 * 60 +DEVELOPMENT_DATA_ROOT = Path(".local/data/development") +DEVELOPMENT_REPOSITORY_RELATIVE_PATH = Path("umbratrans-web/dev-accounts.json") +PRODUCTION_REPOSITORY_KIND = "postgres" +REPOSITORY_KINDS = ("memory", "json", PRODUCTION_REPOSITORY_KIND) + + +class Environment(StrEnum): + DEVELOPMENT = "development" + TEST = "test" + PRODUCTION = "production" + + @property + def is_production(self) -> bool: + return self is Environment.PRODUCTION + + +def _environment_from(value: str) -> Environment: + normalized = value.strip().lower() + aliases = { + "development": Environment.DEVELOPMENT, + "dev": Environment.DEVELOPMENT, + "local": Environment.DEVELOPMENT, + "test": Environment.TEST, + "testing": Environment.TEST, + "production": Environment.PRODUCTION, + "prod": Environment.PRODUCTION, + } + if normalized not in aliases: + raise ConfigurationError( + "invalid_environment", + f"UMBRA_ENV must be one of development, test, production (got {value!r}).", + ) + return aliases[normalized] + + +def _bool_from(value: str, *, name: str) -> bool: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ConfigurationError("invalid_boolean", f"{name} must be a boolean value.") + + +def _int_from(value: str, *, name: str, minimum: int = 1) -> int: + try: + parsed = int(value.strip()) + except ValueError as error: + raise ConfigurationError("invalid_integer", f"{name} must be an integer.") from error + if parsed < minimum: + raise ConfigurationError("invalid_integer", f"{name} must be >= {minimum}.") + return parsed + + +@dataclass(frozen=True, slots=True) +class Settings: + """Immutable settings for one application process.""" + + environment: Environment = Environment.DEVELOPMENT + service_name: str = SERVICE_NAME + version: str = SERVICE_VERSION + session_cookie_name: str = DEFAULT_SESSION_COOKIE_NAME + csrf_cookie_name: str = DEFAULT_CSRF_COOKIE_NAME + csrf_header_name: str = DEFAULT_CSRF_HEADER_NAME + cookie_secure: bool = False + cookie_samesite: str = "lax" + cookie_path: str = "/" + require_csrf: bool = True + session_idle_seconds: int = DEFAULT_SESSION_IDLE_SECONDS + session_absolute_seconds: int = DEFAULT_SESSION_ABSOLUTE_SECONDS + max_failed_login_attempts: int = DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS + lockout_seconds: int = DEFAULT_LOCKOUT_SECONDS + argon2_memory_cost_kib: int = ARGON2_MEMORY_COST_KIB + argon2_time_cost: int = ARGON2_TIME_COST + argon2_parallelism: int = ARGON2_PARALLELISM + argon2_hash_length: int = ARGON2_HASH_LENGTH + argon2_salt_length: int = ARGON2_SALT_LENGTH + password_min_length: int = 14 + password_max_length: int = 1024 + repository_kind: str = "memory" + repository_path: 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) + + def __post_init__(self) -> None: + environment = Environment(self.environment) + object.__setattr__(self, "environment", environment) + if self.repository_kind not in REPOSITORY_KINDS: + raise ConfigurationError( + "invalid_repository_kind", + f"Repository kind must be one of {', '.join(REPOSITORY_KINDS)}.", + ) + if self.session_idle_seconds > self.session_absolute_seconds: + raise ConfigurationError( + "invalid_session_lifetime", "Idle timeout must not exceed the absolute session lifetime." + ) + if self.cookie_samesite not in {"lax", "strict"}: + 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.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): + raise ConfigurationError("wildcard_host_forbidden", "A wildcard or empty allowed host is not permitted.") + if environment.is_production: + self._validate_production() + + def _validate_production(self) -> None: + if not self.cookie_secure: + raise ConfigurationError("insecure_production_cookie", "Production requires Secure session cookies.") + if not self.require_csrf: + raise ConfigurationError("insecure_production_csrf", "Production requires CSRF enforcement.") + if self.repository_kind != PRODUCTION_REPOSITORY_KIND: + raise ConfigurationError( + "production_repository_missing", + "Production requires the PostgreSQL repository adapter, which is not part of this slice.", + ) + if self.repository_path is not None: + raise ConfigurationError( + "production_repository_path_forbidden", + "A file-backed repository path is not permitted in production.", + ) + + @property + def is_production(self) -> bool: + return self.environment.is_production + + @property + def is_development(self) -> bool: + return self.environment is Environment.DEVELOPMENT + + def with_development_path_defaults(self, *, root: Path | None = None) -> Settings: + """Return settings with a development repository path filled in.""" + + if self.repository_path is not None: + return self + base = root or DEVELOPMENT_DATA_ROOT + return replace(self, repository_path=(base / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve()) + + def public_fingerprint(self) -> dict[str, object]: + """Return non-secret settings suitable for a health response.""" + + return { + "environment": str(self.environment), + "repository": self.repository_kind, + "cookie_secure": self.cookie_secure, + "csrf_required": self.require_csrf, + "session_idle_seconds": self.session_idle_seconds, + "session_absolute_seconds": self.session_absolute_seconds, + "argon2": "argon2id", + } + + +def default_settings() -> Settings: + """Return the development defaults used by tests and local tooling.""" + + return Settings(environment=Environment.TEST, cookie_secure=False) + + +def suite_settings(*, secure_cookies: bool = False, require_csrf: bool = True) -> Settings: + """Return fast, isolated settings for the test suite. + + The Argon2 cost is reduced so the suite stays quick. Production parameters + are asserted separately by ``test_security.py``. + """ + + return replace( + default_settings(), + cookie_secure=secure_cookies, + require_csrf=require_csrf, + argon2_memory_cost_kib=8 * 1024, + argon2_time_cost=1, + argon2_parallelism=1, + argon2_hash_length=32, + argon2_salt_length=16, + password_min_length=14, + ) + + +def load_settings(environ: Mapping[str, str] | None = None) -> Settings: + """Build settings from environment variables, failing closed.""" + + env = os.environ if environ is None else environ + environment = _environment_from(env.get("UMBRA_ENV", str(Environment.DEVELOPMENT))) + repository_kind = env.get( + "UMBRATRANS_REPOSITORY", "memory" if not environment.is_production else PRODUCTION_REPOSITORY_KIND + ) + + repository_path: Path | None = None + raw_path = env.get("UMBRATRANS_REPOSITORY_PATH") + if raw_path: + repository_path = Path(raw_path).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") + pepper = raw_pepper.encode("utf-8") if raw_pepper else None + if pepper is not None and len(pepper) < MIN_PEPPER_BYTES: + raise ConfigurationError("weak_token_pepper", f"Token pepper must be at least {MIN_PEPPER_BYTES} bytes.") + + settings = Settings( + environment=environment, + session_cookie_name=env.get("UMBRATRANS_SESSION_COOKIE", DEFAULT_SESSION_COOKIE_NAME), + csrf_cookie_name=env.get("UMBRATRANS_CSRF_COOKIE", DEFAULT_CSRF_COOKIE_NAME), + csrf_header_name=env.get("UMBRATRANS_CSRF_HEADER", DEFAULT_CSRF_HEADER_NAME).lower(), + cookie_secure=_bool_from(env.get("UMBRATRANS_COOKIE_SECURE", "false"), name="UMBRATRANS_COOKIE_SECURE"), + require_csrf=_bool_from(env.get("UMBRATRANS_REQUIRE_CSRF", "true"), name="UMBRATRANS_REQUIRE_CSRF"), + session_idle_seconds=_int_from( + env.get("UMBRATRANS_SESSION_IDLE_SECONDS", str(DEFAULT_SESSION_IDLE_SECONDS)), + name="UMBRATRANS_SESSION_IDLE_SECONDS", + minimum=60, + ), + session_absolute_seconds=_int_from( + env.get("UMBRATRANS_SESSION_ABSOLUTE_SECONDS", str(DEFAULT_SESSION_ABSOLUTE_SECONDS)), + name="UMBRATRANS_SESSION_ABSOLUTE_SECONDS", + minimum=60, + ), + max_failed_login_attempts=_int_from( + env.get("UMBRATRANS_MAX_FAILED_LOGINS", str(DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS)), + name="UMBRATRANS_MAX_FAILED_LOGINS", + minimum=1, + ), + lockout_seconds=_int_from( + env.get("UMBRATRANS_LOCKOUT_SECONDS", str(DEFAULT_LOCKOUT_SECONDS)), + name="UMBRATRANS_LOCKOUT_SECONDS", + minimum=1, + ), + repository_kind=repository_kind, + repository_path=repository_path, + allowed_hosts=allowed_hosts, + token_pepper=pepper, + ) + if settings.repository_kind == "json" and settings.repository_path is None: + return settings.with_development_path_defaults() + return settings + + +__all__ = [ + "DEVELOPMENT_DATA_ROOT", + "PRODUCTION_REPOSITORY_KIND", + "REPOSITORY_KINDS", + "SERVICE_NAME", + "SERVICE_VERSION", + "Environment", + "Settings", + "default_settings", + "load_settings", + "suite_settings", +] diff --git a/umbratrans_web/dev_repository.py b/umbratrans_web/dev_repository.py new file mode 100644 index 0000000..6271438 --- /dev/null +++ b/umbratrans_web/dev_repository.py @@ -0,0 +1,342 @@ +"""Local JSON development repository. + +This store exists so the first slice can be exercised end to end without +PostgreSQL. It is deliberately unsuitable for anything else: + +* it refuses to run in production mode; +* the file is created with mode ``0600`` inside a ``0700`` directory, and both + are verified after every write; +* writes are atomic (temporary file in the same directory, ``fsync``, + ``os.replace``, directory ``fsync``) and a symlinked target is refused; +* it persists Argon2id hashes and session-token hashes only, exactly like the + PostgreSQL adapter would. No plaintext password is ever passed to it. + +The file is development data, ignored by Git, and is not a backup of anything. +""" + +from __future__ import annotations + +import json +import os +import stat +import tempfile +import threading +from collections.abc import Mapping +from datetime import datetime +from pathlib import Path +from typing import Any +from uuid import UUID + +from .config import Settings +from .errors import ConfigurationError, RepositoryError +from .models import ( + AccountStatus, + AuditEvent, + CredentialRecord, + CredentialType, + RoleName, + SessionRecord, + User, +) +from .repository import InMemoryAccountRepository + +SCHEMA_VERSION = 1 +DIRECTORY_MODE = 0o700 +FILE_MODE = 0o600 +MAX_FILE_BYTES = 8 * 1024 * 1024 + + +def _utc(value: datetime) -> str: + return value.isoformat() + + +def _parse_datetime(value: str) -> datetime: + return datetime.fromisoformat(value) + + +def _uuid(value: str | None) -> UUID | None: + return UUID(value) if value else None + + +class JsonDevelopmentRepository(InMemoryAccountRepository): + """In-memory repository with a restrictive, atomic JSON file on disk.""" + + def __init__(self, settings: Settings) -> None: + if settings.is_production: + raise ConfigurationError( + "development_repository_in_production", + "The JSON development repository must never be used in production.", + ) + if settings.repository_path is None: + raise ConfigurationError("repository_path_required", "A development repository path is required.") + super().__init__() + self._settings = settings + self._path = settings.repository_path + self._write_lock = threading.RLock() + self._load() + + # -- properties ---------------------------------------------------------- + + @property + def path(self) -> Path: + return self._path + + @property + def settings(self) -> Settings: + return self._settings + + # -- persistence --------------------------------------------------------- + + def _ensure_directory(self) -> None: + parent = self._path.parent + if parent.is_symlink(): + raise RepositoryError("symlink_refused", "The repository directory must not be a symlink.") + parent.mkdir(mode=DIRECTORY_MODE, parents=True, exist_ok=True) + current = stat.S_IMODE(parent.stat().st_mode) + if current & 0o077: + parent.chmod(DIRECTORY_MODE) + + def _load(self) -> None: + if not self._path.exists(): + return + if self._path.is_symlink(): + raise RepositoryError("symlink_refused", "The repository file must not be a symlink.") + size = self._path.stat().st_size + if size > MAX_FILE_BYTES: + raise RepositoryError("repository_too_large", "The development repository file is unexpectedly large.") + raw = self._path.read_text(encoding="utf-8") + if not raw.strip(): + return + try: + payload = json.loads(raw) + except json.JSONDecodeError as error: + raise RepositoryError("repository_corrupt", "The development repository file is not valid JSON.") from error + version = payload.get("schema_version") + if version != SCHEMA_VERSION: + raise RepositoryError( + "repository_schema_mismatch", + f"Unsupported development repository schema version: {version!r}.", + ) + with self._lock: + 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"]) + for item in payload.get("credentials", []): + credential = _credential_from_payload(item) + self._credentials[credential.id] = credential + for item in payload.get("sessions", []): + session = _session_from_payload(item) + self._sessions[session.id] = session + self._sessions_by_token[session.token_hash] = session.id + for item in payload.get("audit_events", []): + self._audit.append(_audit_from_payload(item)) + + def _payload(self) -> dict[str, Any]: + with self._lock: + return { + "schema_version": SCHEMA_VERSION, + "environment": str(self._settings.environment), + "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()], + "audit_events": [_audit_to_payload(item) for item in self._audit], + } + + def flush(self) -> None: + """Write the current state atomically with restrictive permissions.""" + + with self._write_lock: + self._ensure_directory() + payload = json.dumps(self._payload(), indent=2, sort_keys=True) + handle, temporary_name = tempfile.mkstemp(dir=str(self._path.parent), prefix=".dev-repo-", suffix=".tmp") + temporary = Path(temporary_name) + try: + with os.fdopen(handle, "w", encoding="utf-8") as stream: + stream.write(payload) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, FILE_MODE) + os.replace(temporary, self._path) + except OSError as error: + temporary.unlink(missing_ok=True) + raise RepositoryError( + "repository_write_failed", "The development repository could not be written." + ) from error + directory_fd = os.open(str(self._path.parent), os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + self._verify_permissions() + + def _verify_permissions(self) -> None: + file_mode = stat.S_IMODE(self._path.stat().st_mode) + if file_mode & 0o077: + self._path.chmod(FILE_MODE) + file_mode = stat.S_IMODE(self._path.stat().st_mode) + if file_mode & 0o077: + raise RepositoryError("insecure_permissions", "The development repository file is not private.") + + # -- write-through hooks ------------------------------------------------- + + def create_user(self, user: User) -> User: + result = super().create_user(user) + self.flush() + return result + + def save_user(self, user: User) -> User: + result = super().save_user(user) + self.flush() + return result + + def save_credential(self, credential: CredentialRecord) -> CredentialRecord: + result = super().save_credential(credential) + self.flush() + return result + + def create_session(self, session: SessionRecord) -> SessionRecord: + result = super().create_session(session) + self.flush() + return result + + def save_session(self, session: SessionRecord) -> SessionRecord: + result = super().save_session(session) + self.flush() + return result + + def append_audit_event(self, event: AuditEvent) -> AuditEvent: + result = super().append_audit_event(event) + self.flush() + return result + + +def _user_to_payload(user: User) -> dict[str, Any]: + return { + "id": str(user.id), + "username": user.username, + "username_key": user.username_key, + "display_name": user.display_name, + "role": str(user.role), + "status": str(user.status), + "organization_id": str(user.organization_id) if user.organization_id else None, + "email": user.email, + "created_at": _utc(user.created_at) if user.created_at else None, + "updated_at": _utc(user.updated_at) if user.updated_at else None, + "version": user.version, + } + + +def _user_from_payload(payload: Mapping[str, Any]) -> User: + return User( + id=UUID(payload["id"]), + username=payload["username"], + username_key=payload["username_key"], + display_name=payload["display_name"], + role=RoleName(payload["role"]), + status=AccountStatus(payload["status"]), + organization_id=_uuid(payload.get("organization_id")), + email=payload.get("email"), + 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 _credential_to_payload(credential: CredentialRecord) -> dict[str, Any]: + """Serialize a credential. Only the Argon2id hash is written, never plaintext.""" + + return { + "id": str(credential.id), + "user_id": str(credential.user_id), + "credential_type": str(credential.credential_type), + "password_hash": credential.password_hash, + "must_change_password": credential.must_change_password, + "failed_login_count": credential.failed_login_count, + "locked_until": _utc(credential.locked_until) if credential.locked_until else None, + "last_login_at": _utc(credential.last_login_at) if credential.last_login_at else None, + "password_changed_at": _utc(credential.password_changed_at) if credential.password_changed_at else None, + "created_at": _utc(credential.created_at) if credential.created_at else None, + "updated_at": _utc(credential.updated_at) if credential.updated_at else None, + } + + +def _credential_from_payload(payload: Mapping[str, Any]) -> CredentialRecord: + return CredentialRecord( + id=UUID(payload["id"]), + user_id=UUID(payload["user_id"]), + credential_type=CredentialType(payload["credential_type"]), + password_hash=payload.get("password_hash"), + must_change_password=bool(payload.get("must_change_password", False)), + failed_login_count=int(payload.get("failed_login_count", 0)), + locked_until=_parse_datetime(payload["locked_until"]) if payload.get("locked_until") else None, + last_login_at=_parse_datetime(payload["last_login_at"]) if payload.get("last_login_at") else None, + password_changed_at=( + _parse_datetime(payload["password_changed_at"]) if payload.get("password_changed_at") else None + ), + 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, + ) + + +def _session_to_payload(session: SessionRecord) -> dict[str, Any]: + """Serialize a session. Only the token hash is written, never the token.""" + + return { + "id": str(session.id), + "user_id": str(session.user_id), + "token_hash": session.token_hash, + "created_at": _utc(session.created_at), + "last_seen_at": _utc(session.last_seen_at), + "idle_deadline_at": _utc(session.idle_deadline_at), + "absolute_deadline_at": _utc(session.absolute_deadline_at), + "revoked_at": _utc(session.revoked_at) if session.revoked_at else None, + "revoked_reason": session.revoked_reason, + "mfa_verified_at": _utc(session.mfa_verified_at) if session.mfa_verified_at else None, + } + + +def _session_from_payload(payload: Mapping[str, Any]) -> SessionRecord: + return SessionRecord( + id=UUID(payload["id"]), + user_id=UUID(payload["user_id"]), + token_hash=payload["token_hash"], + created_at=_parse_datetime(payload["created_at"]), + last_seen_at=_parse_datetime(payload["last_seen_at"]), + idle_deadline_at=_parse_datetime(payload["idle_deadline_at"]), + absolute_deadline_at=_parse_datetime(payload["absolute_deadline_at"]), + revoked_at=_parse_datetime(payload["revoked_at"]) if payload.get("revoked_at") else None, + revoked_reason=payload.get("revoked_reason"), + mfa_verified_at=_parse_datetime(payload["mfa_verified_at"]) if payload.get("mfa_verified_at") else None, + ) + + +def _audit_to_payload(event: AuditEvent) -> dict[str, Any]: + return { + "id": str(event.id), + "occurred_at": _utc(event.occurred_at), + "action": event.action, + "outcome": event.outcome, + "actor_user_id": str(event.actor_user_id) if event.actor_user_id else None, + "target_user_id": str(event.target_user_id) if event.target_user_id else None, + "organization_id": str(event.organization_id) if event.organization_id else None, + "request_id": event.request_id, + "detail": dict(event.detail), + } + + +def _audit_from_payload(payload: Mapping[str, Any]) -> AuditEvent: + return AuditEvent( + id=UUID(payload["id"]), + occurred_at=_parse_datetime(payload["occurred_at"]), + action=payload["action"], + outcome=payload["outcome"], + actor_user_id=_uuid(payload.get("actor_user_id")), + target_user_id=_uuid(payload.get("target_user_id")), + organization_id=_uuid(payload.get("organization_id")), + request_id=payload.get("request_id"), + detail=dict(payload.get("detail", {})), + ) + + +__all__ = ["FILE_MODE", "SCHEMA_VERSION", "JsonDevelopmentRepository"] diff --git a/umbratrans_web/errors.py b/umbratrans_web/errors.py new file mode 100644 index 0000000..85b3534 --- /dev/null +++ b/umbratrans_web/errors.py @@ -0,0 +1,186 @@ +"""Domain errors for the UmbraTrans web account slice. + +Every error carries a stable, non-secret ``code`` plus a short message. The +messages are deliberately generic: an HTTP client must never learn whether a +username exists, which authentication mechanism an account uses, or why a +login failed. The precise (still sanitized) reason is written to the audit +trail instead, where an authorized administrator can review it. + +Errors never carry a password, a hash, or a session token. Attributes that +could hold secret material are therefore absent by construction. +""" + +from __future__ import annotations + + +class UmbraWebError(Exception): + """Base class for all expected, handled failures.""" + + code: str = "umbratrans_error" + http_status: int = 500 + message: str = "The request could not be completed." + + def __init__(self, code: str | None = None, message: str | None = None) -> None: + self.code = code or type(self).code + self.message = message or type(self).message + super().__init__(self.message) + + +class ConfigurationError(UmbraWebError): + """The process configuration is unusable or unsafe. + + Raised instead of starting when production defaults would be insecure. + The message is operator-facing and is never returned to an HTTP client in + a 5xx response. + """ + + code = "configuration_error" + http_status = 500 + message = "The service is not configured correctly." + + +class InputError(UmbraWebError): + """Caller-supplied input failed validation. + + The message may describe the constraint (for example a username charset + rule) but never echoes the rejected value. + """ + + code = "invalid_request" + http_status = 422 + message = "The request payload is invalid." + + +class PasswordPolicyError(InputError): + """A candidate password failed the local password policy. + + ``unmet_requirements`` names policy rules only, never the candidate value. + """ + + code = "password_policy" + message = "The password does not meet the required policy." + + def __init__(self, unmet_requirements: tuple[str, ...] = ()) -> None: + super().__init__() + self.unmet_requirements = unmet_requirements + + +class AuthenticationRequiredError(UmbraWebError): + """No valid server-side session was presented.""" + + code = "authentication_required" + http_status = 401 + message = "Authentication is required." + + +class InvalidCredentialsError(UmbraWebError): + """Generic authentication failure. + + Used for every rejected login so that responses cannot be used to + enumerate accounts, distinguish locked or suspended accounts, or detect + which authentication mechanism an account uses. + """ + + code = "invalid_credentials" + http_status = 401 + message = "The username or password is incorrect." + + +class PasswordChangeRequiredError(UmbraWebError): + """The session exists but may only be used to change the first password.""" + + code = "password_change_required" + http_status = 403 + message = "The temporary password must be changed before continuing." + + +class ForbiddenError(UmbraWebError): + """The principal is authenticated but not allowed to perform the action.""" + + code = "forbidden" + http_status = 403 + message = "The action is not permitted for this account." + + +class NotFoundError(UmbraWebError): + code = "not_found" + http_status = 404 + message = "The requested record was not found." + + +class ConflictError(UmbraWebError): + """The action conflicts with existing state (duplicate key, last admin).""" + + code = "conflict" + http_status = 409 + message = "The request conflicts with existing state." + + +class StateTransitionError(ConflictError): + """The requested account status change is not allowed from the current state.""" + + code = "invalid_state_transition" + message = "The account is not in a state that allows this change." + + +class EnrollmentRefusedError(UmbraWebError): + """An enrollment mode was requested through a path that may not use it. + + Temporary local passwords must come from the operator CLI path, never from + a JSON request body. + """ + + code = "enrollment_mode_refused" + http_status = 400 + message = "The requested enrollment mode is not available through this path." + + +class CsrfError(UmbraWebError): + """A cookie-authenticated state change arrived without a valid CSRF token.""" + + code = "csrf_token_invalid" + http_status = 403 + message = "The request could not be verified. Reload the page and retry." + + +class RepositoryError(UmbraWebError): + """The repository could not complete an operation. + + The underlying cause is never included: it may quote a query parameter. + """ + + code = "repository_error" + http_status = 500 + message = "The data store could not complete the request." + + +class OperatorRefusalError(Exception): + """The operator CLI refused to run. + + Used for production-mode refusal and non-interactive terminals. Carries no + secret material. + """ + + def __init__(self, code: str, message: str) -> None: + self.code = code + self.message = message + super().__init__(message) + + +__all__ = [ + "AuthenticationRequiredError", + "ConfigurationError", + "ConflictError", + "CsrfError", + "EnrollmentRefusedError", + "ForbiddenError", + "InputError", + "InvalidCredentialsError", + "NotFoundError", + "OperatorRefusalError", + "PasswordChangeRequiredError", + "PasswordPolicyError", + "RepositoryError", + "StateTransitionError", + "UmbraWebError", +] diff --git a/umbratrans_web/migrations/0001_phase1_identity.sql b/umbratrans_web/migrations/0001_phase1_identity.sql new file mode 100644 index 0000000..c7d2e99 --- /dev/null +++ b/umbratrans_web/migrations/0001_phase1_identity.sql @@ -0,0 +1,351 @@ +-- ===================================================================== +-- UmbraTrans phase-1 identity migration (users.username, user_credentials, +-- additive session columns) +-- ===================================================================== +-- +-- STATUS: PROPOSED / NOT APPLIED / NOT APPROVED +-- +-- This file has never been executed against any database. It was written for +-- review only. Do not run it until every gate in "PRE-FLIGHT GATES" below is +-- 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) +-- +-- ADD COLUMN users.username (nullable, unique, citext) +-- ADD TABLE user_credentials (local_password / oidc credentials) +-- 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) +-- +-- DELIBERATELY NOT IN THIS MIGRATION +-- +-- * 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 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). +-- * No MFA enforcement. mfa_verified_at is added for forward compatibility +-- and stays NULL. +-- * No OIDC subject column. The provider-subject mapping belongs to a later, +-- separately reviewed migration. +-- +-- SAFETY RULES FOR THE REVIEWER AND THE OPERATOR +-- +-- 1. Read the whole file, including the comments, before running anything. +-- 2. Take and restore-test a custom-format logical backup first +-- (pg_dump -Fc, restore into a scratch database, verify row counts). +-- 3. Run the PRE-FLIGHT VERIFICATION block and save its output. If any +-- assumption below is false, STOP and produce a new migration. +-- 4. The statements run in one transaction with a short lock_timeout and +-- statement_timeout so the live application cannot be blocked behind an +-- open transaction. If the timeout fires, the transaction rolls back. +-- 5. users.username is added as NULLABLE on purpose: a NOT NULL column with +-- no backfill would fail or force an invented identifier. +-- 6. user_credentials is created empty. No credential is inserted here. +-- Credentials are created by the application through the reviewed +-- service path only. +-- 7. The unique constraint on users.username is added as a UNIQUE INDEX +-- (CREATE UNIQUE INDEX, then ATTACH as a constraint) so the build does +-- not hold an ACCESS EXCLUSIVE lock for the duration of the scan. +-- 8. The sessions indexes are plain CREATE INDEX. For a large sessions +-- table, run them separately with CREATE INDEX CONCURRENTLY *outside* +-- this transaction, one statement per session, after verifying the table +-- size. See the note above section 4. +-- 9. Do not run this file with a superuser. Use the dedicated migration +-- role, and record the role used in the change ticket. +-- 10. RLS and GRANT statements are deliberately left as reviewed COMMENTs in +-- section 6: the production policy helper functions and role names are +-- unverified, and inventing them would be a BLOCKER, not a shortcut. +-- +-- BACKUP/RESTORE GATE +-- [ ] pg_dump -Fc of umbracontrol completed and checksummed +-- [ ] restore into an isolated scratch database succeeded +-- [ ] restored row counts match for users, sessions, organizations, projects +-- [ ] rollback file reviewed and rehearsed against the scratch database +-- +-- RLS TEST GATE (must pass before approval) +-- [ ] two organizations, two projects, two users each +-- [ ] a user in org A cannot read or write org B rows +-- [ ] a session row is readable only by its owner +-- [ ] the application role cannot escalate by setting a GUC +-- [ ] FORCE ROW LEVEL SECURITY is proven, not merely enabled +-- +-- --------------------------------------------------------------------- +-- PRE-FLIGHT VERIFICATION (run read-only, save the output with the ticket) +-- --------------------------------------------------------------------- +-- +-- SELECT current_database(), current_user, version(); +-- +-- -- users must exist with a uuid primary key named id +-- SELECT column_name, data_type, is_nullable +-- FROM information_schema.columns +-- WHERE table_schema = 'public' AND table_name = 'users' +-- ORDER BY ordinal_position; +-- +-- -- sessions must exist; record its current columns +-- SELECT column_name, data_type, is_nullable +-- FROM information_schema.columns +-- WHERE table_schema = 'public' AND table_name = 'sessions' +-- ORDER BY ordinal_position; +-- +-- -- citext availability for the unique username +-- SELECT name, default_version, installed_version FROM pg_available_extensions +-- WHERE name = 'citext'; +-- +-- -- current RLS posture of the tables this file touches +-- SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity +-- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +-- WHERE n.nspname = 'public' AND c.relname IN ('users','sessions'); +-- +-- -- existing policies, so the additive columns inherit known-good rules +-- SELECT tablename, policyname, cmd, roles, qual, with_check +-- FROM pg_policies WHERE schemaname = 'public' AND tablename IN ('users','sessions'); +-- +-- -- confirm nothing named user_credentials already exists +-- SELECT to_regclass('public.user_credentials') AS user_credentials; +-- +-- -- row counts used as the post-apply baseline +-- SELECT (SELECT count(*) FROM users) AS users, (SELECT count(*) FROM sessions) AS sessions; +-- +-- --------------------------------------------------------------------- +-- 0. Transaction guard rails +-- --------------------------------------------------------------------- + +BEGIN; + +SET LOCAL lock_timeout = '3s'; +SET LOCAL statement_timeout = '60s'; +SET LOCAL idle_in_transaction_session_timeout = '30s'; + +-- --------------------------------------------------------------------- +-- 1. users.username +-- --------------------------------------------------------------------- +-- Assumptions: users.id is uuid; no existing column named username. +-- Existing rows are left with NULL: the new login identifier is introduced +-- only through an explicit, operator-approved mapping. +-- +-- citext gives case-insensitive uniqueness, matching the application rule +-- that "Umbraadmin" and "umbraadmin" are the same account. If the extension +-- is unavailable, stop and use a lower(...) unique index instead; do not +-- substitute a plain text column with case-sensitive uniqueness. + +CREATE EXTENSION IF NOT EXISTS citext; + +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS username citext NULL; + +COMMENT ON COLUMN public.users.username IS + 'Case-insensitive canonical login identifier. NULL for accounts that have not been mapped yet.'; + +-- Build the unique index first (it takes a SHARE lock, not ACCESS EXCLUSIVE), +-- then attach it as a constraint so the rule is visible to the planner and to +-- future migrations. NULLs do not conflict, which is what allows the staged +-- backfill to run before the column becomes mandatory. +CREATE UNIQUE INDEX IF NOT EXISTS users_username_key + ON public.users (username); + +ALTER TABLE public.users + ADD CONSTRAINT users_username_key + UNIQUE USING INDEX users_username_key; + +-- --------------------------------------------------------------------- +-- 2. user_credentials +-- --------------------------------------------------------------------- +-- One credential row per user in phase 1. The unique user_id is a deliberate +-- phase-1 constraint (one authentication mechanism at a time); relaxing it is +-- a later, separately reviewed change. +-- +-- password_hash holds an Argon2id PHC string only, e.g. the argon2id variant +-- with m=65536, t=3, p=4. No plaintext password, temporary password, or +-- password-recovery value is ever stored in this table or anywhere else. + +CREATE TABLE IF NOT EXISTS public.user_credentials ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL UNIQUE REFERENCES public.users (id) ON DELETE CASCADE, + credential_type text NOT NULL, + password_hash text NULL, + must_change_password boolean NOT NULL DEFAULT false, + failed_login_count integer NOT NULL DEFAULT 0, + locked_until timestamptz NULL, + last_login_at timestamptz NULL, + password_changed_at timestamptz NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT user_credentials_type_check + CHECK (credential_type IN ('local_password', 'oidc')), + CONSTRAINT user_credentials_local_requires_hash + CHECK (credential_type <> 'local_password' OR password_hash IS NOT NULL), + CONSTRAINT user_credentials_oidc_forbids_hash + CHECK (credential_type <> 'oidc' OR password_hash IS NULL), + CONSTRAINT user_credentials_failed_login_count_check + CHECK (failed_login_count >= 0), + CONSTRAINT user_credentials_password_hash_format + CHECK (password_hash IS NULL OR password_hash ~ '^\$argon2(id|i|d)\$') +); + +COMMENT ON TABLE public.user_credentials IS + 'Authentication credentials, kept separate from the user profile. Rows are never returned by the API.'; +COMMENT ON COLUMN public.user_credentials.password_hash IS + 'Argon2id PHC hash. Never returned by an endpoint, never logged, never selected into a report.'; +COMMENT ON COLUMN public.user_credentials.must_change_password IS + 'True for temporary-password enrollment. Cleared only after a successful first-login change.'; + +-- Login path and lockout scans. Both are narrow and index-only. +CREATE INDEX IF NOT EXISTS user_credentials_type_idx + ON public.user_credentials (credential_type); + +CREATE INDEX IF NOT EXISTS user_credentials_locked_until_idx + ON public.user_credentials (locked_until) + WHERE locked_until IS NOT NULL; + +-- updated_at is maintained by the application on every write. If the +-- production schema already has a generic set_updated_at() trigger helper, +-- attach it here instead; do not assume one exists. +-- CREATE TRIGGER user_credentials_set_updated_at +-- BEFORE UPDATE ON public.user_credentials +-- FOR EACH ROW EXECUTE FUNCTION public.set_updated_at(); + +-- --------------------------------------------------------------------- +-- 3. sessions: additive columns +-- --------------------------------------------------------------------- +-- The session bearer token is unchanged: it stays random, and only its hash +-- is stored. The three columns below are what the phase-1 identity slice +-- needs; no existing column is dropped, renamed, or retyped, so an older +-- application build keeps working against this schema. + +ALTER TABLE public.sessions + ADD COLUMN IF NOT EXISTS mfa_verified_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS idle_deadline_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS last_seen_at timestamptz NULL; + +COMMENT ON COLUMN public.sessions.mfa_verified_at IS + 'When the session completed MFA. NULL in phase 1; no MFA is enforced yet.'; +COMMENT ON COLUMN public.sessions.idle_deadline_at IS + 'Absolute idle deadline for the session. Sessions past it are rejected and revoked.'; +COMMENT ON COLUMN public.sessions.last_seen_at IS + 'Last time the session was used, for idle accounting and operator review.'; + +-- Backfill last_seen_at from an existing column only if one can be verified +-- in the pre-flight output. Uncomment exactly one source after confirming the +-- column exists; otherwise leave NULL and let the next write populate it. +-- UPDATE public.sessions SET last_seen_at = now() WHERE last_seen_at IS NULL; + +-- Set idle deadlines for live sessions so the new column is never NULL for +-- an active row. The duration must match the reviewed application setting +-- (default 30 minutes idle, 12 hours absolute). +UPDATE public.sessions + SET idle_deadline_at = now() + interval '30 minutes' + WHERE idle_deadline_at IS NULL + AND revoked_at IS NULL; + +ALTER TABLE public.sessions + ALTER COLUMN last_seen_at SET DEFAULT now(); + +-- Make last_seen_at NOT NULL only after the backfill is verified; a partial +-- backfill would fail here, which is the intended safety check. +-- ALTER TABLE public.sessions ALTER COLUMN last_seen_at SET NOT NULL; + +CREATE INDEX IF NOT EXISTS sessions_user_id_idx + ON public.sessions (user_id); + +CREATE INDEX IF NOT EXISTS sessions_idle_deadline_at_idx + ON public.sessions (idle_deadline_at) + WHERE revoked_at IS NULL; + +-- --------------------------------------------------------------------- +-- 4. 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 +-- existing policy conventions are UNVERIFIED in this workspace. Writing +-- executable GRANT/RLS against guessed identifiers is a BLOCKER. +-- +-- Required before approval: +-- * Confirm the name of the "current user" helper (for example +-- app.current_user_id() or a request-scoped GUC) and prove it cannot be +-- set by a client-controlled value. +-- * Confirm the migration role and the application role names. +-- * Confirm the existing users/sessions policies so the additive columns +-- inherit the same guarantees. +-- +-- Policies to add on user_credentials once the helper is verified: +-- +-- ALTER TABLE public.user_credentials ENABLE ROW LEVEL SECURITY; +-- ALTER TABLE public.user_credentials FORCE ROW LEVEL SECURITY; +-- +-- -- a user reads and updates only their own credential +-- CREATE POLICY user_credentials_self_read ON public.user_credentials +-- FOR SELECT USING (user_id = app.current_user_id()); +-- CREATE POLICY user_credentials_self_update ON public.user_credentials +-- FOR UPDATE USING (user_id = app.current_user_id()); +-- +-- -- only the administrator service path inserts credentials, and it does so +-- -- as a privileged role that bypasses the per-user policies +-- +-- Grants to review (role names must be confirmed, not guessed): +-- +-- REVOKE ALL ON public.user_credentials FROM PUBLIC; +-- GRANT SELECT, INSERT, UPDATE ON public.user_credentials TO ; +-- -- No DELETE for the application role: credential removal is an +-- -- administrative, audited operation. +-- -- No column-level GRANT alternative is meaningful here: the application +-- -- must read password_hash to verify a local password. The control is that +-- -- no endpoint selects it, which the API tests assert. +-- +-- On sessions, the new columns need no new policy: they inherit the existing +-- per-session row policies. Adding a policy that keys on a new column before +-- the existing ones are verified risks weakening them. +-- +-- --------------------------------------------------------------------- +-- 5. POST-APPLY VERIFICATION (run immediately after COMMIT) +-- --------------------------------------------------------------------- +-- +-- \d+ public.users +-- \d+ public.user_credentials +-- \d+ public.sessions +-- +-- -- the new unique rule is enforced and case-insensitive +-- SELECT indexname, indexdef FROM pg_indexes +-- WHERE tablename = 'users' AND indexname = 'users_username_key'; +-- +-- -- no duplicate, non-null usernames can exist +-- SELECT lower(username::text) AS key, count(*) +-- FROM public.users WHERE username IS NOT NULL +-- GROUP BY 1 HAVING count(*) > 1; +-- +-- -- the table is empty: this migration must not invent credentials +-- SELECT count(*) FROM public.user_credentials; +-- +-- -- no live session is left without a deadline +-- SELECT count(*) FROM public.sessions +-- WHERE revoked_at IS NULL AND idle_deadline_at IS NULL; +-- +-- -- RLS state matches the reviewed expectation +-- SELECT c.relname, c.relrowsecurity, c.relforcerowsecurity +-- FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace +-- WHERE n.nspname = 'public' AND c.relname = 'user_credentials'; +-- +-- -- the application role can verify a credential but cannot delete one +-- -- (run as the application role, not the migration role) +-- SELECT has_table_privilege('', 'public.user_credentials', 'SELECT'); +-- SELECT has_table_privilege('', 'public.user_credentials', 'DELETE'); +-- +-- --------------------------------------------------------------------- +-- 6. ROLLBACK +-- --------------------------------------------------------------------- +-- Rehearse first, on the scratch database. Rolling back DESTROYS credential +-- rows: any account created after the migration would lose its only +-- authentication material and could not sign in until it is re-enrolled. +-- +-- Take a fresh backup immediately before rolling back, then use +-- 0001_phase1_identity_rollback.sql in the same session settings +-- (lock_timeout, statement_timeout). The rollback is not online-safe for a +-- busy sessions table: prefer a maintenance window. +-- +-- If the migration failed mid-transaction, nothing was applied: the +-- transaction rolled back and this file can be corrected and re-reviewed. + +COMMIT; diff --git a/umbratrans_web/migrations/0001_phase1_identity_rollback.sql b/umbratrans_web/migrations/0001_phase1_identity_rollback.sql new file mode 100644 index 0000000..44a723f --- /dev/null +++ b/umbratrans_web/migrations/0001_phase1_identity_rollback.sql @@ -0,0 +1,111 @@ +-- ===================================================================== +-- Rollback for: UmbraTrans phase-1 identity migration +-- (users.username, user_credentials, additive session columns) +-- ===================================================================== +-- +-- STATUS: PROPOSED / NOT APPLIED / NOT APPROVED +-- +-- This file has never been executed against any database. +-- +-- DESTRUCTIVE. Read the warnings before running anything. +-- +-- * DROP TABLE public.user_credentials DESTROYS every credential row that +-- exists at rollback time. Accounts created or re-enrolled after the +-- 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 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. +-- * Dropping the additive session columns discards MFA, idle deadline, and +-- last-seen history. An application build that still writes those columns +-- will fail after the rollback, so roll the application back first. +-- +-- PREREQUISITES +-- [ ] Forward migration 0001 confirmed applied (see its section 5 output) +-- [ ] Fresh pg_dump -Fc backup taken and checksummed AFTER the forward +-- migration, so the post-migration state is recoverable +-- [ ] Restore rehearsed on a scratch database +-- [ ] Application rolled back to a build that does not use the new columns +-- [ ] Maintenance window agreed (the sessions DDL takes ACCESS EXCLUSIVE) +-- +-- --------------------------------------------------------------------- +-- 0. Transaction guard rails (same limits as the forward migration) +-- --------------------------------------------------------------------- + +BEGIN; + +SET LOCAL lock_timeout = '3s'; +SET LOCAL statement_timeout = '60s'; +SET LOCAL idle_in_transaction_session_timeout = '30s'; + +-- --------------------------------------------------------------------- +-- 1. Record what will be lost, before it is lost +-- --------------------------------------------------------------------- + +-- 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; + +-- --------------------------------------------------------------------- +-- 2. Additive session columns +-- --------------------------------------------------------------------- +-- Drop the indexes first; they are not required to drop the columns, but +-- removing them explicitly keeps the rollback symmetric with the forward file. + +DROP INDEX IF EXISTS public.sessions_idle_deadline_at_idx; +DROP INDEX IF EXISTS public.sessions_user_id_idx; + +ALTER TABLE public.sessions + DROP COLUMN IF EXISTS idle_deadline_at, + DROP COLUMN IF EXISTS mfa_verified_at, + DROP COLUMN IF EXISTS last_seen_at; + +-- --------------------------------------------------------------------- +-- 3. user_credentials +-- --------------------------------------------------------------------- +-- The table and its constraints disappear with it. Any credential that is not +-- recoverable from a backup is gone: re-provision affected accounts through +-- umbratrans-bootstrap-admin (operator path) after the rollback is verified. + +DROP TABLE IF EXISTS public.user_credentials; + +-- --------------------------------------------------------------------- +-- 4. users.username +-- --------------------------------------------------------------------- +-- Values are preserved by default. Uncomment the second statement only when +-- the mapping must be discarded as well. + +ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_username_key; +DROP INDEX IF EXISTS public.users_username_key; + +-- ALTER TABLE public.users DROP COLUMN IF EXISTS username; + +-- citext is left installed: dropping an extension can cascade to dependent +-- objects, and an unused extension is harmless. Remove it only in a separate, +-- explicitly reviewed change. + +-- --------------------------------------------------------------------- +-- 5. POST-ROLLBACK VERIFICATION +-- --------------------------------------------------------------------- +-- +-- \d+ public.users +-- \d+ public.sessions +-- +-- SELECT to_regclass('public.user_credentials') AS user_credentials; -- NULL +-- 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 +-- +-- -- 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') +-- ORDER BY table_name, ordinal_position; +-- +-- -- re-run the control-plane integrity checks that were captured before the +-- -- forward migration and compare row counts for organizations, projects, +-- -- roles, permissions, user_roles, invitations, and audit_events +-- +-- Roll forward again only after a new review of 0001_phase1_identity.sql. + +COMMIT; diff --git a/umbratrans_web/migrations/README.md b/umbratrans_web/migrations/README.md new file mode 100644 index 0000000..20926fb --- /dev/null +++ b/umbratrans_web/migrations/README.md @@ -0,0 +1,70 @@ +# Phase-1 identity migrations + +**Status: proposed, never applied, awaiting review.** + +The SQL in this directory has not been executed against any database, including +any development database. It exists so a reviewer can assess the phase-1 +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_rollback.sql` | Destructive rollback with prerequisites and verification queries | + +## What the forward migration changes + +Exactly three 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. +2. `user_credentials` — one credential row per user, with + `credential_type` in (`local_password`, `oidc`), an Argon2id `password_hash` + 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`, + `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. + +## 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. + +- Does `users` have a `uuid` primary key named `id`, and no existing column + named `username`? +- 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`? +- What are the migration role and application role names? +- Is `citext` available to the migration role? If not, use a `lower(...)` + unique index instead of a case-sensitive text column. +- Does a generic `set_updated_at()` trigger helper exist, or does the + application maintain `updated_at`? +- Is `sessions` large enough that `CREATE INDEX` must be replaced with + `CREATE INDEX CONCURRENTLY` outside the transaction? +- Which existing `sessions` column should backfill `last_seen_at`, if any? + +## Gates that must pass before approval + +- Logical backup taken, checksummed, and restore-tested on a scratch database. +- Migration role and application role privileges verified. +- RLS tests covering two organizations and two projects, including + `FORCE ROW LEVEL SECURITY` and a client-controlled-GUC escalation attempt. +- Account-management tests passing against an isolated PostgreSQL database + with the PostgreSQL adapter implemented. +- Rollback rehearsed on the scratch database. + +Until then, production PostgreSQL stays untouched and the application runs +against a development repository. + +## Data classification + +- `user_credentials.password_hash` — authentication secret (hash only). Never + selected into a report, an endpoint response, or a log. +- `sessions.token_hash` — authentication secret (hash only), unchanged by this + migration. +- `users.username` and `users.email` — personal data. Included in data-export + and retention reviews for the control plane. diff --git a/umbratrans_web/models.py b/umbratrans_web/models.py new file mode 100644 index 0000000..1bfede2 --- /dev/null +++ b/umbratrans_web/models.py @@ -0,0 +1,377 @@ +"""Domain models for the UmbraTrans phase-1 identity slice. + +The records here mirror the reviewed but deliberately **unapplied** migration in +``umbratrans_web/migrations/0001_phase1_identity.sql``. Nothing in this module +talks to PostgreSQL; the in-memory and local JSON development repositories +implement the same shapes. + +Secret handling rules enforced by this module: + +* A credential stores an Argon2id hash or nothing at all. A ``local_password`` + credential must carry a hash; an ``oidc`` credential must not. +* ``password_hash`` and ``token_hash`` are ``repr=False`` so that logging an + object, an exception payload, or a dataclass dump cannot disclose them. +* :class:`AccountView` is the only account representation that may reach the + API. It carries a :class:`CredentialSummary` and never a hash. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum +from types import MappingProxyType +from typing import Any, Mapping +from uuid import UUID + +from .errors import InputError + +MAX_USERNAME_LENGTH = 64 +MIN_USERNAME_LENGTH = 3 +MAX_DISPLAY_NAME_LENGTH = 120 +MAX_EMAIL_LENGTH = 254 +MAX_REASON_LENGTH = 200 + +_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]") + +#: 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. +ALLOWED_AUDIT_DETAIL_KEYS = frozenset( + { + "credential_type", + "enrollment_mode", + "lock_state", + "must_change_password", + "organization_id", + "previous_status", + "reason", + "revoked_sessions", + "role", + "source", + "status", + "username", + } +) + +#: Roles that may call the ``/api/uadmin`` surface. +ADMIN_ROLES = frozenset({"platform_admin", "organization_admin"}) + + +class CredentialType(StrEnum): + """Authentication mechanism bound to a canonical user record.""" + + LOCAL_PASSWORD = "local_password" + OIDC = "oidc" + + +class EnrollmentMode(StrEnum): + """How an account obtains its first credential. + + ``TEMPORARY_PASSWORD`` yields a credential that must be replaced on first + login. It is reached either through the operator CLI, which supplies the + password from a hidden prompt, or through + :func:`umbratrans_web.service.AccountService.provision_admin_temporary_password`, + which generates it in process. ``OIDC`` stores no local password. See + :func:`umbratrans_web.service.AccountService.create_account`. + """ + + TEMPORARY_PASSWORD = "temporary_password" + OIDC = "oidc" + + +class AccountStatus(StrEnum): + ACTIVE = "active" + SUSPENDED = "suspended" + DEACTIVATED = "deactivated" + + +class RoleName(StrEnum): + PLATFORM_ADMIN = "platform_admin" + ORGANIZATION_ADMIN = "organization_admin" + MEMBER = "member" + + +#: 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( + { + AccountStatus.ACTIVE: frozenset({AccountStatus.SUSPENDED, AccountStatus.DEACTIVATED}), + AccountStatus.SUSPENDED: frozenset({AccountStatus.ACTIVE, AccountStatus.DEACTIVATED}), + AccountStatus.DEACTIVATED: frozenset({AccountStatus.ACTIVE}), + } +) + + +def normalize_username(value: str) -> str: + """Return the canonical stored form of a username. + + The value is trimmed but otherwise preserved; case-insensitive comparison + is handled separately by :func:`username_key` so that stored identifiers + keep the operator's spelling (matching the ``citext`` behaviour of the + reviewed migration). + """ + + text = value.strip() + if not _USERNAME_RE.fullmatch(text): + raise InputError( + "invalid_username", + "Username must be 3-64 characters using letters, digits, dot, underscore or hyphen.", + ) + return text + + +def username_key(value: str) -> str: + """Return the case-insensitive lookup key for a username.""" + + return normalize_username(value).casefold() + + +def normalize_display_name(value: str) -> str: + text = " ".join(value.split()) + if not text or len(text) > MAX_DISPLAY_NAME_LENGTH or _CONTROL_CHARS_RE.search(text): + raise InputError("invalid_display_name", "Display name is missing or too long.") + return text + + +def normalize_email(value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + if not text: + return None + if len(text) > MAX_EMAIL_LENGTH or not _EMAIL_RE.fullmatch(text): + raise InputError("invalid_email", "Email address is not valid.") + return text.lower() + + +def normalize_reason(value: str | None) -> str | None: + if value is None: + return None + text = " ".join(value.split()) + if not text: + return None + if len(text) > MAX_REASON_LENGTH or _CONTROL_CHARS_RE.search(text): + raise InputError("invalid_reason", "Reason is missing or too long.") + return text + + +def utc_isoformat(value: datetime) -> str: + """Serialize an aware datetime as UTC with a ``Z`` suffix.""" + + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +@dataclass(frozen=True, slots=True) +class CredentialRecord: + """A single authentication credential for one canonical user. + + Exactly one credential exists per user in phase 1 (the migration enforces + ``user_id`` uniqueness). Keeping it separate from the profile is what + allows OIDC and local passwords to coexist in one ``users`` table. + """ + + id: UUID + user_id: UUID + credential_type: CredentialType + password_hash: str | None = field(default=None, repr=False) + must_change_password: bool = False + failed_login_count: int = 0 + locked_until: datetime | None = None + last_login_at: datetime | None = None + password_changed_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + def __post_init__(self) -> None: + object.__setattr__(self, "credential_type", CredentialType(self.credential_type)) + if self.credential_type is CredentialType.LOCAL_PASSWORD and not self.password_hash: + raise InputError("credential_hash_required", "A local password credential requires a hash.") + if self.credential_type is CredentialType.OIDC and self.password_hash is not None: + raise InputError("credential_hash_forbidden", "An OIDC credential must not store a password hash.") + if self.failed_login_count < 0: + raise InputError("invalid_failed_login_count", "Failed login count cannot be negative.") + + def is_locked(self, now: datetime) -> bool: + return self.locked_until is not None and self.locked_until > now + + def public_summary(self, *, now: datetime) -> CredentialSummary: + return CredentialSummary( + credential_type=self.credential_type, + must_change_password=self.must_change_password, + failed_login_count=self.failed_login_count, + locked=self.is_locked(now), + last_login_at=self.last_login_at, + password_changed_at=self.password_changed_at, + ) + + +@dataclass(frozen=True, slots=True) +class User: + """A canonical account. Contains no password material of any kind.""" + + id: UUID + username: str + username_key: str + display_name: str + role: RoleName + status: AccountStatus + organization_id: UUID | None = None + email: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + version: int = 1 + + def __post_init__(self) -> None: + object.__setattr__(self, "role", RoleName(self.role)) + object.__setattr__(self, "status", AccountStatus(self.status)) + object.__setattr__(self, "username_key", username_key(self.username)) + + @property + def is_admin(self) -> bool: + return self.role.value in ADMIN_ROLES + + +@dataclass(frozen=True, slots=True) +class SessionRecord: + """A server-side session. Only the hash of the bearer token is stored.""" + + id: UUID + user_id: UUID + token_hash: str = field(repr=False) + created_at: datetime + last_seen_at: datetime + idle_deadline_at: datetime + absolute_deadline_at: datetime + revoked_at: datetime | None = None + revoked_reason: str | None = None + mfa_verified_at: datetime | None = None + + def is_revoked(self) -> bool: + return self.revoked_at is not None + + def is_expired(self, now: datetime) -> bool: + return now >= self.idle_deadline_at or now >= self.absolute_deadline_at + + +@dataclass(frozen=True, slots=True) +class AuditEvent: + """A sanitized record of a material account or session change.""" + + id: UUID + occurred_at: datetime + action: str + outcome: str + actor_user_id: UUID | None = None + target_user_id: UUID | None = None + organization_id: UUID | None = None + request_id: str | None = None + detail: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({})) + + def to_dict(self) -> dict[str, Any]: + return { + "id": str(self.id), + "occurred_at": self.occurred_at.isoformat(), + "action": self.action, + "outcome": self.outcome, + "actor_user_id": str(self.actor_user_id) if self.actor_user_id else None, + "target_user_id": str(self.target_user_id) if self.target_user_id else None, + "organization_id": str(self.organization_id) if self.organization_id else None, + "request_id": self.request_id, + "detail": dict(self.detail), + } + + +@dataclass(frozen=True, slots=True) +class CredentialSummary: + """Sanitized credential attributes that are safe to return over the API.""" + + credential_type: CredentialType + must_change_password: bool + failed_login_count: int + locked: bool + last_login_at: datetime | None = None + password_changed_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class AccountView: + """The only account representation that may cross the API boundary.""" + + id: UUID + username: str + display_name: str + role: RoleName + status: AccountStatus + credential: CredentialSummary + organization_id: UUID | None = None + email: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + active_session_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class Principal: + """A resolved caller identity. Never built from browser-supplied values.""" + + user_id: UUID + username: str + role: RoleName + session_id: UUID + organization_id: UUID | None = None + must_change_password: bool = False + + @property + def is_admin(self) -> bool: + return self.role.value in ADMIN_ROLES + + +@dataclass(frozen=True, slots=True) +class IssuedSession: + """A freshly created session. The raw token exists only in this object.""" + + session_id: UUID + token: str = field(repr=False) + idle_expires_at: datetime + absolute_expires_at: datetime + must_change_password: bool = False + + +@dataclass(frozen=True, slots=True) +class AuthenticationResult: + user: User + session: IssuedSession + + +__all__ = [ + "ADMIN_ROLES", + "ALLOWED_AUDIT_DETAIL_KEYS", + "ALLOWED_STATUS_TRANSITIONS", + "MAX_DISPLAY_NAME_LENGTH", + "MAX_EMAIL_LENGTH", + "MAX_REASON_LENGTH", + "MAX_USERNAME_LENGTH", + "MIN_USERNAME_LENGTH", + "AccountStatus", + "AccountView", + "AuditEvent", + "AuthenticationResult", + "CredentialRecord", + "CredentialSummary", + "CredentialType", + "EnrollmentMode", + "IssuedSession", + "Principal", + "RoleName", + "SessionRecord", + "User", + "normalize_display_name", + "normalize_email", + "normalize_reason", + "normalize_username", + "username_key", + "utc_isoformat", +] diff --git a/umbratrans_web/repository.py b/umbratrans_web/repository.py new file mode 100644 index 0000000..8232c00 --- /dev/null +++ b/umbratrans_web/repository.py @@ -0,0 +1,289 @@ +"""Repository boundary for account, credential, session, and audit records. + +:class:`AccountRepository` is a :class:`typing.Protocol` so the PostgreSQL +adapter can replace :class:`InMemoryAccountRepository` without touching the +service. The protocol is synchronous; the HTTP handlers are ``def`` (not +``async def``) so FastAPI runs the Argon2 work in its worker threadpool. + +Only the in-memory development repository is implemented in this slice. No +module here connects to PostgreSQL. +""" + +from __future__ import annotations + +import threading +from dataclasses import replace +from datetime import datetime +from typing import Protocol, runtime_checkable +from uuid import UUID + +from .errors import ConflictError, NotFoundError, RepositoryError +from .models import ( + AccountStatus, + AuditEvent, + CredentialRecord, + SessionRecord, + User, + username_key, +) + + +@runtime_checkable +class AccountRepository(Protocol): + """Storage contract required by :class:`umbratrans_web.service.AccountService`.""" + + def create_user(self, user: User) -> User: + """Insert a new account, or raise :class:`ConflictError` on duplicates.""" + + def save_user(self, user: User) -> User: + """Persist a modified account, or raise :class:`NotFoundError`.""" + + def get_user(self, user_id: UUID) -> User | None: ... + + def get_user_by_username(self, key: str) -> User | None: + """Look up an account by its case-insensitive username key.""" + + def find_users( + self, + *, + organization_id: UUID | None = None, + status: AccountStatus | None = None, + role: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[User], int]: + """Return a page of accounts and the total match count.""" + + def get_credential(self, user_id: UUID) -> CredentialRecord | None: ... + + def save_credential(self, credential: CredentialRecord) -> CredentialRecord: ... + + def create_session(self, session: SessionRecord) -> SessionRecord: ... + + def get_session(self, session_id: UUID) -> SessionRecord | None: ... + + def get_session_by_token_hash(self, token_hash: str) -> SessionRecord | None: ... + + def save_session(self, session: SessionRecord) -> SessionRecord: ... + + def list_sessions(self, user_id: UUID, *, include_revoked: bool = False) -> list[SessionRecord]: ... + + def count_sessions(self, user_id: UUID) -> int: + """Count currently valid (not revoked, not expired) sessions.""" + + def revoke_sessions( + self, + user_id: UUID, + *, + reason: str, + now: datetime, + exclude_session_id: UUID | None = None, + ) -> int: + """Revoke live sessions for one account and return how many were revoked.""" + + def append_audit_event(self, event: AuditEvent) -> AuditEvent: ... + + def list_audit_events( + self, + *, + organization_id: UUID | None = None, + target_user_id: UUID | None = None, + actor_user_id: UUID | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AuditEvent], int]: ... + + +class InMemoryAccountRepository: + """Thread-safe in-memory repository for development and tests. + + Records are frozen dataclasses, so handing a stored instance to a caller + cannot mutate the store. Only hashes (never plaintext) are held here, + which the test suite asserts directly. + """ + + def __init__(self) -> None: + self._lock = threading.RLock() + self._users: dict[UUID, User] = {} + self._users_by_key: dict[str, UUID] = {} + self._credentials: dict[UUID, CredentialRecord] = {} + self._sessions: dict[UUID, SessionRecord] = {} + self._sessions_by_token: dict[str, UUID] = {} + self._audit: list[AuditEvent] = [] + + # -- users --------------------------------------------------------------- + + def create_user(self, user: User) -> User: + with self._lock: + if user.id in self._users: + raise ConflictError("account_exists", "An account with this identifier already exists.") + if user.username_key in self._users_by_key: + raise ConflictError("username_taken", "That username is already in use.") + self._users[user.id] = user + self._users_by_key[user.username_key] = user.id + return user + + def save_user(self, user: User) -> User: + with self._lock: + existing = self._users.get(user.id) + if existing is None: + raise NotFoundError("account_not_found") + if existing.username_key != user.username_key: + if user.username_key in self._users_by_key: + raise ConflictError("username_taken", "That username is already in use.") + del self._users_by_key[existing.username_key] + self._users_by_key[user.username_key] = user.id + self._users[user.id] = user + return user + + def get_user(self, user_id: UUID) -> User | None: + with self._lock: + return self._users.get(user_id) + + def get_user_by_username(self, key: str) -> User | None: + with self._lock: + user_id = self._users_by_key.get(username_key(key)) + return self._users.get(user_id) if user_id else None + + def find_users( + self, + *, + organization_id: UUID | None = None, + status: AccountStatus | None = None, + role: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[User], int]: + with self._lock: + matches = list(self._users.values()) + if organization_id is not None: + matches = [user for user in matches if user.organization_id == organization_id] + if status is not None: + matches = [user for user in matches if user.status == status] + if role is not None: + matches = [user for user in matches if str(user.role) == role] + matches.sort(key=lambda user: (user.created_at or user.updated_at, str(user.id)), reverse=True) + total = len(matches) + return matches[offset : offset + max(limit, 0)], total + + # -- credentials --------------------------------------------------------- + + def get_credential(self, user_id: UUID) -> CredentialRecord | None: + with self._lock: + for credential in self._credentials.values(): + if credential.user_id == user_id: + return credential + return None + + def save_credential(self, credential: CredentialRecord) -> CredentialRecord: + with self._lock: + self._credentials[credential.id] = credential + return credential + + # -- sessions ------------------------------------------------------------ + + def create_session(self, session: SessionRecord) -> SessionRecord: + with self._lock: + self._sessions[session.id] = session + self._sessions_by_token[session.token_hash] = session.id + return session + + def get_session(self, session_id: UUID) -> SessionRecord | None: + with self._lock: + return self._sessions.get(session_id) + + def get_session_by_token_hash(self, token_hash: str) -> SessionRecord | None: + with self._lock: + session_id = self._sessions_by_token.get(token_hash) + return self._sessions.get(session_id) if session_id else None + + def save_session(self, session: SessionRecord) -> SessionRecord: + with self._lock: + if session.id not in self._sessions: + raise NotFoundError("session_not_found") + self._sessions[session.id] = session + return session + + def list_sessions(self, user_id: UUID, *, include_revoked: bool = False) -> list[SessionRecord]: + with self._lock: + sessions = [session for session in self._sessions.values() if session.user_id == user_id] + if include_revoked: + return sessions + return [session for session in sessions if not session.is_revoked()] + + def count_sessions(self, user_id: UUID) -> int: + return len(self.list_sessions(user_id)) + + def revoke_sessions( + self, + user_id: UUID, + *, + reason: str, + now: datetime, + exclude_session_id: UUID | None = None, + ) -> int: + with self._lock: + revoked = 0 + for session in self._sessions.values(): + if session.user_id != user_id or session.is_revoked(): + continue + if exclude_session_id is not None and session.id == exclude_session_id: + continue + self._sessions[session.id] = replace(session, revoked_at=now, revoked_reason=reason) + revoked += 1 + return revoked + + # -- audit --------------------------------------------------------------- + + def append_audit_event(self, event: AuditEvent) -> AuditEvent: + with self._lock: + self._audit.append(event) + return event + + def list_audit_events( + self, + *, + organization_id: UUID | None = None, + target_user_id: UUID | None = None, + actor_user_id: UUID | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AuditEvent], int]: + with self._lock: + events = list(self._audit) + if organization_id is not None: + events = [event for event in events if event.organization_id == organization_id] + if target_user_id is not None: + events = [event for event in events if event.target_user_id == target_user_id] + if actor_user_id is not None: + events = [event for event in events if event.actor_user_id == actor_user_id] + events.sort(key=lambda event: (event.occurred_at, str(event.id)), reverse=True) + total = len(events) + return events[offset : offset + max(limit, 0)], total + + +def build_repository(settings: object, *, repository: AccountRepository | None = None) -> AccountRepository: + """Return the requested repository implementation. + + Kept in one place so the app factory never guesses a store. The PostgreSQL + adapter is intentionally absent from this slice. + """ + + if repository is not None: + return repository + kind = getattr(settings, "repository_kind", "memory") + if kind == "memory": + return InMemoryAccountRepository() + if kind == "json": + from .dev_repository import JsonDevelopmentRepository + + return JsonDevelopmentRepository(settings) # type: ignore[arg-type] + if kind == "postgres": + raise RepositoryError( + "postgres_adapter_unavailable", + "The PostgreSQL adapter is not part of this development slice.", + ) + raise RepositoryError("unknown_repository", "Unknown repository kind.") + + +__all__ = ["AccountRepository", "InMemoryAccountRepository", "build_repository"] diff --git a/umbratrans_web/routes_auth.py b/umbratrans_web/routes_auth.py new file mode 100644 index 0000000..bbd331e --- /dev/null +++ b/umbratrans_web/routes_auth.py @@ -0,0 +1,152 @@ +"""Authentication routes: ``/api/auth/*``. + +Handlers are synchronous so Argon2 verification runs in FastAPI's threadpool. +The session cookie is written only here; the raw token is never logged or +returned in a body. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request, Response + +from .app import ( + clear_csrf_cookie, + clear_session_cookie, + ensure_csrf_token, + get_principal_allowing_password_change, + get_service, + get_settings, + read_csrf_token, + request_id, + require_principal, + set_session_cookie, +) +from .models import Principal +from .schemas import ( + ChangeFirstPasswordRequest, + LoginRequest, + LoginResponse, + LogoutResponse, + SessionUserResponse, + UserResponse, +) +from .service import AccountService + +router = APIRouter() + + +def _profile(service: AccountService, user_id: object) -> UserResponse: + """Return the sanitized profile for one account.""" + + return UserResponse.from_view(service.self_view(user_id)) # type: ignore[arg-type] + + +def _csrf_for(request: Request, response: Response) -> str: + """Reuse the caller's CSRF token, or issue one if the request had none.""" + + token = read_csrf_token(request) + if token: + return token + return ensure_csrf_token(response, settings=get_settings(request)) + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest, request: Request, response: Response) -> LoginResponse: + """Authenticate a local password and open a server-side session. + + Failures are reported with a single generic error, so the response cannot + be used to discover whether an account exists. + """ + + settings = get_settings(request) + service: AccountService = get_service(request) + result = service.authenticate( + username=payload.username, + password=payload.password, + request_id=request_id(request), + ) + set_session_cookie(response, token=result.session.token, settings=settings) + return LoginResponse( + user=_profile(service, result.user.id), + must_change_password=result.session.must_change_password, + csrf_token=_csrf_for(request, response), + idle_expires_at=result.session.idle_expires_at, + absolute_expires_at=result.session.absolute_expires_at, + ) + + +@router.post("/change-first-password", response_model=LoginResponse) +def change_first_password( + payload: ChangeFirstPasswordRequest, + request: Request, + response: Response, + principal: Principal = Depends(get_principal_allowing_password_change), +) -> LoginResponse: + """Complete the mandatory first-login change and rotate the session.""" + + settings = get_settings(request) + service: AccountService = get_service(request) + result = service.change_first_password( + principal, + current_password=payload.current_password, + new_password=payload.new_password, + request_id=request_id(request), + ) + set_session_cookie(response, token=result.session.token, settings=settings) + return LoginResponse( + user=_profile(service, result.user.id), + must_change_password=result.session.must_change_password, + csrf_token=_csrf_for(request, response), + idle_expires_at=result.session.idle_expires_at, + absolute_expires_at=result.session.absolute_expires_at, + ) + + +@router.post("/logout", response_model=LogoutResponse) +def logout( + request: Request, + response: Response, + principal: Principal = Depends(get_principal_allowing_password_change), +) -> LogoutResponse: + """Revoke the caller's current session and clear the cookie.""" + + settings = get_settings(request) + service: AccountService = get_service(request) + revoked = service.logout(principal, request_id=request_id(request)) + clear_session_cookie(response, settings=settings) + clear_csrf_cookie(response, settings=settings) + return LogoutResponse(revoked_sessions=revoked) + + +@router.post("/logout-everywhere", response_model=LogoutResponse) +def logout_everywhere( + request: Request, + response: Response, + principal: Principal = Depends(get_principal_allowing_password_change), +) -> LogoutResponse: + """Revoke every session for the caller, including the current one.""" + + settings = get_settings(request) + service: AccountService = get_service(request) + revoked = service.logout_everywhere(principal, request_id=request_id(request)) + clear_session_cookie(response, settings=settings) + clear_csrf_cookie(response, settings=settings) + return LogoutResponse(revoked_sessions=revoked) + + +@router.get("/me", response_model=SessionUserResponse) +def me( + request: Request, + response: Response, + principal: Principal = Depends(require_principal), +) -> SessionUserResponse: + """Return the caller's own identity and the CSRF token to echo back.""" + + service: AccountService = get_service(request) + return SessionUserResponse( + user=_profile(service, principal.user_id), + role=principal.role, + organization_id=principal.organization_id, + must_change_password=principal.must_change_password, + csrf_token=_csrf_for(request, response), + ) diff --git a/umbratrans_web/routes_uadmin.py b/umbratrans_web/routes_uadmin.py new file mode 100644 index 0000000..baa72f9 --- /dev/null +++ b/umbratrans_web/routes_uadmin.py @@ -0,0 +1,177 @@ +"""Administrator routes: ``/api/uadmin/*``. + +Every route requires a session whose role is ``platform_admin`` or +``organization_admin``. Organization administrators are pinned to their own +organization by the service, so a forged organization identifier in a query +string or body has no effect. + +``POST /api/uadmin/accounts`` provisions either enrollment mode. OIDC +enrollment stores no local credential. Temporary-password enrollment generates +the one-time password inside the service: the request body has no password +field, and the value is returned exactly once in the creation response. The +separate operator bootstrap command in :mod:`umbratrans_web.cli` remains the +path for the first administrator, where the password comes from a hidden +local prompt. +""" + +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Request + +from .app import get_service, request_id, require_admin +from .models import AccountStatus, EnrollmentMode, Principal, RoleName +from .schemas import ( + AccountListResponse, + AccountResponse, + AuditEventResponse, + AuditListResponse, + CreateAccountRequest, + CreateAccountResponse, + RevokeSessionsResponse, + SetAccountStatusRequest, + StatusChangeResponse, +) +from .service import AccountService + +router = APIRouter() + +MAX_PAGE_SIZE = 200 +DEFAULT_PAGE_SIZE = 50 + + +@router.get("/accounts", response_model=AccountListResponse) +def list_accounts( + request: Request, + principal: Principal = Depends(require_admin), + status: AccountStatus | None = Query(default=None), + role: RoleName | None = Query(default=None), + organization_id: UUID | None = Query(default=None), + limit: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE), + offset: int = Query(default=0, ge=0), +) -> AccountListResponse: + """List accounts within the caller's administrative scope.""" + + service: AccountService = get_service(request) + accounts, total = service.list_accounts( + principal, + organization_id=organization_id, + status=status, + role=role, + limit=limit, + offset=offset, + ) + return AccountListResponse( + items=[AccountResponse.from_view(account) for account in accounts], + total=total, + limit=limit, + offset=offset, + ) + + +@router.post("/accounts", response_model=CreateAccountResponse, status_code=201) +def create_account( + payload: CreateAccountRequest, + request: Request, + principal: Principal = Depends(require_admin), +) -> CreateAccountResponse: + """Provision an account in either enrollment mode. + + ``oidc`` creates the account with no local credential. For + ``temporary_password`` the service generates the one-time password in + process; the request body has no password field at all, and the generated + value comes back exactly once in this response for the administrator to + hand to the account owner. The new account may do nothing but replace it. + """ + + service: AccountService = get_service(request) + # Role and organization scope are enforced by the service, not by trusting + # the request body: an organization administrator cannot create a + # platform administrator or an account outside its own organization. + if payload.enrollment_mode is EnrollmentMode.OIDC: + account = service.create_oidc_account( + username=payload.username, + display_name=payload.display_name, + role=payload.role, + email=payload.email, + organization_id=payload.organization_id, + actor=principal, + request_id=request_id(request), + ) + return CreateAccountResponse.from_oidc(account) + issued = service.provision_admin_temporary_password( + username=payload.username, + display_name=payload.display_name, + role=payload.role, + email=payload.email, + organization_id=payload.organization_id, + actor=principal, + request_id=request_id(request), + ) + return CreateAccountResponse.from_temporary_password(issued.account, issued.temporary_password) + + +@router.get("/accounts/{user_id}", response_model=AccountResponse) +def get_account( + user_id: UUID, + request: Request, + principal: Principal = Depends(require_admin), +) -> AccountResponse: + service: AccountService = get_service(request) + return AccountResponse.from_view(service.get_account(principal, user_id)) + + +@router.patch("/accounts/{user_id}/status", response_model=StatusChangeResponse) +def set_account_status( + user_id: UUID, + payload: SetAccountStatusRequest, + request: Request, + principal: Principal = Depends(require_admin), +) -> StatusChangeResponse: + """Suspend, reactivate, or deactivate an account. + + A non-active status revokes every session for that account immediately. + """ + + service: AccountService = get_service(request) + account, revoked = service.set_account_status( + principal, + user_id, + payload.status, + reason=payload.reason, + request_id=request_id(request), + ) + return StatusChangeResponse(account=AccountResponse.from_view(account), revoked_sessions=revoked) + + +@router.post("/accounts/{user_id}/revoke-sessions", response_model=RevokeSessionsResponse) +def revoke_sessions( + user_id: UUID, + request: Request, + principal: Principal = Depends(require_admin), +) -> RevokeSessionsResponse: + service: AccountService = get_service(request) + return RevokeSessionsResponse( + revoked_sessions=service.revoke_sessions(principal, user_id, request_id=request_id(request)) + ) + + +@router.get("/audit", response_model=AuditListResponse) +def list_audit_events( + request: Request, + principal: Principal = Depends(require_admin), + target_user_id: UUID | None = Query(default=None), + limit: int = Query(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE), + offset: int = Query(default=0, ge=0), +) -> AuditListResponse: + """Return sanitized audit events for the caller's administrative scope.""" + + service: AccountService = get_service(request) + events, total = service.list_audit_events(principal, target_user_id=target_user_id, limit=limit, offset=offset) + return AuditListResponse( + items=[AuditEventResponse.from_event(event) for event in events], + total=total, + limit=limit, + offset=offset, + ) diff --git a/umbratrans_web/schemas.py b/umbratrans_web/schemas.py new file mode 100644 index 0000000..0bf4bcc --- /dev/null +++ b/umbratrans_web/schemas.py @@ -0,0 +1,270 @@ +"""Pydantic request and response models for the phase-1 API. + +Two rules shape every model here: + +* Request models forbid unknown fields. A ``password`` field sent to + ``POST /api/uadmin/accounts`` is therefore rejected by the schema itself, so + a one-time password can never arrive through a normal JSON body. +* Response models contain no credential material. Accounts are serialized from + :class:`umbratrans_web.models.AccountView`, which exposes a + :class:`~umbratrans_web.models.CredentialSummary` and never a hash. +""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from .models import ( + AccountStatus, + AccountView, + AuditEvent, + CredentialSummary, + CredentialType, + EnrollmentMode, + RoleName, +) + + +class RequestModel(BaseModel): + """Base for request bodies: strict, no silent field coercion.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=False) + + +class ErrorBody(BaseModel): + code: str + message: str + + +class ErrorResponse(BaseModel): + error: ErrorBody + + +class HealthResponse(BaseModel): + status: str + service: str + version: str + environment: str + repository: str + cookie_secure: bool + csrf_required: bool + time: datetime + + +class CredentialSummaryResponse(BaseModel): + credential_type: CredentialType + must_change_password: bool + failed_login_count: int + locked: bool + last_login_at: datetime | None = None + password_changed_at: datetime | None = None + + @classmethod + def from_summary(cls, summary: CredentialSummary) -> CredentialSummaryResponse: + return cls( + credential_type=summary.credential_type, + must_change_password=summary.must_change_password, + failed_login_count=summary.failed_login_count, + locked=summary.locked, + last_login_at=summary.last_login_at, + password_changed_at=summary.password_changed_at, + ) + + +class UserResponse(BaseModel): + id: UUID + username: str + display_name: str + role: RoleName + status: AccountStatus + organization_id: UUID | None = None + email: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + @classmethod + def from_view(cls, view: AccountView) -> UserResponse: + return cls( + id=view.id, + username=view.username, + display_name=view.display_name, + role=view.role, + status=view.status, + organization_id=view.organization_id, + email=view.email, + created_at=view.created_at, + updated_at=view.updated_at, + ) + + +class AccountResponse(UserResponse): + credential: CredentialSummaryResponse + active_session_count: int + + @classmethod + def from_view(cls, view: AccountView) -> AccountResponse: + return cls( + id=view.id, + username=view.username, + display_name=view.display_name, + role=view.role, + status=view.status, + organization_id=view.organization_id, + email=view.email, + created_at=view.created_at, + updated_at=view.updated_at, + credential=CredentialSummaryResponse.from_summary(view.credential), + active_session_count=view.active_session_count, + ) + + +class AccountListResponse(BaseModel): + items: list[AccountResponse] + total: int + limit: int + offset: int + + +class SessionUserResponse(BaseModel): + """``GET /api/auth/me``. Includes the CSRF token the browser must echo.""" + + user: UserResponse + role: RoleName + organization_id: UUID | None = None + must_change_password: bool + csrf_token: str + idle_expires_at: datetime | None = None + absolute_expires_at: datetime | None = None + + +class LoginResponse(BaseModel): + user: UserResponse + must_change_password: bool + csrf_token: str + idle_expires_at: datetime + absolute_expires_at: datetime + + +class LogoutResponse(BaseModel): + revoked_sessions: int + + +class RevokeSessionsResponse(BaseModel): + revoked_sessions: int + + +class AuditEventResponse(BaseModel): + id: UUID + occurred_at: datetime + action: str + outcome: str + actor_user_id: UUID | None = None + target_user_id: UUID | None = None + organization_id: UUID | None = None + request_id: str | None = None + detail: dict[str, str] + + @classmethod + def from_event(cls, event: AuditEvent) -> AuditEventResponse: + return cls( + id=event.id, + occurred_at=event.occurred_at, + action=event.action, + outcome=event.outcome, + actor_user_id=event.actor_user_id, + target_user_id=event.target_user_id, + organization_id=event.organization_id, + request_id=event.request_id, + detail=dict(event.detail), + ) + + +class AuditListResponse(BaseModel): + items: list[AuditEventResponse] + total: int + limit: int + offset: int + + +class LoginRequest(RequestModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=1, max_length=1024) + + +class ChangeFirstPasswordRequest(RequestModel): + current_password: str = Field(min_length=1, max_length=1024) + new_password: str = Field(min_length=1, max_length=1024) + + +class CreateAccountRequest(RequestModel): + """Administrator account creation for either enrollment mode. + + The request never carries a password: the schema forbids unknown fields, so + a submitted ``password`` is rejected outright. For + ``enrollment_mode="temporary_password"`` the service generates the one-time + password itself and returns it once in :class:`CreateAccountResponse`. + """ + + username: str = Field(min_length=1, max_length=64) + display_name: str = Field(min_length=1, max_length=120) + enrollment_mode: EnrollmentMode = EnrollmentMode.OIDC + role: RoleName = RoleName.MEMBER + email: str | None = Field(default=None, max_length=254) + organization_id: UUID | None = None + + +class CreateAccountResponse(BaseModel): + """Result of ``POST /api/uadmin/accounts``. + + ``temporary_password`` is populated only when the account was enrolled with + a one-time local password. It is the single response that ever contains + that value, it is served with ``Cache-Control: no-store``, and it is never + persisted, logged, or retrievable again: the account must replace it on its + first login. It is ``None`` for OIDC enrollment. + """ + + account: AccountResponse + temporary_password: str | None = None + + @classmethod + def from_oidc(cls, view: AccountView) -> CreateAccountResponse: + return cls(account=AccountResponse.from_view(view)) + + @classmethod + def from_temporary_password(cls, view: AccountView, temporary_password: str) -> CreateAccountResponse: + return cls(account=AccountResponse.from_view(view), temporary_password=temporary_password) + + +class SetAccountStatusRequest(RequestModel): + status: AccountStatus + reason: str | None = Field(default=None, max_length=200) + + +class StatusChangeResponse(BaseModel): + account: AccountResponse + revoked_sessions: int + + +__all__ = [ + "AccountListResponse", + "AccountResponse", + "AuditEventResponse", + "AuditListResponse", + "ChangeFirstPasswordRequest", + "CreateAccountRequest", + "CreateAccountResponse", + "CredentialSummaryResponse", + "ErrorResponse", + "HealthResponse", + "LoginRequest", + "LoginResponse", + "LogoutResponse", + "RevokeSessionsResponse", + "SessionUserResponse", + "SetAccountStatusRequest", + "StatusChangeResponse", + "UserResponse", +] diff --git a/umbratrans_web/security.py b/umbratrans_web/security.py new file mode 100644 index 0000000..45c09d4 --- /dev/null +++ b/umbratrans_web/security.py @@ -0,0 +1,398 @@ +"""Password, token, and redaction helpers for the account slice. + +Only the Argon2id construction is used for local passwords. Session and CSRF +tokens are generated with :mod:`secrets`, handed to the client exactly once, +and stored exclusively as a hash (optionally keyed with a server-side pepper). + +Nothing in this module logs. The one logging-related helper is +:class:`SecretRedactionFilter`, which scrubs well-known secret patterns from +records emitted by the ``umbratrans_web`` logger tree so that an accidental +interpolation cannot leak a credential into a log stream. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import re +import secrets +import string +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from uuid import UUID + +from argon2 import PasswordHasher, Type +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + +from .errors import PasswordPolicyError +from .models import ALLOWED_AUDIT_DETAIL_KEYS + +# Argon2id parameters. The defaults are the OWASP "second recommended" profile +# (64 MiB, t=3, p=4) and are the values recorded in the reviewed migration +# comments. Tests may lower them through ``Settings`` to stay fast; the +# production profile is asserted by a dedicated test. +ARGON2_MEMORY_COST_KIB = 64 * 1024 +ARGON2_TIME_COST = 3 +ARGON2_PARALLELISM = 4 +ARGON2_HASH_LENGTH = 32 +ARGON2_SALT_LENGTH = 16 + +MIN_PASSWORD_LENGTH = 14 +MAX_PASSWORD_LENGTH = 1024 +MAX_FAILED_LOGIN_ATTEMPTS = 5 +DEFAULT_LOCKOUT = timedelta(minutes=15) +SESSION_TOKEN_BYTES = 32 +CSRF_TOKEN_BYTES = 32 +MIN_PEPPER_BYTES = 32 + +#: Length of a server-generated one-time enrollment password. +TEMPORARY_PASSWORD_LENGTH = 20 +# Ambiguous glyphs (0/O, 1/l/I, 5/S, 2/Z) are left out: a one-time password is +# read once by an administrator and typed once by its owner, so transcription +# errors are the main failure mode worth designing out. +_AMBIGUOUS_GLYPHS = frozenset("0Oo1lI5S2Z") +_TEMPORARY_LOWERCASE = "".join(character for character in string.ascii_lowercase if character not in _AMBIGUOUS_GLYPHS) +_TEMPORARY_UPPERCASE = "".join(character for character in string.ascii_uppercase if character not in _AMBIGUOUS_GLYPHS) +_TEMPORARY_DIGITS = "".join(character for character in string.digits if character not in _AMBIGUOUS_GLYPHS) +_TEMPORARY_SYMBOLS = "!@#$%^&*-_=+" +_TEMPORARY_ALPHABET = _TEMPORARY_LOWERCASE + _TEMPORARY_UPPERCASE + _TEMPORARY_DIGITS + _TEMPORARY_SYMBOLS + +MAX_AUDIT_VALUE_LENGTH = 200 +REDACTED = "[redacted]" + +_SECRET_KEY_PATTERN = ( + r"(?:password|passwd|pwd|token|secret|credential|passphrase|private[_-]?key|" + r"api[_-]?key|authorization|cookie|session[_-]?id|hash|pepper|salt)" +) +_SECRET_ASSIGNMENT_RE = re.compile( + rf"(?i)([\"']?\b[A-Za-z0-9_-]*{_SECRET_KEY_PATTERN}\b[\"']?\s*[:=]\s*)" + r"(\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)" +) +_BEARER_RE = re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9._~+/=-]+") +_BASIC_RE = re.compile(r"(?i)(\bBasic\s+)[A-Za-z0-9+/=]+") +_PRIVATE_KEY_RE = re.compile( + r"-----BEGIN [^-]*(?:PRIVATE|SECRET) KEY(?: BLOCK)?-----.*?" + r"-----END [^-]*(?:PRIVATE|SECRET) KEY(?: BLOCK)?-----", + re.DOTALL, +) +_ARGON2_HASH_RE = re.compile(r"\$argon2(?:id|i|d)\$[^\s\"']+") +_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") +_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") + + +def utc_now() -> datetime: + """Return an aware UTC timestamp. The single clock used by the slice.""" + + return datetime.now(timezone.utc) + + +def generate_token(nbytes: int = SESSION_TOKEN_BYTES) -> str: + """Return a URL-safe random token with at least ``nbytes * 8`` bits.""" + + return secrets.token_urlsafe(nbytes) + + +def generate_temporary_password(*, username: str | None = None, length: int = TEMPORARY_PASSWORD_LENGTH) -> str: + """Return a one-time enrollment password generated inside the server. + + The value is handed to the caller exactly once, hashed immediately, and + never logged, audited, or persisted. One character from each class is + guaranteed so the result satisfies the local policy while still being drawn + from :mod:`secrets`. Generation is retried if the candidate would contain + the account username, which the policy forbids. + """ + + for _ in range(8): + characters = [ + secrets.choice(_TEMPORARY_LOWERCASE), + secrets.choice(_TEMPORARY_UPPERCASE), + secrets.choice(_TEMPORARY_DIGITS), + secrets.choice(_TEMPORARY_SYMBOLS), + ] + characters.extend(secrets.choice(_TEMPORARY_ALPHABET) for _ in range(max(length, 8) - len(characters))) + candidate = "".join(characters) + if username and username.casefold() in candidate.casefold(): + continue + return candidate + # Unreachable in practice: the username would have to occupy the whole + # candidate on eight consecutive draws. + raise PasswordPolicyError(("unmet",)) # pragma: no cover + + +def hash_session_token(token: str, *, pepper: bytes | None = None) -> str: + """Return the stored form of a session token. + + A 256-bit random token does not need a slow KDF, so a single SHA-256 (or + HMAC-SHA256 when a server-side pepper is configured) is used. The plain + token is never stored. + """ + + if not token: + raise ValueError("token must not be empty") + payload = token.encode("utf-8") + if pepper: + return hmac.new(pepper, payload, hashlib.sha256).hexdigest() + return hashlib.sha256(payload).hexdigest() + + +def tokens_match(candidate: str, expected: str) -> bool: + """Constant-time comparison for token and CSRF values.""" + + return hmac.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8")) + + +def redact_text(value: str) -> str: + """Remove credential-shaped substrings from free text. + + Used for log records and for operator-supplied audit reasons. It errs + towards over-redaction; false positives cost readability, false negatives + cost a credential. + """ + + text = _PRIVATE_KEY_RE.sub(REDACTED, value) + text = _ARGON2_HASH_RE.sub(REDACTED, text) + text = _JWT_RE.sub(REDACTED, text) + text = _BEARER_RE.sub(rf"\1{REDACTED}", text) + text = _BASIC_RE.sub(rf"\1{REDACTED}", text) + text = _SECRET_ASSIGNMENT_RE.sub(rf"\1{REDACTED}", text) + return _CONTROL_CHARS_RE.sub(" ", text) + + +def sanitize_audit_detail(detail: dict[str, object] | None) -> dict[str, str]: + """Reduce an audit detail mapping to allow-listed, redacted strings. + + Keys outside :data:`umbratrans_web.models.ALLOWED_AUDIT_DETAIL_KEYS` are + dropped, so a future caller cannot record secret material simply by adding + a new key. Values are stringified, redacted, and length-bounded. + """ + + sanitized: dict[str, str] = {} + for key, raw in (detail or {}).items(): + if key not in ALLOWED_AUDIT_DETAIL_KEYS: + continue + if raw is None: + continue + if isinstance(raw, bool): + value = "true" if raw else "false" + elif isinstance(raw, (str, int, float, UUID)): + value = str(raw) + else: + value = f"" + value = redact_text(str(value)).strip() + if not value: + continue + sanitized[key] = value[:MAX_AUDIT_VALUE_LENGTH] + return sanitized + + +class SecretRedactionFilter(logging.Filter): + """Scrub secret patterns from log records of the application logger tree. + + Installed by :func:`install_secret_redaction_filter`. It is a defence in + depth measure: application code is still expected to log identifiers only. + + The record is rendered before redaction so that ``%``-style formatting + cannot be broken by a substitution inside the format string. + + Limitation: the filter is pattern based. It removes secret-shaped text + (``password=...``, ``Authorization: Bearer ...``, an Argon2id PHC string, a + private-key block, a JWT). It cannot recognise a secret published under an + innocuous key, so no call site may log a submitted value in the first + place. + """ + + def filter(self, record: logging.LogRecord) -> bool: + try: + rendered = record.getMessage() + except Exception: # noqa: BLE001 - never break logging on a bad record + record.msg = redact_text(str(record.msg)) + return True + record.msg = redact_text(rendered) + record.args = None + return True + + +#: Root of the application logger tree. +LOGGER_NAMESPACE = "umbratrans_web" + + +def install_secret_redaction_filter(namespace: str = LOGGER_NAMESPACE) -> int: + """Attach :class:`SecretRedactionFilter` to the application logger tree. + + A filter on a parent logger is not applied to records emitted by its + children, so every logger in the namespace is patched. Returns the number + of loggers patched, for tests and diagnostics. + """ + + root = logging.getLogger(namespace) + if not any(isinstance(item, SecretRedactionFilter) for item in root.filters): + root.addFilter(SecretRedactionFilter()) + patched = 1 + for name in list(logging.Logger.manager.loggerDict): + if not name.startswith(f"{namespace}."): + continue + candidate = logging.getLogger(name) + if any(isinstance(item, SecretRedactionFilter) for item in candidate.filters): + continue + candidate.addFilter(SecretRedactionFilter()) + patched += 1 + return patched + + +@dataclass(frozen=True, slots=True) +class PasswordPolicy: + """Local password policy. + + The policy is intentionally length-first: a long passphrase is the main + defence, and composition rules push users toward predictable substitutions. + """ + + min_length: int = MIN_PASSWORD_LENGTH + max_length: int = MAX_PASSWORD_LENGTH + forbid_username: bool = True + + def validate(self, password: str, *, username: str | None = None) -> None: + """Raise :class:`PasswordPolicyError` listing unmet rule names only. + + The candidate password never appears in the exception, its message, or + its attributes. + """ + + unmet: list[str] = [] + if not isinstance(password, str) or not password: + unmet.append("not_empty") + else: + if len(password) < self.min_length: + unmet.append("min_length") + if len(password) > self.max_length: + unmet.append("max_length") + if password.strip() != password: + unmet.append("no_surrounding_whitespace") + if self.forbid_username and username: + lowered = password.casefold() + if username.casefold() in lowered: + unmet.append("must_not_contain_username") + if unmet: + raise PasswordPolicyError(tuple(unmet)) + + def describe(self) -> dict[str, object]: + """Return a non-secret description of the policy for operator docs.""" + + return {"min_length": self.min_length, "max_length": self.max_length, "forbid_username": self.forbid_username} + + +@dataclass(frozen=True, slots=True) +class PasswordService: + """Argon2id hashing and verification with an explicit, reviewable profile.""" + + memory_cost_kib: int = ARGON2_MEMORY_COST_KIB + time_cost: int = ARGON2_TIME_COST + parallelism: int = ARGON2_PARALLELISM + hash_length: int = ARGON2_HASH_LENGTH + salt_length: int = ARGON2_SALT_LENGTH + policy: PasswordPolicy = field(default_factory=PasswordPolicy) + _hasher: PasswordHasher = field(init=False, repr=False, compare=False) + _dummy_hash: str = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + hasher = PasswordHasher( + time_cost=self.time_cost, + memory_cost=self.memory_cost_kib, + parallelism=self.parallelism, + hash_len=self.hash_length, + salt_len=self.salt_length, + type=Type.ID, + ) + object.__setattr__(self, "_hasher", hasher) + # A fixed dummy hash keeps the unknown-user path comparable in cost to + # the real verification path. It is a hash of a random value that is + # discarded immediately and is not a usable credential. + object.__setattr__(self, "_dummy_hash", hasher.hash(secrets.token_urlsafe(32))) + + def hash_password(self, password: str) -> str: + """Return an Argon2id hash. The plaintext is not retained anywhere.""" + + return self._hasher.hash(password) + + def generate_temporary_password(self, *, username: str | None = None) -> str: + """Return a one-time password that satisfies this service's policy. + + Used by administrator-driven enrollment, where the plaintext is created + in-process, shown to the administrator once, hashed immediately, and + never written to a log, an audit record, or the repository. + """ + + candidate = generate_temporary_password( + username=username, + length=max(TEMPORARY_PASSWORD_LENGTH, self.policy.min_length + 4), + ) + self.policy.validate(candidate, username=username) + return candidate + + def verify_password(self, password_hash: str | None, password: str) -> bool: + """Verify a password in constant time, returning ``False`` on any error.""" + + if not password_hash: + self.verify_dummy(password) + return False + try: + return bool(self._hasher.verify(password_hash, password)) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + def verify_dummy(self, password: str) -> None: + """Burn a comparable amount of CPU for an unknown account.""" + + self.verify_password(self._dummy_hash, password) + + def needs_rehash(self, password_hash: str) -> bool: + try: + return bool(self._hasher.check_needs_rehash(password_hash)) + except (InvalidHashError, VerificationError): + return True + + def identifier(self, password_hash: str) -> str: + """Return a non-reversible short label for logs, e.g. ``argon2id:m=65536``.""" + + parts = password_hash.split("$") + if len(parts) < 4 or parts[1] != "argon2id": + return "unknown" + return f"argon2id:{parts[3]}" + + def parameters(self) -> dict[str, int | str]: + return { + "type": "argon2id", + "memory_cost_kib": self.memory_cost_kib, + "time_cost": self.time_cost, + "parallelism": self.parallelism, + "hash_length": self.hash_length, + "salt_length": self.salt_length, + } + + +__all__ = [ + "ARGON2_HASH_LENGTH", + "ARGON2_MEMORY_COST_KIB", + "ARGON2_PARALLELISM", + "ARGON2_SALT_LENGTH", + "ARGON2_TIME_COST", + "CSRF_TOKEN_BYTES", + "DEFAULT_LOCKOUT", + "LOGGER_NAMESPACE", + "MAX_FAILED_LOGIN_ATTEMPTS", + "MAX_PASSWORD_LENGTH", + "MIN_PASSWORD_LENGTH", + "MIN_PEPPER_BYTES", + "SESSION_TOKEN_BYTES", + "PasswordPolicy", + "PasswordService", + "SecretRedactionFilter", + "generate_token", + "hash_session_token", + "install_secret_redaction_filter", + "redact_text", + "sanitize_audit_detail", + "tokens_match", + "utc_now", +] diff --git a/umbratrans_web/service.py b/umbratrans_web/service.py new file mode 100644 index 0000000..a50760c --- /dev/null +++ b/umbratrans_web/service.py @@ -0,0 +1,1038 @@ +"""Account service: enrollment, authentication, sessions, and administration. + +This module is the only place that knows how an account is created, how a +password is verified, and what a session is. Both enrollment modes are +implemented here and are separated by a code-level boundary rather than by a +convention: + +``temporary_password`` + Has two callers, and both keep the plaintext out of the request surface. + The operator CLI reads it from a hidden local prompt and passes it with an + :class:`OperatorContext`. The administrator API never accepts a submitted + password at all: it asks the service to generate a one-time password + in-process, which is hashed immediately and returned to the authenticated + administrator exactly once. Either way the credential is marked + ``must_change_password=True`` so the first login can do nothing else. + +``oidc`` + Requires an authenticated administrator principal. No local password is + created or stored; the identity provider completes enrollment. + +Rules enforced here: + +* The plaintext password is never persisted, returned, logged, or audited. +* Every login failure returns the same generic error, whatever the real cause; + the cause is recorded in the sanitized audit trail instead. +* Sessions store only a hash of the bearer token and are revoked on + suspension, deactivation, password change, and logout-everywhere. +* Account lookups and mutations are authorized against a server-resolved + principal; no caller-supplied organization or role is trusted. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field, replace +from datetime import datetime, timedelta +from types import MappingProxyType +from typing import Callable, Mapping +from uuid import UUID, uuid4 + +from .config import Settings +from .errors import ( + ConflictError, + EnrollmentRefusedError, + ForbiddenError, + InputError, + InvalidCredentialsError, + NotFoundError, + OperatorRefusalError, + PasswordChangeRequiredError, + PasswordPolicyError, + StateTransitionError, +) +from .models import ( + ALLOWED_STATUS_TRANSITIONS, + AccountStatus, + AccountView, + AuditEvent, + AuthenticationResult, + CredentialRecord, + CredentialSummary, + CredentialType, + EnrollmentMode, + IssuedSession, + Principal, + RoleName, + SessionRecord, + User, + normalize_display_name, + normalize_email, + normalize_reason, + normalize_username, + username_key, +) +from .repository import AccountRepository +from .security import ( + PasswordPolicy, + PasswordService, + generate_token, + hash_session_token, + sanitize_audit_detail, + utc_now, +) + +LOGGER = logging.getLogger("umbratrans_web.service") + +#: Sources that may request the operator-only local temporary-password path. +OPERATOR_SOURCES = frozenset({"operator-cli", "operator-service"}) + +#: Audit/log label for administrator-driven temporary-password enrollment. +ADMIN_ENROLLMENT_SOURCE = "admin-api" + +#: Audit action names. Stable strings, no free-form data. +ACTION_ACCOUNT_CREATED = "account.created" +ACTION_ACCOUNT_PROVISIONED_LOCAL = "account.provisioned_local" +ACTION_ACCOUNT_STATUS_CHANGED = "account.status_changed" +ACTION_AUTH_LOGIN = "auth.login" +ACTION_AUTH_FIRST_PASSWORD_CHANGED = "auth.first_password_changed" +ACTION_AUTH_LOGOUT = "auth.logout" +ACTION_AUTH_LOGOUT_EVERYWHERE = "auth.logout_everywhere" +ACTION_SESSION_REVOKED = "session.revoked" +ACTION_SESSION_EXPIRED = "session.expired" +ACTION_SESSION_INVALIDATED = "session.invalidated" + + +@dataclass(frozen=True, slots=True) +class OperatorContext: + """Proves the caller is the operator path, not a public API request. + + The HTTP layer never constructs this object, which is what keeps + temporary-password enrollment off the JSON API surface. + """ + + source: str + interactive: bool = True + + def __post_init__(self) -> None: + if self.source not in OPERATOR_SOURCES: + raise OperatorRefusalError("unknown_operator_source", f"Unknown operator source {self.source!r}.") + + +@dataclass(frozen=True, slots=True) +class IssuedTemporaryPassword: + """An account plus the one-time password generated for it. + + ``temporary_password`` is excluded from ``repr`` so an accidental log line + or traceback cannot carry the value. It exists only in the response that + returns it from account creation, and the caller is expected to display it + once to the account owner. + """ + + account: AccountView + temporary_password: str = field(repr=False) + + +class AccountService: + """Application service for identity and account management.""" + + def __init__( + self, + repository: AccountRepository, + settings: Settings, + *, + clock: Callable[[], datetime] = utc_now, + password_service: PasswordService | None = None, + ) -> None: + self._repository = repository + self._settings = settings + self._clock = clock + self._passwords = password_service or PasswordService( + memory_cost_kib=settings.argon2_memory_cost_kib, + time_cost=settings.argon2_time_cost, + parallelism=settings.argon2_parallelism, + hash_length=settings.argon2_hash_length, + salt_length=settings.argon2_salt_length, + policy=PasswordPolicy(min_length=settings.password_min_length, max_length=settings.password_max_length), + ) + + # -- introspection ------------------------------------------------------- + + @property + def settings(self) -> Settings: + return self._settings + + @property + def repository(self) -> AccountRepository: + return self._repository + + def password_policy_description(self) -> dict[str, object]: + return self._passwords.policy.describe() + + def _now(self) -> datetime: + return self._clock() + + # -- enrollment ---------------------------------------------------------- + + def create_account( + self, + *, + enrollment_mode: str, + username: str, + display_name: str, + role: str = str(RoleName.MEMBER), + email: str | None = None, + organization_id: UUID | None = None, + password: str | None = None, + actor: Principal | None = None, + operator: OperatorContext | None = None, + request_id: str | None = None, + ) -> AccountView: + """Create an account through one of the two enrollment modes. + + ``temporary_password`` is the operator path and requires ``operator`` + plus ``password``; use :meth:`provision_admin_temporary_password` for + administrator-driven enrollment. ``oidc`` requires an administrator + ``actor`` and never stores a password. + """ + + mode = _enrollment_mode(enrollment_mode) + if mode is EnrollmentMode.TEMPORARY_PASSWORD: + return self._provision_local_account( + username=username, + display_name=display_name, + role=role, + email=email, + organization_id=organization_id, + password=password, + operator=operator, + request_id=request_id, + ) + return self.create_oidc_account( + username=username, + display_name=display_name, + role=role, + email=email, + organization_id=organization_id, + actor=actor, + request_id=request_id, + ) + + def _provision_local_account( + self, + *, + username: str, + display_name: str, + role: str, + email: str | None, + organization_id: UUID | None, + password: str | None, + operator: OperatorContext | None, + request_id: str | None, + ) -> AccountView: + """Operator-only enrollment with a one-time local password.""" + + if operator is None: + raise EnrollmentRefusedError( + "operator_context_required", + "Temporary password enrollment requires the operator provisioning path.", + ) + if not isinstance(password, str) or not password: + raise InputError("password_required", "A one-time password is required for this enrollment mode.") + self._passwords.policy.validate(password, username=username) + password_hash = self._passwords.hash_password(password) + user, credential = self._insert_account( + username=username, + display_name=display_name, + role=role, + email=email, + organization_id=organization_id, + credential_type=CredentialType.LOCAL_PASSWORD, + password_hash=password_hash, + must_change_password=True, + ) + self._audit( + action=ACTION_ACCOUNT_PROVISIONED_LOCAL, + outcome="success", + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={ + "username": user.username, + "role": user.role, + "organization_id": user.organization_id, + "enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD, + "credential_type": credential.credential_type, + "must_change_password": True, + "source": operator.source, + }, + ) + LOGGER.info( + "account provisioned username=%s user_id=%s mode=temporary_password source=%s", + user.username, + user.id, + operator.source, + ) + return self._view(user, now=self._now()) + + def provision_admin_temporary_password( + self, + *, + username: str, + display_name: str, + role: str = str(RoleName.MEMBER), + email: str | None = None, + organization_id: UUID | None = None, + actor: Principal | None = None, + request_id: str | None = None, + ) -> IssuedTemporaryPassword: + """Administrator-driven enrollment with a generated one-time password. + + The password is generated inside the service, validated against the + local policy, and hashed before it is returned. No password is accepted + from the caller, so no request body, header, or queue message can carry + one; the plaintext leaves the process only in the single creation + response, is excluded from ``repr``, and is never logged, audited, or + stored. The account is created with ``must_change_password=True``, so it + can do nothing beyond replacing this password on its first login. + """ + + self._require_admin(actor) + 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) + temporary_password = self._passwords.generate_temporary_password(username=normalize_username(username)) + user, credential = self._insert_account( + username=username, + display_name=display_name, + role=role, + email=email, + organization_id=organization_id, + credential_type=CredentialType.LOCAL_PASSWORD, + password_hash=self._passwords.hash_password(temporary_password), + must_change_password=True, + ) + self._audit( + action=ACTION_ACCOUNT_PROVISIONED_LOCAL, + outcome="success", + actor=actor, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={ + "username": user.username, + "role": user.role, + "organization_id": user.organization_id, + "enrollment_mode": EnrollmentMode.TEMPORARY_PASSWORD, + "credential_type": credential.credential_type, + "must_change_password": True, + "source": ADMIN_ENROLLMENT_SOURCE, + }, + ) + LOGGER.info( + "account provisioned username=%s user_id=%s mode=temporary_password source=%s", + user.username, + user.id, + ADMIN_ENROLLMENT_SOURCE, + ) + return IssuedTemporaryPassword(account=self._view(user, now=self._now()), temporary_password=temporary_password) + + def create_oidc_account( + self, + *, + username: str, + display_name: str, + role: str = str(RoleName.MEMBER), + email: str | None = None, + organization_id: UUID | None = None, + actor: Principal | None = None, + request_id: str | None = None, + ) -> AccountView: + """Administrator-driven OIDC enrollment. No local password is created.""" + + self._require_admin(actor) + 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) + user, credential = self._insert_account( + username=username, + display_name=display_name, + role=role, + email=email, + organization_id=organization_id, + credential_type=CredentialType.OIDC, + password_hash=None, + must_change_password=False, + ) + self._audit( + action=ACTION_ACCOUNT_CREATED, + outcome="success", + actor=actor, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={ + "username": user.username, + "role": user.role, + "organization_id": user.organization_id, + "enrollment_mode": EnrollmentMode.OIDC, + "credential_type": credential.credential_type, + "must_change_password": False, + }, + ) + LOGGER.info("account created username=%s user_id=%s mode=oidc", user.username, user.id) + return self._view(user, now=self._now()) + + def _insert_account( + self, + *, + username: str, + display_name: str, + role: str, + email: str | None, + organization_id: UUID | None, + credential_type: CredentialType, + password_hash: str | None, + must_change_password: bool, + ) -> tuple[User, CredentialRecord]: + now = self._now() + canonical_username = normalize_username(username) + user = User( + id=uuid4(), + username=canonical_username, + username_key=username_key(canonical_username), + display_name=normalize_display_name(display_name), + role=_role(role), + status=AccountStatus.ACTIVE, + organization_id=organization_id, + email=normalize_email(email), + created_at=now, + updated_at=now, + ) + credential = CredentialRecord( + id=uuid4(), + user_id=user.id, + credential_type=credential_type, + password_hash=password_hash, + must_change_password=must_change_password, + created_at=now, + updated_at=now, + ) + self._repository.create_user(user) + self._repository.save_credential(credential) + return user, credential + + # -- authentication ------------------------------------------------------ + + def authenticate(self, *, username: str, password: str, request_id: str | None = None) -> AuthenticationResult: + """Authenticate a local password and issue a server-side session. + + Every rejection path raises the same :class:`InvalidCredentialsError` so + that the response cannot be used to enumerate accounts. + """ + + now = self._now() + key = _safe_username_key(username) + if key is None or not isinstance(password, str) or not password: + self._passwords.verify_dummy(password if isinstance(password, str) else "") + self._audit( + action=ACTION_AUTH_LOGIN, + outcome="failure", + request_id=request_id, + detail={"reason": "malformed_request"}, + ) + raise InvalidCredentialsError() + + user = self._repository.get_user_by_username(key) + if user is None: + self._passwords.verify_dummy(password) + self._audit( + action=ACTION_AUTH_LOGIN, + outcome="failure", + request_id=request_id, + detail={"reason": "unknown_account"}, + ) + raise InvalidCredentialsError() + + credential = self._repository.get_credential(user.id) + if credential is None or credential.credential_type is not CredentialType.LOCAL_PASSWORD: + # OIDC accounts never authenticate with a local password; burn a + # comparable amount of CPU before failing. + self._passwords.verify_dummy(password) + self._audit( + action=ACTION_AUTH_LOGIN, + outcome="failure", + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"reason": "not_local_password_credential"}, + ) + raise InvalidCredentialsError() + + if credential.is_locked(now): + self._audit( + action=ACTION_AUTH_LOGIN, + outcome="blocked", + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"reason": "credential_locked", "lock_state": "locked"}, + ) + raise InvalidCredentialsError() + + if user.status is not AccountStatus.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": f"account_{user.status.value}", "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 + lock_state = "none" + if failed_count >= self._settings.max_failed_login_attempts: + locked_until = now + timedelta(seconds=self._settings.lockout_seconds) + lock_state = "locked" + self._repository.save_credential( + replace(credential, failed_login_count=failed_count, locked_until=locked_until, updated_at=now) + ) + self._audit( + action=ACTION_AUTH_LOGIN, + outcome="failure", + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"reason": "invalid_password", "lock_state": lock_state, "username": user.username}, + ) + raise InvalidCredentialsError() + + self._repository.save_credential( + replace(credential, failed_login_count=0, locked_until=None, last_login_at=now, updated_at=now) + ) + session = self._issue_session(user, now=now, must_change_password=credential.must_change_password) + self._audit( + action=ACTION_AUTH_LOGIN, + outcome="success", + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={ + "username": user.username, + "must_change_password": credential.must_change_password, + "credential_type": credential.credential_type, + }, + ) + LOGGER.info("login succeeded user_id=%s must_change_password=%s", user.id, credential.must_change_password) + return AuthenticationResult(user=user, session=session) + + def change_first_password( + self, + principal: Principal, + *, + current_password: str, + new_password: str, + request_id: str | None = None, + ) -> AuthenticationResult: + """Complete a mandatory first-login password change. + + The temporary password is re-verified so that a stolen session cookie + alone cannot take over the account. On success every session is + revoked and a new one is issued (session rotation). + """ + + user = self._require_user(principal.user_id) + credential = self._repository.get_credential(user.id) + if ( + credential is None + or credential.credential_type is not CredentialType.LOCAL_PASSWORD + or credential.password_hash is None + ): + raise InvalidCredentialsError() + if not isinstance(current_password, str) or not self._passwords.verify_password( + credential.password_hash, current_password + ): + self._audit( + action=ACTION_AUTH_FIRST_PASSWORD_CHANGED, + outcome="failure", + actor=principal, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"reason": "invalid_current_password"}, + ) + raise InvalidCredentialsError() + + try: + self._passwords.policy.validate(new_password, username=user.username) + except PasswordPolicyError as error: + self._audit( + action=ACTION_AUTH_FIRST_PASSWORD_CHANGED, + outcome="failure", + actor=principal, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"reason": "password_policy"}, + ) + raise error + if self._passwords.verify_password(credential.password_hash, new_password): + raise PasswordPolicyError(("must_differ_from_current",)) + + now = self._now() + new_hash = self._passwords.hash_password(new_password) + self._repository.save_credential( + replace( + credential, + password_hash=new_hash, + must_change_password=False, + failed_login_count=0, + locked_until=None, + password_changed_at=now, + updated_at=now, + ) + ) + self._repository.save_user(replace(user, updated_at=now, version=user.version + 1)) + revoked = self._repository.revoke_sessions(user.id, reason="password_rotated", now=now) + session = self._issue_session(user, now=now, must_change_password=False) + self._audit( + action=ACTION_AUTH_FIRST_PASSWORD_CHANGED, + outcome="success", + actor=principal, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"revoked_sessions": revoked, "username": user.username}, + ) + LOGGER.info("first password changed user_id=%s revoked_sessions=%s", user.id, revoked) + return AuthenticationResult(user=user, session=session) + + # -- sessions ------------------------------------------------------------ + + def resolve_session(self, token: str, *, request_id: str | None = None) -> Principal: + """Resolve a session token into a principal, or raise a 401-equivalent.""" + + now = self._now() + if not isinstance(token, str) or not token: + raise InvalidCredentialsError() + session = self._repository.get_session_by_token_hash(self._token_hash(token)) + if session is None or session.is_revoked(): + raise InvalidCredentialsError() + user = self._repository.get_user(session.user_id) + if user is None: + raise InvalidCredentialsError() + if session.is_expired(now): + self._repository.save_session(replace(session, revoked_at=now, revoked_reason="expired")) + self._audit( + action=ACTION_SESSION_EXPIRED, + outcome="failure", + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"reason": "deadline_exceeded"}, + ) + raise InvalidCredentialsError() + if user.status is not AccountStatus.ACTIVE: + revoked = self._repository.revoke_sessions(user.id, reason=f"account_{user.status.value}", 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={"status": user.status, "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) + return Principal( + user_id=user.id, + username=user.username, + role=user.role, + session_id=session.id, + organization_id=user.organization_id, + must_change_password=must_change, + ) + + def logout(self, principal: Principal, *, request_id: str | None = None) -> int: + """Revoke only the caller's current session. + + Other sessions of the same account survive; ``logout_everywhere`` is + the explicit way to end all of them. + """ + + now = self._now() + revoked = 0 + session = self._repository.get_session(principal.session_id) + if session is not None and not session.is_revoked(): + self._repository.save_session(replace(session, revoked_at=now, revoked_reason="logout")) + revoked = 1 + self._audit( + action=ACTION_AUTH_LOGOUT, + outcome="success", + actor=principal, + target_user_id=principal.user_id, + request_id=request_id, + detail={"revoked_sessions": revoked}, + ) + return revoked + + def logout_everywhere(self, principal: Principal, *, request_id: str | None = None) -> int: + """Revoke every session belonging to the caller, including the current one.""" + + now = self._now() + revoked = self._repository.revoke_sessions(principal.user_id, reason="logout_everywhere", now=now) + self._audit( + action=ACTION_AUTH_LOGOUT_EVERYWHERE, + outcome="success", + actor=principal, + target_user_id=principal.user_id, + request_id=request_id, + detail={"revoked_sessions": revoked}, + ) + LOGGER.info("logout everywhere user_id=%s revoked_sessions=%s", principal.user_id, revoked) + return revoked + + # -- administration ------------------------------------------------------ + + def self_view(self, user_id: UUID) -> AccountView: + """Return the sanitized view of one account for its own profile. + + The caller must already be authenticated as that account; no + organization scoping is needed because the identifier comes from the + resolved principal, never from the browser. + """ + + return self._view(self._require_user(user_id), now=self._now()) + + def list_accounts( + self, + actor: Principal, + *, + organization_id: UUID | None = None, + status: AccountStatus | None = None, + role: RoleName | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AccountView], int]: + """List accounts. An organization administrator never sees other organizations.""" + + self._require_admin(actor) + scope = self._admin_scope(actor, organization_id) + users, total = self._repository.find_users( + organization_id=scope, status=status, role=str(role) if role else None, limit=limit, offset=offset + ) + now = self._now() + return [self._view(user, now=now, visible_to=actor) for user in users], total + + def get_account(self, actor: Principal, user_id: UUID) -> AccountView: + self._require_admin(actor) + user = self._require_user(user_id) + self._authorize_target(actor, user) + return self._view(user, now=self._now(), visible_to=actor) + + def set_account_status( + self, + actor: Principal, + user_id: UUID, + status: str, + *, + reason: str | None = None, + request_id: str | None = None, + ) -> tuple[AccountView, int]: + """Suspend, reactivate, or deactivate an account and revoke its sessions.""" + + self._require_admin(actor) + user = self._require_user(user_id) + self._authorize_target(actor, user) + new_status = _account_status(status) + if new_status is user.status: + raise StateTransitionError("status_unchanged", "The account already has that status.") + if new_status not in ALLOWED_STATUS_TRANSITIONS[user.status]: + raise StateTransitionError() + if new_status is not AccountStatus.ACTIVE and user.role is RoleName.PLATFORM_ADMIN: + if self._count_active_platform_admins() <= 1: + raise ConflictError( + "last_platform_admin", + "The last active platform administrator cannot be suspended or deactivated.", + ) + if ( + new_status is AccountStatus.ACTIVE + and user.status is AccountStatus.DEACTIVATED + and actor.role is not RoleName.PLATFORM_ADMIN + ): + raise ForbiddenError( + "reactivation_requires_platform_admin", + "Only a platform administrator may reactivate a deactivated account.", + ) + + now = self._now() + updated = replace(user, status=new_status, updated_at=now, version=user.version + 1) + self._repository.save_user(updated) + revoked = 0 + if new_status is not AccountStatus.ACTIVE: + revoked = self._repository.revoke_sessions(user.id, reason=f"account_{new_status.value}", now=now) + self._audit( + action=ACTION_ACCOUNT_STATUS_CHANGED, + outcome="success", + actor=actor, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={ + "status": new_status, + "previous_status": user.status, + "reason": normalize_reason(reason), + "role": user.role, + "revoked_sessions": revoked, + }, + ) + LOGGER.info("account status changed user_id=%s status=%s revoked_sessions=%s", user.id, new_status, revoked) + return self._view(updated, now=now, visible_to=actor), revoked + + def revoke_sessions(self, actor: Principal, user_id: UUID, *, request_id: str | None = None) -> int: + """Administrative session revocation for one account.""" + + self._require_admin(actor) + user = self._require_user(user_id) + self._authorize_target(actor, user) + now = self._now() + revoked = self._repository.revoke_sessions(user.id, reason="administrative_revocation", now=now) + self._audit( + action=ACTION_SESSION_REVOKED, + outcome="success", + actor=actor, + target_user_id=user.id, + organization_id=user.organization_id, + request_id=request_id, + detail={"revoked_sessions": revoked, "reason": "administrative_revocation"}, + ) + return revoked + + def list_audit_events( + self, + actor: Principal, + *, + target_user_id: UUID | None = None, + limit: int = 50, + offset: int = 0, + ) -> tuple[list[AuditEvent], int]: + """List sanitized audit events within the caller's administrative scope.""" + + self._require_admin(actor) + scope = self._admin_scope(actor, None) + if target_user_id is not None: + self._authorize_target(actor, self._require_user(target_user_id)) + events, total = self._repository.list_audit_events( + organization_id=scope, + target_user_id=target_user_id, + limit=limit, + offset=offset, + ) + return list(events), total + + # -- internals ----------------------------------------------------------- + + def _token_hash(self, token: str) -> str: + return hash_session_token(token, pepper=self._settings.token_pepper) + + def _issue_session(self, user: User, *, now: datetime, must_change_password: bool) -> IssuedSession: + token = generate_token() + absolute_deadline = now + timedelta(seconds=self._settings.session_absolute_seconds) + idle_deadline = min(now + timedelta(seconds=self._settings.session_idle_seconds), absolute_deadline) + session = SessionRecord( + id=uuid4(), + user_id=user.id, + token_hash=self._token_hash(token), + created_at=now, + last_seen_at=now, + idle_deadline_at=idle_deadline, + absolute_deadline_at=absolute_deadline, + mfa_verified_at=None, + ) + self._repository.create_session(session) + return IssuedSession( + session_id=session.id, + token=token, + idle_expires_at=idle_deadline, + absolute_expires_at=absolute_deadline, + must_change_password=must_change_password, + ) + + def _touch_session(self, session: SessionRecord, *, now: datetime) -> None: + """Refresh ``last_seen_at`` at most once per configured interval.""" + + interval = self._settings.last_seen_write_interval_seconds + if interval <= 0 or (now - session.last_seen_at).total_seconds() < interval: + return + idle_deadline = min(now + timedelta(seconds=self._settings.session_idle_seconds), session.absolute_deadline_at) + self._repository.save_session( + replace(session, last_seen_at=now, idle_deadline_at=max(idle_deadline, session.idle_deadline_at)) + ) + + def _require_user(self, user_id: UUID) -> User: + user = self._repository.get_user(user_id) + if user is None: + raise NotFoundError("account_not_found") + return user + + def _require_admin(self, actor: Principal | None) -> None: + if actor is None: + raise ForbiddenError("administrator_required") + if actor.must_change_password: + raise PasswordChangeRequiredError() + if not actor.is_admin: + raise ForbiddenError() + + def _admin_scope(self, actor: Principal, requested_organization: UUID | None) -> UUID | None: + """Return the organization filter an administrator may use. + + An organization administrator is always pinned to their own + organization; a browser-supplied organization identifier is ignored. + """ + + if actor.role is RoleName.PLATFORM_ADMIN: + return requested_organization + if actor.organization_id is None: + return None + return actor.organization_id + + def _authorize_target(self, actor: Principal, target: User) -> None: + if actor.role is RoleName.PLATFORM_ADMIN: + return + if target.role is RoleName.PLATFORM_ADMIN: + raise ForbiddenError("platform_admin_only") + if actor.organization_id is None or target.organization_id != actor.organization_id: + raise ForbiddenError("cross_organization_forbidden") + + def _authorize_creation(self, actor: Principal, *, role: str, organization_id: UUID | None) -> None: + """Stop an organization administrator escalating its own scope. + + An organization administrator may only create members inside its own + organization. A browser-supplied organization or role is never honoured + outside that scope. + """ + + if actor.role is RoleName.PLATFORM_ADMIN: + return + if _role(role) is RoleName.PLATFORM_ADMIN: + raise ForbiddenError("platform_admin_only") + if organization_id != actor.organization_id: + raise ForbiddenError("cross_organization_forbidden") + + def _count_active_platform_admins(self) -> int: + users, _ = self._repository.find_users(role=str(RoleName.PLATFORM_ADMIN), limit=10_000) + return sum(1 for user in users if user.status is AccountStatus.ACTIVE) + + def _view(self, user: User, *, now: datetime, visible_to: Principal | None = None) -> AccountView: + credential = self._repository.get_credential(user.id) + summary = ( + credential.public_summary(now=now) + if credential is not None + else CredentialSummary( + credential_type=CredentialType.OIDC, + must_change_password=False, + failed_login_count=0, + locked=False, + ) + ) + return AccountView( + id=user.id, + username=user.username, + display_name=user.display_name, + role=user.role, + status=user.status, + credential=summary, + organization_id=user.organization_id, + email=user.email, + created_at=user.created_at, + updated_at=user.updated_at, + active_session_count=self._repository.count_sessions(user.id), + ) + + def _audit( + self, + *, + action: str, + outcome: str, + actor: Principal | None = None, + target_user_id: UUID | None = None, + organization_id: UUID | None = None, + request_id: str | None = None, + detail: Mapping[str, object] | None = None, + ) -> AuditEvent: + event = AuditEvent( + id=uuid4(), + occurred_at=self._now(), + action=action, + outcome=outcome, + actor_user_id=actor.user_id if actor else None, + target_user_id=target_user_id, + organization_id=organization_id, + request_id=request_id, + detail=MappingProxyType(sanitize_audit_detail(detail)), + ) + self._repository.append_audit_event(event) + LOGGER.info( + "audit action=%s outcome=%s actor=%s target=%s request_id=%s", + action, + outcome, + event.actor_user_id, + event.target_user_id, + event.request_id, + ) + return event + + +def _enrollment_mode(value: str) -> EnrollmentMode: + try: + return EnrollmentMode(value) + except ValueError as error: + raise InputError("invalid_enrollment_mode", "Unknown enrollment mode.") from error + + +def _account_status(value: str) -> AccountStatus: + try: + return AccountStatus(value) + except ValueError as error: + raise InputError("invalid_status", "Unknown account status.") from error + + +def _role(value: str) -> RoleName: + try: + return RoleName(value) + except ValueError as error: + raise InputError("invalid_role", "Unknown role.") from error + + +def _safe_username_key(username: str) -> str | None: + if not isinstance(username, str): + return None + try: + return username_key(username) + except InputError: + return None + + +__all__ = [ + "ACTION_ACCOUNT_CREATED", + "ACTION_ACCOUNT_PROVISIONED_LOCAL", + "ACTION_ACCOUNT_STATUS_CHANGED", + "ACTION_AUTH_FIRST_PASSWORD_CHANGED", + "ACTION_AUTH_LOGIN", + "ACTION_AUTH_LOGOUT", + "ACTION_AUTH_LOGOUT_EVERYWHERE", + "ACTION_SESSION_EXPIRED", + "ACTION_SESSION_INVALIDATED", + "ACTION_SESSION_REVOKED", + "ADMIN_ENROLLMENT_SOURCE", + "OPERATOR_SOURCES", + "AccountService", + "IssuedTemporaryPassword", + "OperatorContext", +] diff --git a/umbratrans_web/tests/__init__.py b/umbratrans_web/tests/__init__.py new file mode 100644 index 0000000..baf428e --- /dev/null +++ b/umbratrans_web/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for the UmbraTrans web account slice.""" diff --git a/umbratrans_web/tests/conftest.py b/umbratrans_web/tests/conftest.py new file mode 100644 index 0000000..94f2fb1 --- /dev/null +++ b/umbratrans_web/tests/conftest.py @@ -0,0 +1,236 @@ +"""Shared test fixtures. + +Every test runs against the in-memory repository with fast Argon2 parameters. +No test reads a production configuration, touches PostgreSQL, or contains a +password literal: candidate passwords are generated at run time by +:func:`random_password`, so the repository contains no credential value, real +or otherwise. +""" + +from __future__ import annotations + +import secrets +import string +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +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.repository import InMemoryAccountRepository +from umbratrans_web.service import AccountService, OperatorContext + +ALPHABET = string.ascii_letters + string.digits + "-_.@" + + +def random_password(length: int = 24) -> str: + """Return a random password for one test run. + + Generated rather than literal so no fixture file, snapshot, or failure + message can ever carry credential material. + """ + + return "".join(secrets.choice(ALPHABET) for _ in range(length)) + + +@dataclass +class SeededAccount: + """A test account plus the plaintext password used to create it.""" + + view: AccountView + password: str + user_id: UUID + username: str + + +class ApiHarness: + """Small helper that keeps CSRF and session cookies consistent.""" + + def __init__(self, client: TestClient, settings: Settings) -> None: + self._client = client + self._settings = settings + + @property + def client(self) -> TestClient: + return self._client + + def csrf(self) -> str: + value = self._client.cookies.get(self._settings.csrf_cookie_name) + assert value, "no CSRF cookie has been issued yet" + return str(value) + + def post(self, path: str, **kwargs: Any) -> Any: + headers = dict(kwargs.pop("headers", {})) + headers.setdefault(self._settings.csrf_header_name, self.csrf()) + return self._client.post(path, headers=headers, **kwargs) + + def patch(self, path: str, **kwargs: Any) -> Any: + headers = dict(kwargs.pop("headers", {})) + headers.setdefault(self._settings.csrf_header_name, self.csrf()) + return self._client.patch(path, headers=headers, **kwargs) + + def get(self, path: str, **kwargs: Any) -> Any: + """Safe request. The CSRF header is accepted but not required here.""" + + headers = dict(kwargs.pop("headers", {})) + token = self._client.cookies.get(self._settings.csrf_cookie_name) + if token: + headers.setdefault(self._settings.csrf_header_name, str(token)) + return self._client.get(path, headers=headers, **kwargs) + + def login(self, username: str, password: str) -> Any: + return self._client.post("/api/auth/login", json={"username": username, "password": password}) + + def login_ok(self, account: SeededAccount) -> Any: + response = self.login(account.username, account.password) + assert response.status_code == 200, response.text + return response + + def logout(self) -> None: + self._client.cookies.clear() + + +@pytest.fixture +def settings() -> Settings: + return suite_settings() + + +@pytest.fixture +def repository() -> InMemoryAccountRepository: + return InMemoryAccountRepository() + + +@pytest.fixture +def service(repository: InMemoryAccountRepository, settings: Settings) -> AccountService: + return AccountService(repository, settings) + + +@pytest.fixture +def app(repository: InMemoryAccountRepository, settings: Settings, service: AccountService) -> Any: + return create_app(settings=settings, repository=repository, service=service) + + +@pytest.fixture +def client(app: Any) -> Iterator[TestClient]: + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture +def api(client: TestClient, settings: Settings) -> ApiHarness: + return ApiHarness(client, settings) + + +def provision_local( + service: AccountService, + *, + username: str = "Operator.User", + password: str | None = None, + display_name: str = "Local Operator", + role: str = str(RoleName.PLATFORM_ADMIN), + organization_id: UUID | None = None, +) -> SeededAccount: + """Create a local temporary-password account through the operator path.""" + + candidate = password or random_password() + view = service.create_account( + enrollment_mode="temporary_password", + username=username, + display_name=display_name, + role=role, + organization_id=organization_id, + password=candidate, + operator=OperatorContext(source="operator-cli"), + ) + return SeededAccount(view=view, password=candidate, user_id=view.id, username=view.username) + + +def provision_oidc( + service: AccountService, + actor: Principal, + *, + username: str = "Oidc.User", + display_name: str = "OIDC User", + role: str = str(RoleName.MEMBER), + organization_id: UUID | None = None, +) -> SeededAccount: + """Create an OIDC-enrollment account as an administrator.""" + + view = service.create_oidc_account( + username=username, + display_name=display_name, + role=role, + organization_id=organization_id, + actor=actor, + ) + return SeededAccount(view=view, password="", user_id=view.id, username=view.username) + + +def activated_admin( + service: AccountService, + *, + username: str = "Umbraadmin", + role: str = str(RoleName.PLATFORM_ADMIN), + organization_id: UUID | None = None, +) -> SeededAccount: + """Provision an admin and complete its first-login password change. + + Returns the account with the *new* password, so tests can sign in over HTTP + with a session that is not restricted to the password-change route. + """ + + account = provision_local(service, username=username, role=role, organization_id=organization_id) + result = service.authenticate(username=account.username, password=account.password) + principal = service.resolve_session(result.session.token) + new_password = random_password(28) + service.change_first_password(principal, current_password=account.password, new_password=new_password) + return SeededAccount( + view=service.self_view(account.user_id), + password=new_password, + user_id=account.user_id, + username=account.username, + ) + + +def admin_principal(account: SeededAccount, *, organization_id: UUID | None = None) -> Principal: + """Build a principal directly, for service tests that skip HTTP.""" + + return Principal( + user_id=account.user_id, + username=account.username, + role=RoleName(account.view.role), + session_id=secrets.token_hex(8), + organization_id=organization_id if organization_id is not None else account.view.organization_id, + ) + + +def member_principal(account: SeededAccount, *, organization_id: UUID | None = None) -> Principal: + return Principal( + user_id=account.user_id, + username=account.username, + role=RoleName.MEMBER, + session_id=secrets.token_hex(8), + organization_id=organization_id if organization_id is not None else account.view.organization_id, + ) + + +__all__ = [ + "ALPHABET", + "ApiHarness", + "SeededAccount", + "activated_admin", + "admin_principal", + "client", + "member_principal", + "provision_local", + "provision_oidc", + "random_password", + "repository", + "service", + "settings", +] diff --git a/umbratrans_web/tests/test_api_auth.py b/umbratrans_web/tests/test_api_auth.py new file mode 100644 index 0000000..faef90f --- /dev/null +++ b/umbratrans_web/tests/test_api_auth.py @@ -0,0 +1,408 @@ +"""HTTP authentication tests: health, login, first-password change, sessions.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from umbratrans_web.app import create_app +from umbratrans_web.config import Environment, Settings +from umbratrans_web.errors import ConfigurationError +from umbratrans_web.repository import InMemoryAccountRepository +from umbratrans_web.service import AccountService + +from .conftest import ApiHarness, activated_admin, provision_local, random_password + +# ---------------------------------------------------------------------- +# Health +# ---------------------------------------------------------------------- + + +def test_health_is_public_and_discloses_no_secret(client: TestClient) -> None: + response = client.get("/api/health") + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["service"] == "umbratrans-web" + assert body["environment"] == "test" + assert body["repository"] == "memory" + assert set(body) == { + "status", + "service", + "version", + "environment", + "repository", + "cookie_secure", + "csrf_required", + "time", + } + text = response.text.lower() + for forbidden in ("password", "token", "secret", "argon2", "postgres://", "dsn"): + assert forbidden not in text + + +def test_api_responses_are_not_cached_and_carry_a_request_id(client: TestClient) -> None: + response = client.get("/api/health") + + assert response.headers["cache-control"] == "no-store" + assert response.headers["x-content-type-options"] == "nosniff" + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["referrer-policy"] == "no-referrer" + assert response.headers["x-request-id"] + + +def test_request_id_is_echoed_when_supplied(client: TestClient) -> None: + response = client.get("/api/health", headers={"X-Request-Id": "trace-1234"}) + assert response.headers["x-request-id"] == "trace-1234" + + +# ---------------------------------------------------------------------- +# Login +# ---------------------------------------------------------------------- + + +def test_login_sets_a_private_session_cookie_and_no_token_in_the_body( + api: ApiHarness, client: TestClient, settings: Settings +) -> None: + account = provision_local(_service_of(api)) + + response = api.login_ok(account) + + assert response.status_code == 200 + body = response.json() + assert body["must_change_password"] is True + assert body["user"]["username"] == account.username + assert body["csrf_token"] == api.csrf() + + cookie_header = response.headers["set-cookie"] + assert f"{settings.session_cookie_name}=" in cookie_header + assert "HttpOnly" in cookie_header + assert "SameSite=lax" in cookie_header + assert "Path=/" in cookie_header + # Development only: the flag is configurable and must be secure in production. + assert "Secure" not in cookie_header + assert client.cookies[settings.session_cookie_name] + + assert set(body) == { + "user", + "must_change_password", + "csrf_token", + "idle_expires_at", + "absolute_expires_at", + } + serialized = response.text + assert account.password not in serialized + assert "$argon2" not in serialized + assert "password_hash" not in serialized + + +def test_stored_session_token_is_never_the_cookie_value( + api: ApiHarness, client: TestClient, settings: Settings, service: AccountService +) -> None: + account = activated_admin(service) + api.login_ok(account) + + token = client.cookies[settings.session_cookie_name] + stored = [session for session in service.repository.list_sessions(account.user_id, include_revoked=True)] + assert stored + assert all(session.token_hash != token for session in stored) + assert all(token not in session.token_hash for session in stored) + + +def test_login_failures_are_generic(api: ApiHarness, service: AccountService) -> None: + """Unknown user, wrong password and suspended account are indistinguishable.""" + + account = provision_local(service, username="Umbraadmin") + suspended_account = provision_local(service, username="Suspended.User", role="member") + + admin = activated_admin(service, username="Admin.Operator") + api.login_ok(admin) + api.patch( + f"/api/uadmin/accounts/{suspended_account.user_id}/status", + json={"status": "suspended", "reason": "policy review"}, + ) + api.logout() + + unknown = api.login("Nobody.Here", random_password()) + wrong = api.login(account.username, random_password()) + suspended = api.login(suspended_account.username, suspended_account.password) + + assert unknown.status_code == wrong.status_code == suspended.status_code == 401 + assert unknown.json() == wrong.json() == suspended.json() + assert unknown.json()["error"]["code"] == "invalid_credentials" + # The audit trail keeps the real reason; the response does not. + events, _ = service.repository.list_audit_events(target_user_id=suspended_account.user_id) + reasons = {event.detail.get("reason") for event in events if event.action == "auth.login"} + assert "account_suspended" in reasons + + +def test_validation_errors_do_not_echo_the_submitted_password(client: TestClient) -> None: + # Longer than the accepted maximum, so the request is rejected by the + # schema. FastAPI's default error body would embed this value. + password = random_password(1200) + response = client.post("/api/auth/login", json={"username": "Umbraadmin", "password": password}) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "invalid_request" + assert response.json()["error"]["fields"] == ["password"] + assert password not in response.text + assert "input" not in response.json()["error"] + + +def test_missing_credentials_are_rejected_without_detail_leak(client: TestClient) -> None: + response = client.post("/api/auth/login", json={}) + assert response.status_code == 422 + assert response.json()["error"]["fields"] == ["password", "username"] + assert "input" not in response.text + + +def test_login_rejects_unknown_fields(client: TestClient) -> None: + response = client.post( + "/api/auth/login", + json={"username": "Umbraadmin", "password": random_password(), "role": "platform_admin"}, + ) + assert response.status_code == 422 + + +# ---------------------------------------------------------------------- +# First-password change +# ---------------------------------------------------------------------- + + +def test_must_change_session_cannot_reach_other_routes( + api: ApiHarness, client: TestClient, service: AccountService +) -> None: + account = provision_local(service, username="Umbraadmin") + api.login_ok(account) + + me = client.get("/api/auth/me") + accounts = client.get("/api/uadmin/accounts") + + assert me.status_code == 403 + assert me.json()["error"]["code"] == "password_change_required" + assert accounts.status_code == 403 + assert accounts.json()["error"]["code"] == "password_change_required" + + +def test_first_password_change_completes_the_flow( + api: ApiHarness, client: TestClient, settings: Settings, service: AccountService +) -> None: + account = provision_local(service, username="Umbraadmin") + api.login_ok(account) + old_cookie = client.cookies[settings.session_cookie_name] + new_password = random_password(28) + + response = api.post( + "/api/auth/change-first-password", + json={"current_password": account.password, "new_password": new_password}, + ) + + assert response.status_code == 200 + assert response.json()["must_change_password"] is False + new_cookie = client.cookies[settings.session_cookie_name] + assert new_cookie != old_cookie + + # The rotated session works, the old one is gone, and the temporary + # password no longer authenticates. + assert client.get("/api/auth/me").status_code == 200 + api.logout() + assert api.login(account.username, account.password).status_code == 401 + assert api.login(account.username, new_password).status_code == 200 + + +def test_first_password_change_validates_the_temporary_password(api: ApiHarness, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + api.login_ok(account) + + wrong = api.post( + "/api/auth/change-first-password", + json={"current_password": random_password(), "new_password": random_password(28)}, + ) + weak = api.post( + "/api/auth/change-first-password", + json={"current_password": account.password, "new_password": "short"}, + ) + + assert wrong.status_code == 401 + assert wrong.json()["error"]["code"] == "invalid_credentials" + assert weak.status_code == 422 + assert weak.json()["error"]["code"] == "password_policy" + # Unmet rules are named; the candidate value is never echoed. + assert "min_length" in weak.json()["error"]["requirements"] + assert "short" not in weak.text + + +def test_first_password_change_requires_a_csrf_token( + api: ApiHarness, client: TestClient, service: AccountService +) -> None: + account = provision_local(service, username="Umbraadmin") + api.login_ok(account) + + response = client.post( + "/api/auth/change-first-password", + json={"current_password": account.password, "new_password": random_password(28)}, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] == "csrf_token_missing" + + +def test_csrf_token_mismatch_is_rejected(api: ApiHarness, client: TestClient, settings: Settings) -> None: + account = activated_admin(_service_of(api)) + api.login_ok(account) + + response = client.post( + "/api/auth/logout", + headers={settings.csrf_header_name: "not-the-right-token"}, + ) + + assert response.status_code == 403 + assert response.json()["error"]["code"] in {"csrf_token_missing", "csrf_token_invalid"} + + +# ---------------------------------------------------------------------- +# Sessions +# ---------------------------------------------------------------------- + + +def test_logout_revokes_the_session_and_clears_the_cookie( + api: ApiHarness, client: TestClient, settings: Settings, service: AccountService +) -> None: + account = activated_admin(service) + api.login_ok(account) + + response = api.post("/api/auth/logout") + + assert response.status_code == 200 + assert response.json()["revoked_sessions"] == 1 + assert "Max-Age=0" in response.headers["set-cookie"] or "max-age=0" in response.headers["set-cookie"] + assert client.get("/api/auth/me").status_code == 401 + assert api.login(account.username, account.password).status_code == 200 + + +def test_logout_everywhere_ends_all_sessions( + api: ApiHarness, client: TestClient, settings: Settings, service: AccountService +) -> None: + account = activated_admin(service) + api.login_ok(account) + first_cookie = client.cookies[settings.session_cookie_name] + + # A second browser session for the same account. + with TestClient(client.app) as second: + second.post("/api/auth/login", json={"username": account.username, "password": account.password}) + second_cookie = second.cookies[settings.session_cookie_name] + assert second_cookie != first_cookie + + live_before = len(service.repository.list_sessions(account.user_id)) + assert live_before >= 2 + + response = api.post("/api/auth/logout-everywhere") + assert response.status_code == 200 + assert response.json()["revoked_sessions"] == live_before + + assert second.get("/api/auth/me").status_code == 401 + assert client.get("/api/auth/me").status_code == 401 + assert service.repository.list_sessions(account.user_id) == [] + + +def test_me_requires_a_session(client: TestClient) -> None: + response = client.get("/api/auth/me") + assert response.status_code == 401 + assert response.json()["error"]["code"] == "authentication_required" + assert response.headers["x-umbratrans-reauthenticate"] == "1" + + +def test_me_returns_only_the_callers_own_identity(api: ApiHarness, service: AccountService) -> None: + account = activated_admin(service, username="Admin.One") + other = provision_local(service, username="Other.User", role="member") + api.login_ok(account) + + response = api.client.get("/api/auth/me") + body = response.json() + + assert body["user"]["id"] == str(account.user_id) + assert body["user"]["username"] == account.username + assert body["role"] == "platform_admin" + assert body["must_change_password"] is False + assert str(other.user_id) not in response.text + assert other.password not in response.text + assert "password_hash" not in response.text + + +def test_forged_session_cookie_is_rejected(client: TestClient, settings: Settings) -> None: + client.cookies.set(settings.session_cookie_name, random_password(40)) + response = client.get("/api/auth/me") + assert response.status_code == 401 + + +# ---------------------------------------------------------------------- +# Cookie hardening +# ---------------------------------------------------------------------- + + +def test_production_cookies_are_secure(settings: Settings) -> None: + production = Settings(environment=Environment.PRODUCTION, repository_kind="postgres", cookie_secure=True) + repository = InMemoryAccountRepository() + service = AccountService(repository, production) + app = create_app(settings=production, repository=repository, service=service) + account = activated_admin(service, username="Umbraadmin") + + with TestClient(app) as client: + response = client.post("/api/auth/login", json={"username": account.username, "password": account.password}) + cookie_header = response.headers["set-cookie"] + + assert response.status_code == 200 + assert "Secure" in cookie_header + assert "HttpOnly" in cookie_header + assert "SameSite=lax" in cookie_header + + +def test_production_refuses_insecure_configuration() -> None: + with pytest.raises(ConfigurationError) as error: + Settings(environment=Environment.PRODUCTION, repository_kind="postgres", cookie_secure=False) + assert error.value.code == "insecure_production_cookie" + + with pytest.raises(ConfigurationError) as csrf_error: + Settings( + environment=Environment.PRODUCTION, + repository_kind="postgres", + cookie_secure=True, + require_csrf=False, + ) + assert csrf_error.value.code == "insecure_production_csrf" + + with pytest.raises(ConfigurationError) as repository_error: + Settings(environment=Environment.PRODUCTION, repository_kind="memory", cookie_secure=True) + assert repository_error.value.code == "production_repository_missing" + + +def test_production_app_requires_an_explicit_repository() -> None: + production = Settings(environment=Environment.PRODUCTION, repository_kind="postgres", cookie_secure=True) + with pytest.raises(ConfigurationError) as error: + create_app(settings=production) + assert error.value.code == "production_repository_missing" + + +def test_wildcard_hosts_are_refused() -> None: + with pytest.raises(ConfigurationError) as error: + Settings(environment=Environment.TEST, allowed_hosts=("example.test", "*")) + assert error.value.code == "wildcard_host_forbidden" + + +def test_allowed_hosts_are_enforced_when_configured() -> None: + settings = Settings( + environment=Environment.TEST, + allowed_hosts=("umbratrans.bdmurphy.me",), + argon2_memory_cost_kib=8 * 1024, + argon2_time_cost=1, + argon2_parallelism=1, + ) + app = create_app(settings=settings, repository=InMemoryAccountRepository()) + with TestClient(app) as client: + assert client.get("/api/health", headers={"Host": "umbratrans.bdmurphy.me"}).status_code == 200 + assert client.get("/api/health", headers={"Host": "attacker.example"}).status_code == 400 + + +def _service_of(api: ApiHarness) -> AccountService: + return api.client.app.state.service diff --git a/umbratrans_web/tests/test_api_uadmin.py b/umbratrans_web/tests/test_api_uadmin.py new file mode 100644 index 0000000..89a53ed --- /dev/null +++ b/umbratrans_web/tests/test_api_uadmin.py @@ -0,0 +1,520 @@ +"""HTTP administrator tests: authorization, enrollment, lifecycle, audit.""" + +from __future__ import annotations + +import json +from uuid import UUID, uuid4 + +import pytest +from fastapi.testclient import TestClient + +from umbratrans_web.service import AccountService + +from .conftest import ApiHarness, activated_admin, provision_local, random_password + + +@pytest.fixture +def admin(api: ApiHarness, service: AccountService) -> object: + """An activated platform administrator, signed in over HTTP.""" + + account = activated_admin(service, username="Umbraadmin") + api.login_ok(account) + return account + + +# ---------------------------------------------------------------------- +# Authorization +# ---------------------------------------------------------------------- + + +def test_admin_routes_require_a_session(client: TestClient) -> None: + for method, path in [ + ("get", "/api/uadmin/accounts"), + ("get", "/api/uadmin/audit"), + ("get", f"/api/uadmin/accounts/{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/accounts"), + ("post", f"/api/uadmin/accounts/{uuid4()}/revoke-sessions"), + ]: + response = getattr(client, method)(path, json={}) + # Rejected by CSRF first, then by authentication; either way, never 2xx. + assert response.status_code in {401, 403}, path + assert response.json()["error"]["code"] in { + "authentication_required", + "csrf_token_missing", + "csrf_token_invalid", + } + + +def test_members_are_forbidden(api: ApiHarness, service: AccountService) -> None: + member = provision_local(service, username="Member.User", role="member") + activated_admin(service, username="Admin.Helper") + api.login_ok(member) # the temporary password is all this session can do + + # Complete the change so the session is usable, then retry. + new_password = random_password(28) + response = api.post( + "/api/auth/change-first-password", + json={"current_password": member.password, "new_password": new_password}, + ) + assert response.status_code == 200 + + for method, path, payload in [ + ("get", "/api/uadmin/accounts", None), + ("get", "/api/uadmin/audit", None), + ("get", f"/api/uadmin/accounts/{member.user_id}", None), + ("post", "/api/uadmin/accounts", {"username": "New.User", "display_name": "New User"}), + ("patch", f"/api/uadmin/accounts/{member.user_id}/status", {"status": "suspended"}), + ("post", f"/api/uadmin/accounts/{member.user_id}/revoke-sessions", None), + ]: + call = getattr(api, method)(path, **({"json": payload} if payload is not None else {})) + assert call.status_code == 403, f"{method} {path} -> {call.status_code}" + assert call.json()["error"]["code"] == "forbidden" + + +def test_temporary_password_accounts_cannot_reach_admin_routes(api: ApiHarness, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin", role="platform_admin") + api.login_ok(account) + response = api.get("/api/uadmin/accounts") + assert response.status_code == 403 + assert response.json()["error"]["code"] == "password_change_required" + + +# ---------------------------------------------------------------------- +# Account creation +# ---------------------------------------------------------------------- + + +def test_create_oidc_account(api: ApiHarness, admin: object, service: AccountService) -> None: + response = api.post( + "/api/uadmin/accounts", + json={ + "username": "Oidc.User", + "display_name": "OIDC User", + "enrollment_mode": "oidc", + "role": "member", + "email": "Oidc.User@Example.COM", + }, + ) + + assert response.status_code == 201 + body = response.json() + assert body["temporary_password"] is None + account = body["account"] + assert account["username"] == "Oidc.User" + assert account["email"] == "oidc.user@example.com" + assert account["status"] == "active" + assert account["credential"] == { + "credential_type": "oidc", + "must_change_password": False, + "failed_login_count": 0, + "locked": False, + "last_login_at": None, + "password_changed_at": None, + } + assert "password_hash" not in response.text + + stored = service.repository.get_credential(UUID(account["id"])) + assert stored is not None + assert stored.password_hash is None + + +def test_create_account_refuses_a_submitted_password_in_either_mode(api: ApiHarness, admin: object) -> None: + password = random_password(28) + for mode in ("oidc", "temporary_password"): + response = api.post( + "/api/uadmin/accounts", + json={ + "username": f"Sneaky.User.{mode}", + "display_name": "Sneaky User", + "enrollment_mode": mode, + "password": password, + }, + ) + + assert response.status_code == 422 + assert response.json()["error"]["code"] == "invalid_request" + assert password not in response.text + + +def test_admin_provisions_a_temporary_password_account( + api: ApiHarness, + admin: object, + service: AccountService, +) -> None: + response = api.post( + "/api/uadmin/accounts", + json={ + "username": "Temp.User", + "display_name": "Temp User", + "enrollment_mode": "temporary_password", + "role": "member", + "email": "Temp.User@Example.com", + }, + ) + + assert response.status_code == 201 + body = response.json() + account = body["account"] + temporary_password = body["temporary_password"] + + # The one-time password is returned once, by this endpoint only. + assert isinstance(temporary_password, str) + assert len(temporary_password) >= 14 + assert any(character.isdigit() for character in temporary_password) + assert any(not character.isalnum() for character in temporary_password) + assert account["credential"]["credential_type"] == "local_password" + assert account["credential"]["must_change_password"] is True + assert account["status"] == "active" + assert "$argon2" not in response.text + + # It is never stored, logged, or audited in the clear. + stored = service.repository.get_credential(UUID(account["id"])) + assert stored is not None + assert stored.password_hash is not None + assert stored.password_hash.startswith("$argon2id$") + assert temporary_password not in stored.password_hash + assert temporary_password not in repr(stored) + assert temporary_password not in json.dumps( + [event.detail for event in service.repository.list_audit_events()[0]], + default=str, + ) + # No other endpoint will ever repeat it. + assert temporary_password not in api.get(f"/api/uadmin/accounts/{account['id']}").text + assert temporary_password not in api.get("/api/uadmin/accounts").text + assert temporary_password not in api.get("/api/uadmin/audit").text + + +def test_generated_temporary_password_must_be_replaced_on_first_login( + api: ApiHarness, + admin: object, +) -> None: + created = api.post( + "/api/uadmin/accounts", + json={ + "username": "First.Login", + "display_name": "First Login", + "enrollment_mode": "temporary_password", + }, + ) + assert created.status_code == 201 + username = created.json()["account"]["username"] + temporary_password = created.json()["temporary_password"] + + api.logout() + login = api.login(username, temporary_password) + assert login.status_code == 200 + assert login.json()["must_change_password"] is True + + # The temporary session can do nothing except replace the password: every + # other route, including /api/auth/me, is refused until the change happens. + for path in ("/api/uadmin/accounts", "/api/auth/me"): + blocked = api.get(path) + assert blocked.status_code == 403, path + assert blocked.json()["error"]["code"] == "password_change_required", path + + new_password = random_password(30) + changed = api.post( + "/api/auth/change-first-password", + json={"current_password": temporary_password, "new_password": new_password}, + ) + assert changed.status_code == 200 + assert changed.json()["must_change_password"] is False + + # The one-time password is spent: it never works again. + api.logout() + assert api.login(username, temporary_password).status_code == 401 + assert api.login(username, new_password).status_code == 200 + + +def test_generated_temporary_passwords_are_unique_per_account( + api: ApiHarness, + admin: object, +) -> None: + issued = { + api.post( + "/api/uadmin/accounts", + json={ + "username": f"Bulk.User{index}", + "display_name": f"Bulk User {index}", + "enrollment_mode": "temporary_password", + }, + ).json()["temporary_password"] + for index in range(5) + } + assert len(issued) == 5 + + +def test_organization_admin_cannot_provision_outside_its_scope(api: ApiHarness, service: AccountService) -> None: + org_a = uuid4() + org_b = uuid4() + 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) + + outside = api.post( + "/api/uadmin/accounts", + json={ + "username": "Outside.Temp", + "display_name": "Outside Temp", + "enrollment_mode": "temporary_password", + "organization_id": str(org_b), + }, + ) + escalate = api.post( + "/api/uadmin/accounts", + json={ + "username": "Escalate.Temp", + "display_name": "Escalate Temp", + "enrollment_mode": "temporary_password", + "role": "platform_admin", + }, + ) + + assert outside.status_code == 403 + assert escalate.status_code == 403 + # Nothing was created by either attempt. + assert api.get("/api/uadmin/accounts").json()["total"] == 1 + + +def test_create_account_validates_input(api: ApiHarness, admin: object) -> None: + duplicate = api.post( + "/api/uadmin/accounts", + json={"username": "Umbraadmin", "display_name": "Duplicate", "enrollment_mode": "oidc"}, + ) + invalid = api.post( + "/api/uadmin/accounts", + json={"username": "bad user", "display_name": "Bad", "enrollment_mode": "oidc"}, + ) + unknown_mode = api.post( + "/api/uadmin/accounts", + json={"username": "Mode.User", "display_name": "Mode", "enrollment_mode": "carrier_pigeon"}, + ) + + assert duplicate.status_code == 409 + assert duplicate.json()["error"]["code"] == "username_taken" + assert invalid.status_code == 422 + assert unknown_mode.status_code == 422 + + +# ---------------------------------------------------------------------- +# Listing, retrieval, isolation +# ---------------------------------------------------------------------- + + +def test_list_and_get_accounts(api: ApiHarness, admin: object, service: AccountService) -> None: + provision_local(service, username="Member.One", role="member") + provision_local(service, username="Member.Two", role="member") + + listing = api.get("/api/uadmin/accounts") + assert listing.status_code == 200 + body = listing.json() + assert body["total"] == 3 + assert {item["username"] for item in body["items"]} == {"Umbraadmin", "Member.One", "Member.Two"} + assert "password_hash" not in listing.text + assert "$argon2" not in listing.text + + member = api.get("/api/uadmin/accounts?status=active&limit=1") + assert member.status_code == 200 + assert member.json()["total"] == 3 + assert len(member.json()["items"]) == 1 + + single = api.get(f"/api/uadmin/accounts/{admin.user_id}") # type: ignore[attr-defined] + assert single.status_code == 200 + assert single.json()["id"] == str(admin.user_id) # type: ignore[attr-defined] + + missing = api.get(f"/api/uadmin/accounts/{uuid4()}") + assert missing.status_code == 404 + assert missing.json()["error"]["code"] == "account_not_found" + + +def test_organization_admin_scope_is_enforced(api: ApiHarness, service: AccountService) -> None: + org_a = uuid4() + org_b = uuid4() + 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) + outside = provision_local(service, username="Outside.User", role="member", organization_id=org_b) + api.login_ok(org_admin) + + listing = api.get("/api/uadmin/accounts") + assert listing.status_code == 200 + assert listing.json()["total"] == 2 + assert str(outside.user_id) not in listing.text + assert str(inside.user_id) in listing.text + + # A forged organization filter cannot widen the scope. + forged = api.get(f"/api/uadmin/accounts?organization_id={org_b}") + assert forged.status_code == 200 + assert forged.json()["total"] == 2 + + assert api.get(f"/api/uadmin/accounts/{outside.user_id}").status_code == 403 + assert api.patch(f"/api/uadmin/accounts/{outside.user_id}/status", json={"status": "suspended"}).status_code == 403 + assert api.post(f"/api/uadmin/accounts/{outside.user_id}/revoke-sessions").status_code == 403 + # Creating an account outside its own organization is refused. + escape = api.post( + "/api/uadmin/accounts", + json={ + "username": "Escape.User", + "display_name": "Escape", + "enrollment_mode": "oidc", + "organization_id": str(org_b), + }, + ) + assert escape.status_code == 403 + # Nor can it create a platform administrator. + escalate = api.post( + "/api/uadmin/accounts", + json={ + "username": "Escalate.User", + "display_name": "Escalate", + "enrollment_mode": "oidc", + "role": "platform_admin", + "organization_id": str(org_a), + }, + ) + assert escalate.status_code == 403 + + +# ---------------------------------------------------------------------- +# Lifecycle +# ---------------------------------------------------------------------- + + +def test_suspend_reactivate_and_deactivate(api: ApiHarness, admin: object, service: AccountService) -> None: + member = provision_local(service, username="Member.User", role="member") + # A separate browser holds the member's live session while the + # administrator acts through the first client. + with TestClient(api.client.app) as member_client: + result = member_client.post("/api/auth/login", json={"username": member.username, "password": member.password}) + assert result.status_code == 200 + + suspended = api.patch( + f"/api/uadmin/accounts/{member.user_id}/status", + json={"status": "suspended", "reason": "policy review"}, + ) + assert suspended.status_code == 200 + assert suspended.json()["account"]["status"] == "suspended" + assert suspended.json()["revoked_sessions"] == 1 + # The suspended user's live session is dead. + assert member_client.get("/api/auth/me").status_code == 401 + + reactivated = api.patch( + f"/api/uadmin/accounts/{member.user_id}/status", + json={"status": "active", "reason": "cleared"}, + ) + assert reactivated.status_code == 200 + assert reactivated.json()["account"]["status"] == "active" + + deactivated = api.patch(f"/api/uadmin/accounts/{member.user_id}/status", json={"status": "deactivated"}) + assert deactivated.status_code == 200 + assert deactivated.json()["account"]["status"] == "deactivated" + + # Logging in as the deactivated account is refused. + assert api.login(member.username, member.password).status_code == 401 + + +def test_status_change_of_the_last_platform_admin_is_refused(api: ApiHarness, admin: object) -> None: + response = api.patch( + f"/api/uadmin/accounts/{admin.user_id}/status", # type: ignore[attr-defined] + json={"status": "suspended"}, + ) + assert response.status_code == 409 + assert response.json()["error"]["code"] == "last_platform_admin" + + +def test_revoke_sessions_endpoint(api: ApiHarness, admin: object, service: AccountService) -> None: + member = provision_local(service, username="Member.User", role="member") + with TestClient(api.client.app) as member_client: + result = member_client.post("/api/auth/login", json={"username": member.username, "password": member.password}) + assert result.status_code == 200 + + revoked = api.post(f"/api/uadmin/accounts/{member.user_id}/revoke-sessions") + assert revoked.status_code == 200 + assert revoked.json()["revoked_sessions"] == 1 + assert member_client.get("/api/auth/me").status_code == 401 + + # Idempotent: nothing is left to revoke. + assert api.post(f"/api/uadmin/accounts/{member.user_id}/revoke-sessions").json() == {"revoked_sessions": 0} + + +def test_admin_cannot_suspend_itself_into_a_lockout(api: ApiHarness, admin: object, service: AccountService) -> None: + second_admin = activated_admin(service, username="Second.Admin") + # A second platform administrator exists, so the first may step down. + response = api.patch( + f"/api/uadmin/accounts/{admin.user_id}/status", # type: ignore[attr-defined] + json={"status": "suspended", "reason": "handover"}, + ) + assert response.status_code == 200 + assert api.client.get("/api/auth/me").status_code == 401 + assert second_admin.username == "Second.Admin" + + +# ---------------------------------------------------------------------- +# Audit +# ---------------------------------------------------------------------- + + +def test_audit_endpoint_returns_sanitized_events(api: ApiHarness, admin: object, service: AccountService) -> None: + member = provision_local(service, username="Member.User", role="member") + api.patch( + f"/api/uadmin/accounts/{member.user_id}/status", + json={"status": "suspended", "reason": f"password={random_password()}"}, + ) + + response = api.get("/api/uadmin/audit") + assert response.status_code == 200 + body = response.json() + assert body["total"] >= 1 + actions = {item["action"] for item in body["items"]} + assert "account.status_changed" in actions + assert "auth.login" in actions + + for item in body["items"]: + assert set(item) == { + "id", + "occurred_at", + "action", + "outcome", + "actor_user_id", + "target_user_id", + "organization_id", + "request_id", + "detail", + } + assert "$argon2" not in str(item) + assert "password" not in response.text or '"password"' not in response.text + + +def test_audit_is_scoped_to_the_callers_organization(api: ApiHarness, service: AccountService) -> None: + org_a = uuid4() + org_b = uuid4() + 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) + api.login_ok(org_admin) + + response = api.get("/api/uadmin/audit") + assert response.status_code == 200 + assert str(other.user_id) not in response.text + + filtered = api.get(f"/api/uadmin/audit?target_user_id={other.user_id}") + assert filtered.status_code == 403 + + +def test_audit_events_reference_the_request_id(api: ApiHarness, admin: object, service: AccountService) -> None: + response = api.post( + "/api/uadmin/accounts", + json={"username": "Traced.User", "display_name": "Traced", "enrollment_mode": "oidc"}, + ) + assert response.status_code == 201 + request_id = response.headers["x-request-id"] + + events = api.get(f"/api/uadmin/audit?target_user_id={response.json()['account']['id']}").json() + assert any(event["request_id"] == request_id for event in events["items"]) + assert all(event["request_id"] for event in events["items"] if event["action"] == "account.created") diff --git a/umbratrans_web/tests/test_cli.py b/umbratrans_web/tests/test_cli.py new file mode 100644 index 0000000..4ed15c6 --- /dev/null +++ b/umbratrans_web/tests/test_cli.py @@ -0,0 +1,254 @@ +"""Operator CLI tests: production refusal, TTY requirement, hidden prompt, output.""" + +from __future__ import annotations + +import io +import json +import os +import stat +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import TextIO + +import pytest + +from umbratrans_web import cli +from umbratrans_web.cli import ( + EXIT_ERROR, + EXIT_NOT_INTERACTIVE, + EXIT_OK, + EXIT_PASSWORD_REJECTED, + EXIT_REFUSED_PRODUCTION, + bootstrap_admin, +) +from umbratrans_web.config import Environment, Settings +from umbratrans_web.dev_repository import JsonDevelopmentRepository +from umbratrans_web.service import AccountService + +from .conftest import random_password + + +class _FakeTty(io.StringIO): + """A stream that claims to be a terminal, for the hidden-prompt guard.""" + + def isatty(self) -> bool: + return True + + +class _Sink: + """Minimal writable stream so CLI output can be asserted on.""" + + def __init__(self) -> None: + self.chunks: list[str] = [] + + def write(self, value: str) -> int: + self.chunks.append(value) + return len(value) + + def flush(self) -> None: + return None + + @property + def text(self) -> str: + return "".join(self.chunks) + + +@contextmanager +def _environment(values: dict[str, str] | None) -> Iterator[None]: + previous = {key: os.environ.get(key) for key in (values or {})} + try: + for key, value in (values or {}).items(): + os.environ[key] = value + yield + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def _run( + argv: list[str], + *, + entries: list[str] | None = None, + stdin: TextIO | None = None, + env: dict[str, str] | None = None, +) -> tuple[int, str, str]: + """Run the CLI with an injected prompt. Values are generated per run.""" + + out = _Sink() + err = _Sink() + # The command always prompts twice; a single value is reused for both. + typed = list(entries) if entries else [random_password(28)] + if len(typed) == 1: + typed.append(typed[0]) + + def fake_prompt(_label: str) -> str: + return typed.pop(0) + + with _environment(env): + code = bootstrap_admin( + argv, + stdin=stdin if stdin is not None else _FakeTty(), + stdout=out, # type: ignore[arg-type] + stderr=err, # type: ignore[arg-type] + prompt=fake_prompt, + ) + return code, out.text, err.text + + +def test_bootstrap_creates_a_must_change_account_in_a_private_file(tmp_path: Path) -> None: + path = tmp_path / "state" / "dev-accounts.json" + password = random_password(28) + + code, out, err = _run(["--repository-path", str(path)], entries=[password, password]) + + assert code == EXIT_OK, err + assert "Bootstrap account created." in out + assert "Umbraadmin" in out + assert "must_change_password: true" in out + assert "platform_admin" in out + # Nothing sensitive is printed. + assert password not in out + assert password not in err + assert "$argon2" not in out + + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + + settings = Settings( + environment=Environment.TEST, + repository_kind="json", + repository_path=path, + argon2_memory_cost_kib=8 * 1024, + argon2_time_cost=1, + argon2_parallelism=1, + ) + service = AccountService(JsonDevelopmentRepository(settings), settings) + result = service.authenticate(username="Umbraadmin", password=password) + assert result.session.must_change_password is True + + raw = path.read_text(encoding="utf-8") + assert password not in raw + payload = json.loads(raw) + assert len(payload["credentials"]) == 1 + assert payload["credentials"][0]["credential_type"] == "local_password" + assert payload["credentials"][0]["must_change_password"] is True + assert payload["credentials"][0]["password_hash"].startswith("$argon2id$") + + +def test_bootstrap_uses_a_configurable_non_secret_username(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + code, out, err = _run(["--repository-path", str(path), "--username", "Ops.Admin", "--role", "organization_admin"]) + assert code == EXIT_OK, err + assert "Ops.Admin" in out + assert "organization_admin" in out + assert path.is_file() + + +def test_bootstrap_refuses_production_mode(tmp_path: Path) -> None: + code, out, err = _run( + ["--repository-path", str(tmp_path / "dev-accounts.json")], + env={"UMBRA_ENV": "production"}, + ) + assert code == EXIT_REFUSED_PRODUCTION + assert "development-only" in err + assert "Refusing to run" in err + assert not (tmp_path / "dev-accounts.json").exists() + + +def test_bootstrap_refuses_a_non_interactive_terminal(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + code, out, err = _run(["--repository-path", str(path)], stdin=io.StringIO()) + assert code == EXIT_NOT_INTERACTIVE + assert "interactive terminal" in err + assert not path.exists() + + +def test_bootstrap_refuses_mismatched_entries(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + code, out, err = _run(["--repository-path", str(path)], entries=[random_password(28), random_password(28)]) + assert code == EXIT_PASSWORD_REJECTED + assert "did not match" in err + assert not path.exists() + + +def test_bootstrap_rejects_a_password_that_fails_the_policy(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + code, out, err = _run(["--repository-path", str(path)], entries=["short", "short"]) + assert code == EXIT_PASSWORD_REJECTED + assert "policy" in err + assert "short" not in err + assert not path.exists() + + +def test_bootstrap_reports_a_duplicate_username_without_disclosing_secrets(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + first = random_password(28) + assert _run(["--repository-path", str(path)], entries=[first, first])[0] == EXIT_OK + second = random_password(28) + code, out, err = _run(["--repository-path", str(path)], entries=[second, second]) + assert code == EXIT_ERROR + assert first not in err + assert second not in err + assert "$argon2" not in err + + +def test_serve_refuses_non_loopback_and_production(monkeypatch: pytest.MonkeyPatch) -> None: + err = _Sink() + monkeypatch.delenv("UMBRA_ENV", raising=False) + assert cli.serve(["--host", "0.0.0.0"], stderr=err) == EXIT_REFUSED_PRODUCTION # type: ignore[arg-type] + assert "loopback" in err.text + + err = _Sink() + monkeypatch.setenv("UMBRA_ENV", "production") + assert cli.serve([], stderr=err) == EXIT_REFUSED_PRODUCTION # type: ignore[arg-type] + assert "production" in err.text + + +def test_module_entry_point_rejects_unknown_commands() -> None: + from umbratrans_web.__main__ import main + + assert main(["not-a-command"]) == 2 + + +def test_parser_exposes_no_password_option() -> None: + parser = cli.build_parser() + text = parser.format_help() + assert "hidden" in text + assert "password" in text.lower() + option_strings = { + option + for action in parser._actions + for option in action.option_strings # noqa: SLF001 + } + assert not any("password" in option for option in option_strings) + assert not any("secret" in option for option in option_strings) + + +def test_interactive_guard_rejects_a_closed_stream() -> None: + from umbratrans_web.errors import OperatorRefusalError + + closed = io.StringIO() + closed.close() + with pytest.raises(OperatorRefusalError) as error: + cli._require_interactive(closed) # noqa: SLF001 + assert error.value.code == "terminal_unavailable" + + +def test_prompt_defaults_to_getpass(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + def fake_getpass(prompt: str) -> str: + calls.append(prompt) + return random_password(28) + + monkeypatch.setattr(cli.getpass, "getpass", fake_getpass) + prompt: Callable[[str], str] = cli._prompt_hidden_password # noqa: SLF001 + value = prompt("One-time password (input hidden): ") + + assert value + assert calls == ["One-time password (input hidden): "] + assert cli.getpass.getpass is fake_getpass diff --git a/umbratrans_web/tests/test_config.py b/umbratrans_web/tests/test_config.py new file mode 100644 index 0000000..7828992 --- /dev/null +++ b/umbratrans_web/tests/test_config.py @@ -0,0 +1,195 @@ +"""Configuration tests: fail-closed production defaults, no wildcards, no secrets.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import pytest + +from umbratrans_web.config import ( + DEVELOPMENT_REPOSITORY_RELATIVE_PATH, + Environment, + Settings, + load_settings, + suite_settings, +) +from umbratrans_web.errors import ConfigurationError +from umbratrans_web.security import SecretRedactionFilter + + +def test_defaults_are_development_and_insecure_only_where_allowed() -> None: + settings = Settings() + assert settings.environment is Environment.DEVELOPMENT + assert settings.cookie_secure is False + assert settings.require_csrf is True + assert settings.repository_kind == "memory" + + +def test_load_settings_reads_the_development_environment() -> None: + settings = load_settings({"UMBRA_ENV": "development"}) + assert settings.environment is Environment.DEVELOPMENT + assert settings.repository_kind == "memory" + assert settings.repository_path is None + + +def test_load_settings_refuses_unknown_environments() -> None: + with pytest.raises(ConfigurationError) as error: + load_settings({"UMBRA_ENV": "staging"}) + assert error.value.code == "invalid_environment" + + +def test_load_settings_refuses_insecure_production() -> None: + with pytest.raises(ConfigurationError) as cookie_error: + load_settings({"UMBRA_ENV": "production"}) + assert cookie_error.value.code == "insecure_production_cookie" + + with pytest.raises(ConfigurationError) as csrf_error: + load_settings( + {"UMBRA_ENV": "production", "UMBRATRANS_COOKIE_SECURE": "true", "UMBRATRANS_REQUIRE_CSRF": "false"} + ) + assert csrf_error.value.code == "insecure_production_csrf" + + +def test_production_settings_select_the_absent_postgres_adapter() -> None: + settings = load_settings({"UMBRA_ENV": "production", "UMBRATRANS_COOKIE_SECURE": "true"}) + + assert settings.is_production is True + assert settings.repository_kind == "postgres" + assert settings.cookie_secure is True + # The adapter is not part of this slice, so the app cannot start. + from umbratrans_web.errors import RepositoryError + from umbratrans_web.repository import build_repository + + with pytest.raises(RepositoryError) as error: + build_repository(settings) + assert error.value.code == "postgres_adapter_unavailable" + + +def test_load_settings_parses_json_repository_paths() -> None: + settings = load_settings( + { + "UMBRA_ENV": "development", + "UMBRATRANS_REPOSITORY": "json", + "UMBRATRANS_REPOSITORY_PATH": "/tmp/umbratrans-test/dev-accounts.json", + } + ) + assert settings.repository_kind == "json" + assert settings.repository_path == Path("/tmp/umbratrans-test/dev-accounts.json") + + +def test_json_repository_gets_a_development_default_path() -> None: + settings = load_settings({"UMBRA_ENV": "development", "UMBRATRANS_REPOSITORY": "json"}) + assert settings.repository_path is not None + assert settings.repository_path.name == DEVELOPMENT_REPOSITORY_RELATIVE_PATH.name + assert str(settings.repository_path).startswith(str(Path.cwd() / ".local/data/development")) + + +def test_load_settings_rejects_weak_peppers_and_wildcard_hosts() -> None: + with pytest.raises(ConfigurationError) as pepper_error: + load_settings({"UMBRATRANS_TOKEN_PEPPER": "short"}) + assert pepper_error.value.code == "weak_token_pepper" + + with pytest.raises(ConfigurationError) as host_error: + load_settings({"UMBRATRANS_ALLOWED_HOSTS": "umbratrans.bdmurphy.me,*"}) + assert host_error.value.code == "wildcard_host_forbidden" + + +def test_pepper_is_absent_from_repr_and_comparison() -> None: + pepper = b"k" * 32 + settings = Settings(environment=Environment.TEST, token_pepper=pepper) + # The pepper is a secret: it must not surface in repr, and it must not + # change equality either. + assert pepper.decode() not in repr(settings) + assert settings == Settings(environment=Environment.TEST) + assert settings.token_pepper == pepper + + +def test_session_lifetime_must_be_consistent() -> None: + with pytest.raises(ConfigurationError) as error: + Settings(environment=Environment.TEST, session_idle_seconds=7200, session_absolute_seconds=600) + assert error.value.code == "invalid_session_lifetime" + + +def test_samesite_must_be_lax_or_strict() -> None: + with pytest.raises(ConfigurationError) as error: + Settings(environment=Environment.TEST, cookie_samesite="none") + assert error.value.code == "invalid_samesite" + + +def test_public_fingerprint_has_no_secret_values() -> None: + settings = Settings(environment=Environment.TEST, token_pepper=b"k" * 32) + fingerprint = settings.public_fingerprint() + assert "k" * 32 not in str(fingerprint) + assert set(fingerprint) == { + "environment", + "repository", + "cookie_secure", + "csrf_required", + "session_idle_seconds", + "session_absolute_seconds", + "argon2", + } + + +def test_suite_settings_are_fast_and_isolated() -> None: + settings = suite_settings() + assert settings.environment is Environment.TEST + assert settings.argon2_memory_cost_kib == 8 * 1024 + assert settings.argon2_time_cost == 1 + assert settings.require_csrf is True + + +def test_application_logger_tree_has_the_redaction_filter() -> None: + from umbratrans_web.app import create_app + from umbratrans_web.security import install_secret_redaction_filter + + create_app(settings=suite_settings()) + namespace = logging.getLogger("umbratrans_web") + assert any(isinstance(item, SecretRedactionFilter) for item in namespace.filters) + # Child loggers are patched too, because a parent filter is not applied to + # records emitted by its children. + child = logging.getLogger("umbratrans_web.service") + assert any(isinstance(item, SecretRedactionFilter) for item in child.filters) + # Installation is idempotent. + assert install_secret_redaction_filter() >= 1 + + +def test_child_logger_records_are_redacted_end_to_end() -> None: + import logging as std_logging + + from umbratrans_web.app import create_app + from umbratrans_web.repository import InMemoryAccountRepository + from umbratrans_web.service import AccountService + + settings = suite_settings() + repository = InMemoryAccountRepository() + service = AccountService(repository, settings) + create_app(settings=settings, repository=repository, service=service) + + records: list[std_logging.LogRecord] = [] + + class _Capture(std_logging.Handler): + def emit(self, record: std_logging.LogRecord) -> None: + records.append(record) + + logger = std_logging.getLogger("umbratrans_web.service") + handler = _Capture() + logger.addHandler(handler) + previous_level = logger.level + logger.setLevel(std_logging.DEBUG) + try: + # The filter is pattern based: it removes secret-shaped text such as + # "password=..." or "Authorization: Bearer ...". It cannot recognise a + # secret hidden behind an innocuous key, which is why the service logs + # identifiers only and never a submitted value. + password = "not-a-real-password-value" + logger.warning("operator supplied password=%s", password) + finally: + logger.removeHandler(handler) + logger.setLevel(previous_level) + + assert records + rendered = "".join(record.getMessage() for record in records) + assert password not in rendered + assert "[redacted]" in rendered diff --git a/umbratrans_web/tests/test_migrations.py b/umbratrans_web/tests/test_migrations.py new file mode 100644 index 0000000..0c7a6e2 --- /dev/null +++ b/umbratrans_web/tests/test_migrations.py @@ -0,0 +1,119 @@ +"""Migration-file guards. + +These tests do not run SQL. They assert that the reviewed migration stays +unapplied, stays inside the phase-1 identity scope, and contains no secret +material. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +MIGRATIONS = Path(__file__).resolve().parent.parent / "migrations" +FORWARD = MIGRATIONS / "0001_phase1_identity.sql" +ROLLBACK = MIGRATIONS / "0001_phase1_identity_rollback.sql" + +FORBIDDEN_SQL = ( + "INSERT INTO", + "UPDATE public.users SET username", + "CREATE USER", + "CREATE ROLE", + "GRANT ALL", + "DROP TABLE public.users", + "psql", + "PGPASSWORD", +) + + +@pytest.fixture(autouse=True) +def _no_postgres(monkeypatch: pytest.MonkeyPatch) -> None: + """Make any accidental database import fail loudly inside the suite.""" + + monkeypatch.setenv("UMBRA_ENV", "test") + monkeypatch.delenv("UMBRATRANS_REPOSITORY", raising=False) + monkeypatch.delenv("UMBRATRANS_REPOSITORY_PATH", raising=False) + + +def test_migration_files_exist() -> None: + assert FORWARD.is_file() + assert ROLLBACK.is_file() + assert (MIGRATIONS / "README.md").is_file() + + +def test_forward_migration_is_marked_unapplied() -> None: + text = FORWARD.read_text(encoding="utf-8") + assert "NOT APPLIED" in text + assert "NOT APPROVED" in text + assert "has never been executed" in text + + +def test_forward_migration_stays_in_phase_one_identity_scope() -> None: + text = FORWARD.read_text(encoding="utf-8") + assert "ADD COLUMN IF NOT EXISTS username" in text + assert "CREATE TABLE IF NOT EXISTS public.user_credentials" in text + for column in ("mfa_verified_at", "idle_deadline_at", "last_seen_at"): + assert column in text + # 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", + ): + assert forbidden not in text + + +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*['\"]") + for path in (FORWARD, ROLLBACK, MIGRATIONS / "README.md"): + text = path.read_text(encoding="utf-8") + assert phc_hash.search(text) is None, path + assert assignment.search(text) is None, path + assert "PASSWORD '" not in text + assert "postgresql://" not in text + assert "BEGIN; --" not in text + + +def test_forward_migration_has_no_executable_dangerous_statements() -> None: + text = FORWARD.read_text(encoding="utf-8") + for statement in FORBIDDEN_SQL: + assert statement not in text, statement + + +def test_forward_migration_documents_safety_rolls_back_and_rls() -> None: + text = FORWARD.read_text(encoding="utf-8") + for marker in ( + "lock_timeout", + "statement_timeout", + "PRE-FLIGHT VERIFICATION", + "GRANT", + "ROW LEVEL SECURITY", + "has_table_privilege", + "SET NOT NULL", + "6. ROLLBACK", + ): + assert marker in text, marker + + +def test_rollback_documents_data_loss_and_keeps_username_values() -> None: + text = ROLLBACK.read_text(encoding="utf-8") + assert "NOT APPLIED" in text + assert "DESTRUCTIVE" in text + assert "DROP TABLE IF EXISTS public.user_credentials" in text + assert "DESTROYS" in text + # Username values are preserved unless the operator opts in. + assert "DROP COLUMN IF EXISTS username" in text + assert text.index("-- ALTER TABLE public.users DROP COLUMN IF EXISTS username") > 0 + + +def test_readme_lists_the_approval_gates() -> None: + text = (MIGRATIONS / "README.md").read_text(encoding="utf-8") + assert "not been executed" in text + for gate in ("backup", "RLS", "rollback", "PostgreSQL"): + assert gate in text diff --git a/umbratrans_web/tests/test_repository.py b/umbratrans_web/tests/test_repository.py new file mode 100644 index 0000000..e9d220b --- /dev/null +++ b/umbratrans_web/tests/test_repository.py @@ -0,0 +1,225 @@ +"""Repository tests: in-memory behaviour and the local JSON development store.""" + +from __future__ import annotations + +import json +import os +import stat +from dataclasses import replace +from pathlib import Path +from types import SimpleNamespace +from uuid import uuid4 + +import pytest + +from umbratrans_web.config import Environment, Settings +from umbratrans_web.dev_repository import JsonDevelopmentRepository +from umbratrans_web.errors import ConfigurationError, ConflictError, NotFoundError, RepositoryError +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 + +# ---------------------------------------------------------------------- +# In-memory repository +# ---------------------------------------------------------------------- + + +def test_in_memory_repository_satisfies_the_protocol() -> None: + assert isinstance(InMemoryAccountRepository(), AccountRepository) + + +def test_build_repository_selects_the_requested_kind() -> None: + assert isinstance(build_repository(Settings(environment=Environment.TEST)), InMemoryAccountRepository) + + +def test_build_repository_refuses_the_absent_postgres_adapter() -> None: + settings = Settings(environment=Environment.TEST, repository_kind="postgres") + with pytest.raises(RepositoryError) as error: + build_repository(settings) + assert error.value.code == "postgres_adapter_unavailable" + + +def test_duplicate_user_ids_and_usernames_are_rejected( + repository: InMemoryAccountRepository, service: AccountService +) -> None: + account = provision_local(service, username="Umbraadmin") + stored = repository.get_user(account.user_id) + assert stored is not None + + with pytest.raises(ConflictError) as duplicate_id: + repository.create_user(stored) + assert duplicate_id.value.code == "account_exists" + + with pytest.raises(ConflictError) as duplicate_name: + repository.create_user(replace(stored, id=uuid4())) + assert duplicate_name.value.code == "username_taken" + + +def test_saving_an_unknown_user_fails(repository: InMemoryAccountRepository, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + stored = repository.get_user(account.user_id) + assert stored is not None + with pytest.raises(NotFoundError): + repository.save_user(replace(stored, id=uuid4())) + + +def test_find_users_filters_and_paginates(repository: InMemoryAccountRepository, service: AccountService) -> None: + org = uuid4() + 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()) + + page, total = repository.find_users(organization_id=org, limit=2, offset=0) + assert total == 3 + assert len(page) == 2 + + second_page, _ = repository.find_users(organization_id=org, limit=2, offset=2) + assert len(second_page) == 1 + + _, total_all = repository.find_users() + assert total_all == 4 + + _, active = repository.find_users(status=AccountStatus.ACTIVE) + assert active == 4 + + # Username lookup is case-insensitive in both directions. + assert repository.get_user_by_username("MEMBER0") is not None + assert repository.get_user_by_username("member0") is not None + assert repository.get_user_by_username("member0") == repository.get_user_by_username("Member0") + + +def test_audit_listing_is_newest_first(repository: InMemoryAccountRepository, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + with pytest.raises(Exception): + service.authenticate(username=account.username, password=random_password()) + + events, total = repository.list_audit_events(limit=50) + assert total == len(events) >= 1 + timestamps = [event.occurred_at for event in events] + assert timestamps == sorted(timestamps, reverse=True) + + +# ---------------------------------------------------------------------- +# JSON development repository +# ---------------------------------------------------------------------- + + +def _dev_settings(path: Path) -> Settings: + return Settings( + environment=Environment.TEST, + repository_kind="json", + repository_path=path, + argon2_memory_cost_kib=8 * 1024, + argon2_time_cost=1, + argon2_parallelism=1, + ) + + +def test_json_repository_writes_a_private_file(tmp_path: Path) -> None: + path = tmp_path / "state" / "dev-accounts.json" + repository = JsonDevelopmentRepository(_dev_settings(path)) + service = AccountService(repository, repository.settings) + + provision_local(service, username="Umbraadmin") + + assert path.is_file() + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + assert not list(path.parent.glob(".dev-repo-*")) + + +def test_json_repository_persists_hashes_across_restarts(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + settings = _dev_settings(path) + first = AccountService(JsonDevelopmentRepository(settings), settings) + account = provision_local(first, username="Umbraadmin") + + second = AccountService(JsonDevelopmentRepository(settings), settings) + stored = second.repository.get_credential(account.user_id) + assert stored is not None + assert stored.password_hash is not None + assert stored.must_change_password is True + # The account still authenticates from the persisted hash. + result = second.authenticate(username=account.username, password=account.password) + assert result.session.must_change_password is True + # The audit trail survived too. + events, total = second.repository.list_audit_events() + assert total >= 1 + assert events + + +def test_json_file_contains_no_plaintext_password(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + repository = JsonDevelopmentRepository(_dev_settings(path)) + service = AccountService(repository, repository.settings) + account = provision_local(service, username="Umbraadmin") + + raw = path.read_text(encoding="utf-8") + + assert account.password not in raw + assert "$argon2id$" in raw + payload = json.loads(raw) + assert payload["schema_version"] == 1 + assert payload["credentials"][0]["credential_type"] == "local_password" + assert payload["credentials"][0]["must_change_password"] is True + + +def test_json_repository_refuses_production_mode(tmp_path: Path) -> None: + impostor = SimpleNamespace( + is_production=True, + environment=Environment.PRODUCTION, + repository_path=tmp_path / "dev-accounts.json", + ) + with pytest.raises(ConfigurationError) as error: + JsonDevelopmentRepository(impostor) # type: ignore[arg-type] + assert error.value.code == "development_repository_in_production" + + # The settings layer refuses the same combination. + with pytest.raises(ConfigurationError) as settings_error: + Settings( + environment=Environment.PRODUCTION, + repository_kind="json", + cookie_secure=True, + repository_path=tmp_path / "dev-accounts.json", + ) + assert settings_error.value.code == "production_repository_missing" + + +def test_json_repository_refuses_a_symlinked_file(tmp_path: Path) -> None: + target = tmp_path / "real.json" + target.write_text("{}", encoding="utf-8") + link = tmp_path / "dev-accounts.json" + os.symlink(target, link) + + with pytest.raises(RepositoryError) as error: + JsonDevelopmentRepository(_dev_settings(link)) + assert error.value.code == "symlink_refused" + + +def test_json_repository_rejects_an_unknown_schema_version(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + path.write_text(json.dumps({"schema_version": 99}), encoding="utf-8") + with pytest.raises(RepositoryError) as error: + JsonDevelopmentRepository(_dev_settings(path)) + assert error.value.code == "repository_schema_mismatch" + + +def test_json_repository_rejects_corrupt_content(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + path.write_text("not json at all", encoding="utf-8") + with pytest.raises(RepositoryError) as error: + JsonDevelopmentRepository(_dev_settings(path)) + assert error.value.code == "repository_corrupt" + + +def test_json_repository_tightens_loose_permissions(tmp_path: Path) -> None: + path = tmp_path / "dev-accounts.json" + repository = JsonDevelopmentRepository(_dev_settings(path)) + provision_local(AccountService(repository, repository.settings), username="Umbraadmin") + + os.chmod(path, 0o644) + repository.flush() + + assert stat.S_IMODE(path.stat().st_mode) == 0o600 diff --git a/umbratrans_web/tests/test_security.py b/umbratrans_web/tests/test_security.py new file mode 100644 index 0000000..3b15d55 --- /dev/null +++ b/umbratrans_web/tests/test_security.py @@ -0,0 +1,206 @@ +"""Password hashing, token handling, redaction, and policy tests.""" + +from __future__ import annotations + +import logging + +import pytest + +from umbratrans_web.errors import PasswordPolicyError +from umbratrans_web.security import ( + ARGON2_MEMORY_COST_KIB, + ARGON2_PARALLELISM, + ARGON2_TIME_COST, + PasswordPolicy, + PasswordService, + SecretRedactionFilter, + generate_token, + hash_session_token, + redact_text, + sanitize_audit_detail, + tokens_match, +) + +from .conftest import random_password + + +def test_production_profile_is_argon2id_with_reviewed_parameters() -> None: + service = PasswordService() + parameters = service.parameters() + assert parameters["type"] == "argon2id" + assert parameters["memory_cost_kib"] == ARGON2_MEMORY_COST_KIB + assert parameters["time_cost"] == ARGON2_TIME_COST + assert parameters["parallelism"] == ARGON2_PARALLELISM + + password = random_password() + password_hash = service.hash_password(password) + + assert password_hash.startswith("$argon2id$") + assert f"m={ARGON2_MEMORY_COST_KIB}" in password_hash + assert f"t={ARGON2_TIME_COST}" in password_hash + assert f"p={ARGON2_PARALLELISM}" in password_hash + + +def test_hash_never_contains_the_plaintext_and_verifies_once() -> None: + service = PasswordService( + memory_cost_kib=8 * 1024, + time_cost=1, + parallelism=1, + ) + password = random_password() + password_hash = service.hash_password(password) + + assert password not in password_hash + assert service.verify_password(password_hash, password) is True + assert service.verify_password(password_hash, random_password()) is False + # A second verification of the same value still succeeds (no one-shot use). + assert service.verify_password(password_hash, password) is True + + +def test_hash_is_salted_per_call() -> None: + service = PasswordService(memory_cost_kib=8 * 1024, time_cost=1, parallelism=1) + password = random_password() + assert service.hash_password(password) != service.hash_password(password) + + +def test_verify_returns_false_for_absent_or_corrupt_hash() -> None: + service = PasswordService(memory_cost_kib=8 * 1024, time_cost=1, parallelism=1) + password = random_password() + assert service.verify_password(None, password) is False + assert service.verify_password("not-a-hash", password) is False + assert service.needs_rehash("not-a-hash") is True + + +def test_repr_hides_hashes() -> None: + service = PasswordService(memory_cost_kib=8 * 1024, time_cost=1, parallelism=1) + password = random_password() + password_hash = service.hash_password(password) + assert password_hash not in repr(service) + assert password not in repr(service) + assert "$2" not in service.identifier(password_hash) or service.identifier(password_hash).startswith("argon2id") + assert service.identifier(password_hash).startswith("argon2id:") + + +def test_password_policy_rejects_weak_candidates_without_echoing_them() -> None: + policy = PasswordPolicy() + with pytest.raises(PasswordPolicyError) as short_error: + policy.validate("short", username="Umbraadmin") + assert "min_length" in short_error.value.unmet_requirements + assert "short" not in str(short_error.value) + + with pytest.raises(PasswordPolicyError) as reuse_error: + policy.validate("Umbraadmin-passphrase-2026", username="Umbraadmin") + assert "must_not_contain_username" in reuse_error.value.unmet_requirements + assert "Umbraadmin" not in str(reuse_error.value) + + policy.validate(random_password(), username="Umbraadmin") + + +def test_password_policy_enforces_maximum_length() -> None: + policy = PasswordPolicy() + with pytest.raises(PasswordPolicyError) as error: + policy.validate("x" * (policy.max_length + 1)) + assert "max_length" in error.value.unmet_requirements + + +def test_generated_temporary_passwords_satisfy_the_policy() -> None: + policy = PasswordPolicy() + service = PasswordService(policy=policy) + generated = {service.generate_temporary_password(username="Umbraadmin") for _ in range(20)} + + assert len(generated) == 20 + for candidate in generated: + policy.validate(candidate, username="Umbraadmin") + assert len(candidate) >= policy.min_length + assert any(character.islower() for character in candidate) + assert any(character.isupper() for character in candidate) + assert any(character.isdigit() for character in candidate) + assert any(not character.isalnum() for character in candidate) + # Ambiguous glyphs are excluded so a single transcription does not fail. + assert not set(candidate) & set("0O1lI5S2Z") + + +def test_session_tokens_are_random_and_stored_only_as_a_hash() -> None: + token = generate_token() + other = generate_token() + assert token != other + assert len(token) >= 32 + + token_hash = hash_session_token(token) + assert token_hash != token + assert token not in token_hash + assert hash_session_token(token) == token_hash + assert hash_session_token(other) != token_hash + + +def test_peppered_token_hash_differs_from_plain_hash() -> None: + pepper = b"p" * 32 + token = generate_token() + assert hash_session_token(token, pepper=pepper) != hash_session_token(token) + assert hash_session_token(token, pepper=pepper) == hash_session_token(token, pepper=pepper) + + +def test_tokens_match_is_exact() -> None: + assert tokens_match(generate_token(), generate_token()) is False + value = generate_token() + assert tokens_match(value, value) is True + assert tokens_match(value, value + "x") is False + + +def test_redact_text_removes_credential_shapes() -> None: + password = random_password() + samples = [ + f'password="{password}"', + "Authorization: Bearer abcdefghijklmnop", + "$argon2id$v=19$m=65536,t=3,p=4$abcdef$0123456789abcdef", + "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", + ] + for sample in samples: + redacted = redact_text(sample) + assert "[redacted]" in redacted + assert password not in redacted + + +def test_sanitize_audit_detail_drops_unknown_keys_and_bounds_values() -> None: + detail = sanitize_audit_detail( + { + "username": "Umbraadmin", + "status": "suspended", + "must_change_password": True, + "revoked_sessions": 2, + "reason": "x" * 500, + "password": "should-never-survive", + "session_token": "should-never-survive", + } + ) + assert detail["username"] == "Umbraadmin" + assert detail["status"] == "suspended" + assert detail["must_change_password"] == "true" + assert detail["revoked_sessions"] == "2" + assert len(detail["reason"]) == 200 + assert "password" not in detail + assert "session_token" not in detail + assert "should-never-survive" not in "".join(detail.values()) + + +def test_sanitize_audit_detail_redacts_secret_shaped_values() -> None: + password = random_password() + detail = sanitize_audit_detail({"reason": f"password={password}", "username": "Umbraadmin"}) + assert password not in detail["reason"] + assert detail["username"] == "Umbraadmin" + + +def test_logging_filter_scrubs_record_arguments() -> None: + password = random_password() + record = logging.LogRecord( + name="umbratrans_web.service", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="account created password=%s", + args=(password,), + exc_info=None, + ) + assert SecretRedactionFilter().filter(record) is True + assert password not in record.getMessage() + assert "[redacted]" in record.getMessage() diff --git a/umbratrans_web/tests/test_service.py b/umbratrans_web/tests/test_service.py new file mode 100644 index 0000000..e143fa8 --- /dev/null +++ b/umbratrans_web/tests/test_service.py @@ -0,0 +1,631 @@ +"""Service-level tests: enrollment, authentication, sessions, authorization.""" + +from __future__ import annotations + +from dataclasses import fields, is_dataclass +from uuid import UUID, uuid4 + +import pytest + +from umbratrans_web.errors import ( + ConflictError, + EnrollmentRefusedError, + ForbiddenError, + InputError, + InvalidCredentialsError, + OperatorRefusalError, + PasswordChangeRequiredError, + PasswordPolicyError, + StateTransitionError, +) +from umbratrans_web.models import AccountStatus, CredentialType, Principal, RoleName +from umbratrans_web.service import AccountService, OperatorContext + +from .conftest import ( + admin_principal, + member_principal, + provision_local, + provision_oidc, + random_password, +) + +# ---------------------------------------------------------------------- +# Enrollment +# ---------------------------------------------------------------------- + + +def test_operator_enrollment_stores_only_an_argon2id_hash(repository: object, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + stored = repository.get_credential(account.user_id) # type: ignore[attr-defined] + + assert stored is not None + assert stored.credential_type is CredentialType.LOCAL_PASSWORD + assert stored.password_hash is not None + assert stored.password_hash.startswith("$argon2id$") + assert account.password not in stored.password_hash + assert stored.must_change_password is True + assert account.view.credential.must_change_password is True + + +def test_repository_state_contains_no_plaintext_password(repository: object, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + repository_object = repository + blobs: list[str] = [] + for attribute in vars(repository_object).values(): + if isinstance(attribute, dict): + for value in attribute.values(): + blobs.append(repr(value)) + elif isinstance(attribute, list): + blobs.extend(repr(value) for value in attribute) + joined = "\n".join(blobs) + + # Nothing the repository holds - including every repr - contains the + # plaintext, and the hash is hidden from repr as well. + assert account.password not in joined + assert "$argon2id$" not in joined + stored = repository.get_credential(account.user_id) # type: ignore[attr-defined] + assert stored is not None + assert stored.password_hash is not None + assert stored.password_hash.startswith("$argon2id$") + + +def test_credential_records_hide_hashes_in_repr(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + stored = service.repository.get_credential(account.user_id) + assert stored is not None + assert stored.password_hash not in repr(stored) + assert account.password not in repr(stored) + + +def test_username_is_unique_case_insensitively(service: AccountService) -> None: + provision_local(service, username="Umbraadmin") + with pytest.raises(ConflictError) as error: + provision_local(service, username="umbraadmin") + assert error.value.code == "username_taken" + + +def test_generic_create_account_path_requires_the_operator_context(service: AccountService) -> None: + """The generic entry point stays operator-only for local passwords. + + Administrator-driven enrollment has its own entry point, + :meth:`provision_admin_temporary_password`, which generates the secret + instead of accepting one. + """ + + actor = admin_principal(provision_local(service, username="Umbraadmin")) + password = random_password() + + with pytest.raises(EnrollmentRefusedError) as error: + service.create_account( + enrollment_mode="temporary_password", + username="No.Path", + display_name="No Path", + password=password, + actor=actor, + ) + assert error.value.code == "operator_context_required" + assert service.repository.get_user_by_username("no.path") is None + + +def test_admin_enrollment_generates_a_one_time_password(service: AccountService) -> None: + actor = admin_principal(provision_local(service, username="Umbraadmin")) + issued = service.provision_admin_temporary_password( + username="Temp.User", + display_name="Temp User", + actor=actor, + ) + + # Handed back once, and hidden from any accidental rendering or log line. + assert issued.temporary_password not in repr(issued) + assert issued.account.credential.must_change_password is True + assert issued.account.credential.credential_type is CredentialType.LOCAL_PASSWORD + + stored = service.repository.get_credential(issued.account.id) + assert stored is not None + assert stored.password_hash is not None + assert stored.password_hash.startswith("$argon2id$") + assert issued.temporary_password not in stored.password_hash + + # It authenticates once, and only far enough to be replaced. + first = service.authenticate(username="Temp.User", password=issued.temporary_password) + assert first.session.must_change_password is True + replacement = random_password(30) + service.change_first_password( + service.resolve_session(first.session.token), + current_password=issued.temporary_password, + new_password=replacement, + ) + with pytest.raises(InvalidCredentialsError): + service.authenticate(username="Temp.User", password=issued.temporary_password) + assert service.authenticate(username="Temp.User", password=replacement).session.must_change_password is False + + +def test_admin_enrollment_requires_an_administrator(service: AccountService) -> None: + member = provision_local(service, username="Member.User", role="member") + with pytest.raises(ForbiddenError): + service.provision_admin_temporary_password( + username="Escalate.User", + display_name="Escalate", + actor=member_principal(member), + ) + assert service.repository.get_user_by_username("escalate.user") is None + + +def test_temporary_password_enrollment_requires_a_password(service: AccountService) -> None: + with pytest.raises(InputError) as error: + service.create_account( + enrollment_mode="temporary_password", + username="No.Password", + display_name="No Password", + operator=OperatorContext(source="operator-cli"), + ) + assert error.value.code == "password_required" + + +def test_oidc_enrollment_creates_no_password_material(repository: object, service: AccountService) -> None: + actor = admin_principal(provision_local(service, username="Umbraadmin")) + account = provision_oidc(service, actor, username="Oidc.User") + + stored = repository.get_credential(account.user_id) # type: ignore[attr-defined] + assert stored is not None + assert stored.credential_type is CredentialType.OIDC + assert stored.password_hash is None + assert stored.must_change_password is False + assert account.view.credential.credential_type is CredentialType.OIDC + + +def test_oidc_account_cannot_authenticate_with_a_local_password(service: AccountService) -> None: + actor = admin_principal(provision_local(service, username="Umbraadmin")) + account = provision_oidc(service, actor, username="Oidc.User") + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=random_password()) + + +def test_only_declared_operator_sources_are_accepted() -> None: + with pytest.raises(OperatorRefusalError) as error: + OperatorContext(source="http-post") + assert error.value.code == "unknown_operator_source" + # The HTTP layer never builds this object at all; see test_api_uadmin. + assert OperatorContext(source="operator-cli").interactive is True + + +# ---------------------------------------------------------------------- +# Authentication +# ---------------------------------------------------------------------- + + +def test_authentication_issues_a_session_without_exposing_the_token(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + result = service.authenticate(username=account.username, password=account.password) + + assert result.session.token + assert result.session.must_change_password is True + principal = service.resolve_session(result.session.token) + assert principal.user_id == account.user_id + assert principal.must_change_password is True + + +def test_stored_session_token_is_a_hash_not_the_token(repository: object, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + result = service.authenticate(username=account.username, password=account.password) + + stored = repository.get_session(result.session.session_id) # type: ignore[attr-defined] + assert stored is not None + assert stored.token_hash != result.session.token + assert result.session.token not in stored.token_hash + assert result.session.token not in repr(stored) + + +def test_unknown_user_and_wrong_password_are_indistinguishable(repository: object, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + with pytest.raises(InvalidCredentialsError) as unknown: + service.authenticate(username="Nobody.Here", password=random_password()) + with pytest.raises(InvalidCredentialsError) as wrong: + service.authenticate(username=account.username, password=random_password()) + + assert unknown.value.code == wrong.value.code == "invalid_credentials" + assert unknown.value.message == wrong.value.message + assert unknown.value.message == InvalidCredentialsError().message + + +def test_login_failures_are_audited_without_the_username_value(repository: object, service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=random_password()) + + events, _ = repository.list_audit_events(target_user_id=account.user_id) # type: ignore[attr-defined] + failures = [event for event in events if event.action == "auth.login" and event.outcome == "failure"] + assert failures + assert failures[0].detail.get("reason") == "invalid_password" + assert "password" not in failures[0].detail + + +def test_repeated_failures_lock_the_credential(service: AccountService, settings: object) -> None: + account = provision_local(service, username="Umbraadmin") + for _ in range(settings.max_failed_login_attempts): # type: ignore[attr-defined] + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=random_password()) + + stored = service.repository.get_credential(account.user_id) + assert stored is not None + assert stored.failed_login_count >= settings.max_failed_login_attempts # type: ignore[attr-defined] + assert stored.is_locked(service._now()) # type: ignore[attr-defined] + + # Even the correct password is refused while locked, with the same error. + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=account.password) + + events, _ = service.repository.list_audit_events(target_user_id=account.user_id) + assert any(event.detail.get("lock_state") == "locked" for event in events) + + +def test_successful_login_clears_the_failure_counter(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=random_password()) + service.authenticate(username=account.username, password=account.password) + + stored = service.repository.get_credential(account.user_id) + assert stored is not None + assert stored.failed_login_count == 0 + assert stored.last_login_at is not None + + +# ---------------------------------------------------------------------- +# First-login password change +# ---------------------------------------------------------------------- + + +def test_first_login_change_rotates_the_password_and_revokes_old_sessions(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + first = service.authenticate(username=account.username, password=account.password) + second = service.authenticate(username=account.username, password=account.password) + principal = service.resolve_session(first.session.token) + new_password = random_password(28) + + result = service.change_first_password(principal, current_password=account.password, new_password=new_password) + + assert result.session.token != first.session.token + assert result.session.must_change_password is False + # The previous sessions, including the one used for the change, are gone. + with pytest.raises(InvalidCredentialsError): + service.resolve_session(first.session.token) + with pytest.raises(InvalidCredentialsError): + service.resolve_session(second.session.token) + # The temporary password no longer works. + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=account.password) + # The new password does, and no longer forces a change. + reopened = service.authenticate(username=account.username, password=new_password) + assert reopened.session.must_change_password is False + assert service.resolve_session(result.session.token).must_change_password is False + + +def test_first_login_change_requires_the_temporary_password(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + result = service.authenticate(username=account.username, password=account.password) + principal = service.resolve_session(result.session.token) + with pytest.raises(InvalidCredentialsError): + service.change_first_password(principal, current_password=random_password(), new_password=random_password(28)) + + +def test_first_login_change_rejects_password_reuse_and_weak_values(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + result = service.authenticate(username=account.username, password=account.password) + principal = service.resolve_session(result.session.token) + + with pytest.raises(PasswordPolicyError) as reuse: + service.change_first_password(principal, current_password=account.password, new_password=account.password) + assert reuse.value.unmet_requirements == ("must_differ_from_current",) + + with pytest.raises(PasswordPolicyError): + service.change_first_password(principal, current_password=account.password, new_password="short") + + +# ---------------------------------------------------------------------- +# Session lifecycle +# ---------------------------------------------------------------------- + + +def test_logout_revokes_only_the_current_session(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + first = service.authenticate(username=account.username, password=account.password) + second = service.authenticate(username=account.username, password=account.password) + principal = service.resolve_session(first.session.token) + + service.logout(principal) + + with pytest.raises(InvalidCredentialsError): + service.resolve_session(first.session.token) + assert service.resolve_session(second.session.token).user_id == account.user_id + + +def test_logout_everywhere_revokes_all_sessions(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + tokens = [ + service.authenticate(username=account.username, password=account.password).session.token for _ in range(3) + ] + principal = service.resolve_session(tokens[0]) + + revoked = service.logout_everywhere(principal) + + assert revoked == 3 + for token in tokens: + with pytest.raises(InvalidCredentialsError): + service.resolve_session(token) + + +def test_expired_sessions_are_rejected_and_revoked(service: AccountService, settings: object) -> None: + account = provision_local(service, username="Umbraadmin") + result = service.authenticate(username=account.username, password=account.password) + stored = service.repository.get_session(result.session.session_id) + assert stored is not None + + from dataclasses import replace + + expired = replace(stored, idle_deadline_at=service._now()) # type: ignore[attr-defined] + service.repository.save_session(expired) + + with pytest.raises(InvalidCredentialsError): + service.resolve_session(result.session.token) + assert service.repository.get_session(result.session.session_id).revoked_reason == "expired" # type: ignore[union-attr] + + +def test_unknown_and_forged_tokens_are_rejected(service: AccountService) -> None: + for token in ("", "not-a-token", random_password(40)): + with pytest.raises(InvalidCredentialsError): + service.resolve_session(token) + + +# ---------------------------------------------------------------------- +# Account lifecycle and authorization +# ---------------------------------------------------------------------- + + +def test_suspension_revokes_sessions_and_blocks_new_logins(repository: object, service: AccountService) -> None: + actor = admin_principal(provision_local(service, username="Umbraadmin")) + member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER)) + member_result = service.authenticate(username=member.username, password=member.password) + assert service.resolve_session(member_result.session.token).user_id == member.user_id + + view, revoked = service.set_account_status(actor, member.user_id, "suspended", reason="policy review") + + assert view.status is AccountStatus.SUSPENDED + assert revoked == 1 + with pytest.raises(InvalidCredentialsError): + service.resolve_session(member_result.session.token) + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=member.username, password=member.password) + + +def test_reactivation_restores_access(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + actor = admin_principal(admin) + member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER)) + service.set_account_status(actor, member.user_id, "suspended", reason="pause") + view, _ = service.set_account_status(actor, member.user_id, "active") + assert view.status is AccountStatus.ACTIVE + + result = service.authenticate(username=member.username, password=member.password) + assert service.resolve_session(result.session.token).user_id == member.user_id + + +def test_administrative_session_revocation(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + actor = admin_principal(admin) + member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER)) + result = service.authenticate(username=member.username, password=member.password) + + assert service.revoke_sessions(actor, member.user_id) == 1 + with pytest.raises(InvalidCredentialsError): + service.resolve_session(result.session.token) + + +def test_last_platform_admin_cannot_be_suspended(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + actor = admin_principal(admin) + with pytest.raises(ConflictError) as error: + service.set_account_status(actor, admin.user_id, "suspended") + assert error.value.code == "last_platform_admin" + + +def test_status_transitions_are_enforced(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + actor = admin_principal(admin) + member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER)) + with pytest.raises(StateTransitionError): + service.set_account_status(actor, member.user_id, "active") + service.set_account_status(actor, member.user_id, "deactivated") + with pytest.raises(StateTransitionError): + service.set_account_status(actor, member.user_id, "deactivated") + + +def test_members_cannot_use_administrator_operations(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER)) + actor = member_principal(member) + with pytest.raises(ForbiddenError): + service.list_accounts(actor) + with pytest.raises(ForbiddenError): + service.get_account(actor, admin.user_id) + with pytest.raises(ForbiddenError): + service.set_account_status(actor, admin.user_id, "suspended") + with pytest.raises(ForbiddenError): + service.revoke_sessions(actor, admin.user_id) + with pytest.raises(ForbiddenError): + service.list_audit_events(actor) + + +def test_organization_admin_cannot_cross_organizations(service: AccountService) -> None: + org_a = uuid4() + org_b = uuid4() + org_admin_account = provision_local( + service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org_a + ) + other = provision_local(service, username="Other.User", role=str(RoleName.MEMBER), organization_id=org_b) + same_org = provision_local(service, username="Same.User", role=str(RoleName.MEMBER), organization_id=org_a) + actor = admin_principal(org_admin_account, organization_id=org_a) + + visible, total = service.list_accounts(actor) + assert total == 2 + assert {view.id for view in visible} == {org_admin_account.user_id, same_org.user_id} + + with pytest.raises(ForbiddenError): + service.get_account(actor, other.user_id) + with pytest.raises(ForbiddenError): + service.set_account_status(actor, other.user_id, "suspended") + with pytest.raises(ForbiddenError): + service.revoke_sessions(actor, other.user_id) + + +def test_organization_admin_cannot_manage_a_platform_admin(service: AccountService) -> None: + org = uuid4() + admin = provision_local(service, username="Umbraadmin") + org_admin_account = provision_local( + service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org + ) + actor = admin_principal(org_admin_account, organization_id=org) + with pytest.raises(ForbiddenError) as error: + service.get_account(actor, admin.user_id) + assert error.value.code == "platform_admin_only" + + +def test_organization_admin_cannot_promote_to_platform_admin(service: AccountService) -> None: + org = uuid4() + org_admin_account = provision_local( + service, username="Org.Admin", role=str(RoleName.ORGANIZATION_ADMIN), organization_id=org + ) + actor = admin_principal(org_admin_account, organization_id=org) + with pytest.raises(ForbiddenError): + service.create_oidc_account( + username="Escalated.User", + display_name="Escalated", + role=str(RoleName.PLATFORM_ADMIN), + organization_id=org, + actor=actor, + ) + + +def test_password_change_principal_cannot_use_admin_operations(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + result = service.authenticate(username=admin.username, password=admin.password) + principal = service.resolve_session(result.session.token) + assert principal.must_change_password is True + with pytest.raises(PasswordChangeRequiredError): + service.list_accounts(principal) + with pytest.raises(PasswordChangeRequiredError): + service.create_oidc_account(username="Blocked.User", display_name="Blocked", actor=principal) + + +def test_audit_events_are_sanitized_and_scoped(service: AccountService) -> None: + admin = provision_local(service, username="Umbraadmin") + actor = admin_principal(admin) + member = provision_local(service, username="Member.User", role=str(RoleName.MEMBER)) + service.set_account_status(actor, member.user_id, "suspended", reason=f"password={random_password()}") + + events, total = service.list_audit_events(actor) + assert total >= 1 + statuses = [event for event in events if event.action == "account.status_changed"] + assert statuses + for event in events: + for key in event.detail: + assert key in { + "credential_type", + "enrollment_mode", + "lock_state", + "must_change_password", + "organization_id", + "previous_status", + "reason", + "revoked_sessions", + "role", + "source", + "status", + "username", + } + assert "password" not in event.detail + assert "token" not in event.detail + + # The audit view is scoped: a non-admin sees nothing. + with pytest.raises(ForbiddenError): + service.list_audit_events(member_principal(member)) + + +def test_audit_detail_never_contains_a_password_value(service: AccountService) -> None: + account = provision_local(service, username="Umbraadmin") + with pytest.raises(InvalidCredentialsError): + service.authenticate(username=account.username, password=random_password()) + events, _ = service.repository.list_audit_events(target_user_id=account.user_id) + for event in events: + for value in event.detail.values(): + assert account.password not in value + assert "$argon2" not in value + + +# ---------------------------------------------------------------------- +# Model invariants +# ---------------------------------------------------------------------- + + +def test_oidc_credential_cannot_store_a_hash() -> None: + from umbratrans_web.models import CredentialRecord + + with pytest.raises(InputError): + CredentialRecord( + id=uuid4(), + user_id=uuid4(), + credential_type=CredentialType.OIDC, + password_hash="$argon2id$fake", + ) + with pytest.raises(InputError): + CredentialRecord( + id=uuid4(), + user_id=uuid4(), + credential_type=CredentialType.LOCAL_PASSWORD, + password_hash=None, + ) + + +def test_account_view_is_a_plain_dataclass_without_hash_fields() -> None: + from umbratrans_web.models import CredentialSummary + + names = {item.name for item in fields(CredentialSummary)} + assert is_dataclass(CredentialSummary) + assert "password_hash" not in names + assert names == { + "credential_type", + "must_change_password", + "failed_login_count", + "locked", + "last_login_at", + "password_changed_at", + } + + +def test_username_and_display_name_validation() -> None: + from umbratrans_web.errors import InputError + from umbratrans_web.models import normalize_display_name, normalize_email, normalize_username, username_key + + assert normalize_username(" Umbra.admin-1 ") == "Umbra.admin-1" + assert username_key("UmbraAdmin") == "umbraadmin" + for bad in ("", "ab", "with space", "with/slash", "x" * 65): + with pytest.raises(InputError): + normalize_username(bad) + assert normalize_email(" User@Example.COM ") == "user@example.com" + with pytest.raises(InputError): + normalize_email("not-an-email") + assert normalize_display_name(" Umbra Admin ") == "Umbra Admin" + + +def test_principal_organization_is_never_taken_from_the_browser() -> None: + """The principal type has no field a browser could populate directly.""" + + principal = Principal( + user_id=UUID(int=1), + username="Umbraadmin", + role=RoleName.PLATFORM_ADMIN, + session_id=UUID(int=2), + organization_id=None, + ) + assert not hasattr(principal, "request") + assert principal.is_admin is True diff --git a/uv.lock b/uv.lock index f6dff5b..66854aa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,545 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" }, + { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" }, + { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" }, + { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" }, + { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" }, + { url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" }, + { url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" }, + { url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b9/97f0370f99611b14efd384918613dd5cbda75f28d9bb1b677aacfeaa17df/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:19b562b1de4b9052ef1214a2821c44b6e6f22945daa102c32ae4eff929d8b6d8", size = 23055, upload-time = "2026-08-20T07:33:19.716Z" }, + { url = "https://files.pythonhosted.org/packages/ae/70/7eb3fe7bf00103cbbb569c51aef150661f22b734a782673a600ff0f52309/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49d525938467d52c923a890153c99087c9d5a937d1f6b585dbdba34ec82e397a", size = 24869, upload-time = "2026-08-20T07:33:20.671Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4b/9d5919c6cb1f15df7406af0f99b048bd93936f112e3e8f4c8077bc2a9110/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b0bcac4d490a237e18cf91f57352920c29f77f2fa39efd0813fb81298bf17ba", size = 24216, upload-time = "2026-08-20T07:33:21.653Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/32109943bace7729233cc4ee78530baa306d8cc3c6501a64ba8cb3b58129/argon2_cffi_bindings-26.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:0cc40f7b4050bb93eb67de95d2d759322fc7ce4930b9d645581ecf4913ec651e", size = 23584, upload-time = "2026-08-20T07:33:22.613Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/86/b71166048974d49c6d136b2ed1c0e5bec0b974d8c4de5cbce7e86a9e412a/psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874", size = 4728002, upload-time = "2026-09-18T13:16:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/1d/1e06c0de7ed5aed898acb87544eac6ef0bc7d752a67ec6e5d6b835e9b40c/psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492", size = 4775403, upload-time = "2026-09-18T13:16:58.939Z" }, + { url = "https://files.pythonhosted.org/packages/84/02/2ffcbc43f8e4bbc38e5286a22013bcac01898d13cd38325f60dd5428a8af/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf", size = 5594145, upload-time = "2026-09-18T13:17:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/e1/25/031dae2c7d2e7e77dcf5b1962c1e0684fa548d7af0ff6707b6b5e6054ca7/psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f", size = 5267985, upload-time = "2026-09-18T13:17:16.24Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/94c89ada3c003a4d858178f3bba49a35e0297ef2aad659b80eb5e380e690/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300", size = 6860986, upload-time = "2026-09-18T13:17:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/9d/a0/81bf499d095adee8413bd19822a6872fbfa21663ec78014a68d83a8db83c/psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a", size = 5106702, upload-time = "2026-09-18T13:17:28.847Z" }, + { url = "https://files.pythonhosted.org/packages/00/75/99d56da64c27bd985fd82c6ecbf7976b724ac638fdd1654ef995323a1a26/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f", size = 4627090, upload-time = "2026-09-18T13:17:36.668Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0c/0222171d11233332c6a24b1cef1578215f0ffddf3642eb8dd8c4448ad69f/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e", size = 4320353, upload-time = "2026-09-18T13:17:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/62/6f/e1cc2a28dd1228c67c969ba6fd37cd8726b312e2ff51380f847ddb38ccde/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba", size = 4047253, upload-time = "2026-09-18T13:17:47.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fd/38b64790ce7a515b1dbd2bab3d119637a858aeb22c380cf4859bc4ce0e42/psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7", size = 4353208, upload-time = "2026-09-18T13:17:52.41Z" }, + { url = "https://files.pythonhosted.org/packages/f7/dc/45386530ceb2a8c789a226de9b9b34eca8fccf1feba2e4ef68a6aca50c56/psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac", size = 3675638, upload-time = "2026-09-18T13:17:58.112Z" }, + { url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" }, + { url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" }, + { url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" }, + { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" }, + { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" }, + { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] [[package]] name = "ruff" @@ -27,17 +566,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, ] +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + [[package]] name = "umbratrans-agent-comm" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "argon2-cffi" }, + { name = "fastapi" }, + { name = "psycopg", extra = ["binary"] }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [ + { name = "httpx" }, + { name = "pytest" }, { name = "ruff" }, ] [package.metadata] +requires-dist = [ + { name = "argon2-cffi", specifier = ">=23.1,<26" }, + { name = "fastapi", specifier = ">=0.115,<1" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" }, + { name = "uvicorn", specifier = ">=0.34,<1" }, +] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = "==0.16.9" }] +dev = [ + { name = "httpx", specifier = ">=0.28,<1" }, + { name = "pytest", specifier = ">=8,<9" }, + { name = "ruff", specifier = "==0.16.9" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +]