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.
This commit is contained in:
umbra audio
2026-09-26 08:29:23 -07:00
parent 37b2056808
commit b474995414
33 changed files with 8769 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

126
umbratrans_web/README.md Normal file
View File

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

View File

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

View File

@@ -0,0 +1,36 @@
"""Module entry point: ``python -m umbratrans_web <command>``.
``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())

448
umbratrans_web/app.py Normal file
View File

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

336
umbratrans_web/cli.py Normal file
View File

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

292
umbratrans_web/config.py Normal file
View File

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

View File

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

186
umbratrans_web/errors.py Normal file
View File

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

View File

@@ -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 <app_role>;
-- -- 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('<app_role>', 'public.user_credentials', 'SELECT');
-- SELECT has_table_privilege('<app_role>', '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;

View File

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

View File

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

377
umbratrans_web/models.py Normal file
View File

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

View File

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

View File

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

View File

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

270
umbratrans_web/schemas.py Normal file
View File

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

398
umbratrans_web/security.py Normal file
View File

@@ -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"<redacted:{type(raw).__name__}>"
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",
]

1038
umbratrans_web/service.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
"""Test package for the UmbraTrans web account slice."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

615
uv.lock generated
View File

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