From b474995414ac34acdc99e1b221c8963e38f94630 Mon Sep 17 00:00:00 2001 From: umbra audio Date: Sat, 26 Sep 2026 08:29:23 -0700 Subject: [PATCH] Add phase-1 account management backend slice Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean. --- Makefile | 12 +- README.md | 12 + docs/umbratrans-backend-design.md | 222 ++++ pyproject.toml | 23 +- umbratrans_web/README.md | 126 ++ umbratrans_web/__init__.py | 96 ++ umbratrans_web/__main__.py | 36 + umbratrans_web/app.py | 448 +++++++ umbratrans_web/cli.py | 336 ++++++ umbratrans_web/config.py | 292 +++++ umbratrans_web/dev_repository.py | 342 ++++++ umbratrans_web/errors.py | 186 +++ .../migrations/0001_phase1_identity.sql | 351 ++++++ .../0001_phase1_identity_rollback.sql | 111 ++ umbratrans_web/migrations/README.md | 70 ++ umbratrans_web/models.py | 377 ++++++ umbratrans_web/repository.py | 289 +++++ umbratrans_web/routes_auth.py | 152 +++ umbratrans_web/routes_uadmin.py | 177 +++ umbratrans_web/schemas.py | 270 +++++ umbratrans_web/security.py | 398 +++++++ umbratrans_web/service.py | 1038 +++++++++++++++++ umbratrans_web/tests/__init__.py | 1 + umbratrans_web/tests/conftest.py | 236 ++++ umbratrans_web/tests/test_api_auth.py | 408 +++++++ umbratrans_web/tests/test_api_uadmin.py | 520 +++++++++ umbratrans_web/tests/test_cli.py | 254 ++++ umbratrans_web/tests/test_config.py | 195 ++++ umbratrans_web/tests/test_migrations.py | 119 ++ umbratrans_web/tests/test_repository.py | 225 ++++ umbratrans_web/tests/test_security.py | 206 ++++ umbratrans_web/tests/test_service.py | 631 ++++++++++ uv.lock | 615 +++++++++- 33 files changed, 8769 insertions(+), 5 deletions(-) create mode 100644 docs/umbratrans-backend-design.md create mode 100644 umbratrans_web/README.md create mode 100644 umbratrans_web/__init__.py create mode 100644 umbratrans_web/__main__.py create mode 100644 umbratrans_web/app.py create mode 100644 umbratrans_web/cli.py create mode 100644 umbratrans_web/config.py create mode 100644 umbratrans_web/dev_repository.py create mode 100644 umbratrans_web/errors.py create mode 100644 umbratrans_web/migrations/0001_phase1_identity.sql create mode 100644 umbratrans_web/migrations/0001_phase1_identity_rollback.sql create mode 100644 umbratrans_web/migrations/README.md create mode 100644 umbratrans_web/models.py create mode 100644 umbratrans_web/repository.py create mode 100644 umbratrans_web/routes_auth.py create mode 100644 umbratrans_web/routes_uadmin.py create mode 100644 umbratrans_web/schemas.py create mode 100644 umbratrans_web/security.py create mode 100644 umbratrans_web/service.py create mode 100644 umbratrans_web/tests/__init__.py create mode 100644 umbratrans_web/tests/conftest.py create mode 100644 umbratrans_web/tests/test_api_auth.py create mode 100644 umbratrans_web/tests/test_api_uadmin.py create mode 100644 umbratrans_web/tests/test_cli.py create mode 100644 umbratrans_web/tests/test_config.py create mode 100644 umbratrans_web/tests/test_migrations.py create mode 100644 umbratrans_web/tests/test_repository.py create mode 100644 umbratrans_web/tests/test_security.py create mode 100644 umbratrans_web/tests/test_service.py 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" }, +]