diff --git a/.env.example b/.env.example index 25396cb09..6cc4888b6 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,9 @@ # DATA_OTEL_EXPORTER="console" # DATA_OTEL_ENDPOINT=null # DATA_OTEL_PROTOCOL="grpc" +# DATA_AUTH_ENABLED=false +# DATA_AUTH_REQUIRED=false +# DATA_AUTH_SERVER_URL=null # DATA_DATABASE_NAME="madsci_data" # DATA_COLLECTION_NAME="datapoints" # DOCUMENT_DB_URL="mongodb://localhost:27017" @@ -89,6 +92,9 @@ # EVENT_OTEL_EXPORTER="console" # EVENT_OTEL_ENDPOINT=null # EVENT_OTEL_PROTOCOL="grpc" +# EVENT_AUTH_ENABLED=false +# EVENT_AUTH_REQUIRED=false +# EVENT_AUTH_SERVER_URL=null # DOCUMENT_DB_URL="mongodb://localhost:27017" # EVENT_DATABASE_NAME="madsci_events" # EVENT_COLLECTION_NAME="events" @@ -131,6 +137,9 @@ # WORKCELL_OTEL_EXPORTER="console" # WORKCELL_OTEL_ENDPOINT=null # WORKCELL_OTEL_PROTOCOL="grpc" +# WORKCELL_AUTH_ENABLED=false +# WORKCELL_AUTH_REQUIRED=false +# WORKCELL_AUTH_SERVER_URL=null # WORKCELL_NODES=null # WORKCELLS_DIRECTORY=".madsci/workcells" # CACHE_HOST="localhost" @@ -172,6 +181,9 @@ # EXPERIMENT_OTEL_EXPORTER="console" # EXPERIMENT_OTEL_ENDPOINT=null # EXPERIMENT_OTEL_PROTOCOL="grpc" +# EXPERIMENT_AUTH_ENABLED=false +# EXPERIMENT_AUTH_REQUIRED=false +# EXPERIMENT_AUTH_SERVER_URL=null # DOCUMENT_DB_URL="mongodb://localhost:27017" # EXPERIMENT_DATABASE_NAME="madsci_experiments" # EXPERIMENT_COLLECTION_NAME="experiments" @@ -201,6 +213,9 @@ # RESOURCE_OTEL_EXPORTER="console" # RESOURCE_OTEL_ENDPOINT=null # RESOURCE_OTEL_PROTOCOL="grpc" +# RESOURCE_AUTH_ENABLED=false +# RESOURCE_AUTH_REQUIRED=false +# RESOURCE_AUTH_SERVER_URL=null # RESOURCE_DEFAULT_TEMPLATES=null # RESOURCE_DB_URL="postgresql://madsci:madsci@localhost:5434/resources" @@ -229,6 +244,9 @@ # LAB_OTEL_EXPORTER="console" # LAB_OTEL_ENDPOINT=null # LAB_OTEL_PROTOCOL="grpc" +# LAB_AUTH_ENABLED=false +# LAB_AUTH_REQUIRED=false +# LAB_AUTH_SERVER_URL=null # LAB_DASHBOARD_FILES_PATH="~/MADSci/ui/dist" ### LocationManagerSettings @@ -256,6 +274,9 @@ # LOCATION_OTEL_EXPORTER="console" # LOCATION_OTEL_ENDPOINT=null # LOCATION_OTEL_PROTOCOL="grpc" +# LOCATION_AUTH_ENABLED=false +# LOCATION_AUTH_REQUIRED=false +# LOCATION_AUTH_SERVER_URL=null # LOCATION_RECONCILIATION_INTERVAL_SECONDS=30.0 # LOCATION_RECONCILIATION_ENABLED=true # LOCATION_LAB_CONFIG_FILE="locations.yaml" @@ -536,3 +557,45 @@ # RESOURCES_MIGRATION_BACKUP_ONLY=false # RESOURCES_MIGRATION_RESTORE_FROM=null # RESOURCES_MIGRATION_GENERATE_MIGRATION=null + +### AuthManagerSettings + +# AUTH_SERVER_URL="http://localhost:8007/" +# AUTH_MANAGER_ID=null +# AUTH_MANAGER_TYPE="auth_manager" +# AUTH_RATE_LIMIT_ENABLED=true +# AUTH_RATE_LIMIT_REQUESTS=300 +# AUTH_RATE_LIMIT_WINDOW=60 +# AUTH_RATE_LIMIT_SHORT_REQUESTS=50 +# AUTH_RATE_LIMIT_SHORT_WINDOW=1 +# AUTH_RATE_LIMIT_CLEANUP_INTERVAL=300 +# AUTH_RATE_LIMIT_EXEMPT_IPS=null +# AUTH_UVICORN_WORKERS=null +# AUTH_UVICORN_LIMIT_CONCURRENCY=null +# AUTH_UVICORN_LIMIT_MAX_REQUESTS=null +# AUTH_ENABLE_REGISTRY_RESOLUTION=true +# AUTH_MANAGER_NAME=null +# AUTH_MANAGER_DESCRIPTION=null +# AUTH_LAB_URL=null +# AUTH_REGISTRY_LOCK_TIMEOUT=60.0 +# AUTH_OTEL_ENABLED=false +# AUTH_OTEL_SERVICE_NAME=null +# AUTH_OTEL_EXPORTER="console" +# AUTH_OTEL_ENDPOINT=null +# AUTH_OTEL_PROTOCOL="grpc" +# AUTH_AUTH_ENABLED=true +# AUTH_AUTH_REQUIRED=true +# AUTH_AUTH_SERVER_URL=null +# DATABASE_URL="postgresql://madsci:madsci@localhost/madsci_auth" +# AUTH_LAB_ID=null +# AUTH_ACCESS_TOKEN_TTL=900 +# AUTH_REFRESH_TOKEN_TTL=2592000 +# AUTH_SIGNING_KEY_TTL=7776000 +# AUTH_ARGON2_TIME_COST=3 +# AUTH_ARGON2_MEMORY_COST=65536 +# AUTH_ARGON2_PARALLELISM=4 +# AUTH_DENY_LIST_PERSIST_GRACE=300 +# AUTH_TOKEN_CLOCK_SKEW_SECONDS=30 +# AUTH_TRUST_FORWARDED_FOR=false +# AUTH_LOCAL_AUDIT_LOG_PATH=null +# AUTH_LOCAL_AUDIT_LOG_MAX_BYTES=104857600 diff --git a/AGENTS.md b/AGENTS.md index 952dfa5a6..26a9a614f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ The system follows a microservices architecture with the following main componen - **madsci_data_manager**: Data capture, storage, and querying (Port 8004) - **madsci_workcell_manager**: Workflow coordination and scheduling (Port 8005) - **madsci_location_manager**: Laboratory location management, resource attachments, and node-specific references (Port 8006) +- **madsci_auth_manager**: Authentication and authorization service — users, projects, service accounts, node identities, JWT issuance, JWKS, RBAC (Port 8007). Default-disabled across all managers (`auth_enabled=False`); opt in per the rollout in `docs/guides/auth_operator.md`. Use `AuthClient` (`from madsci.client.auth_client import AuthClient`) for programmatic access; install ambient propagation via `auth_client_context()` so other service clients pick up bearer tokens automatically. Apply `@requires(permission=...)` from `madsci.common.auth_decorators` to enforce per-endpoint authorization. ### Frontend - **ui/**: Vue 3 + Vuetify dashboard for lab management and monitoring @@ -429,6 +430,7 @@ When TUI screens, CLI commands, or application code needs to communicate with MA | `DataClient` | Data Manager | 8004 | | `WorkcellClient` | Workcell Manager | 8005 | | `LocationClient` | Location Manager | 8006 | +| `AuthClient` | Auth Manager | 8007 | | `RestNodeClient` | Direct node communication | varies | Client classes handle Pydantic model deserialization (avoiding field alias bugs like `_id` vs `experiment_id`), retry strategies, connection pooling, consistent error handling, and rate limiting. diff --git a/README.md b/README.md index fdc2620e0..e2f9d049b 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ docker compose up # Starts all services with example configuration MADSci uses environment variables for configuration with hierarchical precedence. Key patterns: -- **Service URLs**: Each manager defaults to `localhost` with specific ports (Event: 8001, Experiment: 8002, Resource: 8003, Data: 8004, Workcell: 8005, Location: 8006, etc.) +- **Service URLs**: Each manager defaults to `localhost` with specific ports (Event: 8001, Experiment: 8002, Resource: 8003, Data: 8004, Workcell: 8005, Location: 8006, Auth: 8007) - **Database connections**: FerretDB (document database)/PostgreSQL on localhost by default - **File storage**: Defaults to `~/.madsci/` subdirectories - **Environment prefixes**: Each service has a unique prefix (e.g., `WORKCELL_`, `EVENT_`, `LOCATION_`) @@ -123,11 +123,23 @@ MADSci uses environment variables for configuration with hierarchical precedence See [Configuration.md](docs/Configuration.md) for comprehensive options, [example_lab/](./examples/example_lab/) for working configurations, and [OBSERVABILITY.md](./docs/guides/observability.md) for OpenTelemetry setup. +## Auth Manager (v0.8 — opt-in) + +MADSci ships an OAuth 2.0 + OIDC-style **Auth Manager** (port 8007) for users, projects, service-accounts, node identities, and JWT-based service-to-service trust. It is **default-disabled** so existing deployments keep working unchanged; deployments opt in by: + +1. Bootstrapping the Auth Manager (`madsci auth bootstrap`). +2. Registering each consuming manager and node, distributing client secrets. +3. Setting `auth_enabled=True, auth_required=False` on each manager (migration mode). +4. Flipping `auth_required=True` once traffic is clean. + +See [`docs/guides/auth.md`](docs/guides/auth.md) for the architecture and token model and [`docs/guides/auth_operator.md`](docs/guides/auth_operator.md) for the bootstrap and rollout runbook. + ## Roadmap We're working on bringing the following additional components to MADSci: -- **Auth Manager**: For handling authentication and user and group management for an autonomous lab. +- **Globus / ORCID OIDC federation** — cross-lab user identity (follow-on to the Auth Manager). +- **mTLS for nodes** — TLS-based node trust, layered onto the existing `NodeIdentity` model (slots into the reserved `mtls_cert_fingerprint` field). ## Getting Started diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c5a0b47a8..1fcbd3c95 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Removed +### Security + +#### Auth Manager hardening (review-driven follow-ups) +- **Auth Manager admin endpoints now require authentication.** Every admin route on the Auth Manager itself (`POST /users`, `/projects`, `/roles`, `/roles/grant`, `/service-accounts`, `/node-identities`, `POST /credentials/{id}/rotate`, `POST /keys/rotate`, `DELETE /keys/{kid}`, every `GET` listing endpoint) carries an explicit `@requires(permission=...)` check, and the Auth Manager mounts `AuthMiddleware` on itself with an explicit unauthenticated allowlist (`/token`, `/.well-known/jwks.json`, `/health`, `/health/keys`, `/settings`, `/deny-list`, `/introspect`). New permission strings: `auth.user.{read,write}`, `auth.project.{read,write}`, `auth.role.{read,write,grant}`, `auth.principal.write`, `auth.credentials.rotate`, `auth.key.{read,rotate,retire}`, `auth.token.{introspect,revoke}`. +- **`POST /introspect` follows RFC 7662** — unauthenticated callers (or callers without `auth.token.introspect`) receive `{"active": false}` rather than the full claims dump. +- **`POST /revoke` requires authentication** — self-revocation (matching `sub`) is allowed; revoking another principal's token requires `auth.token.revoke`. +- **JWT verification pins `RS256`.** Both `TokenService.verify_token` and `AuthClient.verify_jwt` reject any other JWS algorithm before the JOSE library touches the token (and pass the same allowlist to the library's own `algorithms=` argument), closing the alg-confusion attack class (e.g., HS256-with-public-key forgery). +- **JWT verification applies a configurable clock-skew leeway** (`AuthManagerSettings.token_clock_skew_seconds`, default 30s). Applies to both server and client verification. +- **Refresh-token consumption is atomic.** `consume_refresh_token` now uses an atomic claim/marker pattern so two parallel consumers of the same refresh token cannot both succeed; the loser fires the family-revoke reuse-detection path. The `rotated_to` column links parent → child for forensic walks. New Alembic migration `0002_refresh_token_partial_unique_index` adds a partial unique index on `refresh_tokens(token_hash) WHERE revoked_at IS NULL`. +- **Auth Manager refuses to start without a bound `lab_id`.** The `"lab-unbound"` placeholder audience is gone; two unbound deployments can no longer mutually trust each other's tokens. +- **`madsci auth bootstrap` no longer accepts `--password` on argv** (leaks via `ps`). Source the password from `MADSCI_AUTH_BOOTSTRAP_PASSWORD` env var or the interactive prompt. +- **`X-Forwarded-For` is no longer trusted by default.** New `AuthManagerSettings.trust_forwarded_for` (default `False`) gates whether `_client_ip` reads the header. Operators behind a real proxy must opt in. +- **`examples/example_lab/compose.yaml` Auth Manager DB URL fix.** The `auth_manager` service uses `network_mode: host` (like every other manager), but the `AUTH_DATABASE_URL` env var was set to the container DNS name `madsci_postgres_auth:5432` — which doesn't resolve under host networking, so Auth Manager crashed on startup. Changed to `localhost:${AUTH_POSTGRES_PORT:-5435}` (the host-mapped port, matching the pattern other managers use for postgres). Caught during the auth-manager-security-hardening smoke test. +- **Auth Manager defaults to enforcing auth on its own admin surface.** Mitigates the security review's HIGH finding that `AuthManagerSettings` inheriting `auth_enabled=False` from `ManagerSettings` left a fresh deployment fully unauthenticated. `AuthManagerSettings` now overrides both `auth_enabled` and `auth_required` to `True`; `AuthManager` installs a self-verifying `AuthMiddleware` (using its own in-process `TokenService` rather than a remote `AuthClient`); and `AuthManager.run_server()` refuses to bind unless both flags are `True`. Tests that need to bypass enforcement must opt out explicitly (`settings.auth_enabled = False`). +- **`/revoke` honors self-vs-other on the refresh-token branch.** Previously, the self-vs-other permission check applied only when revoking access tokens; refresh-token revocation ran unconditionally for any authenticated principal who knew the bearer string. Now both branches probe the target principal's `sub` and require `auth.token.revoke` for cross-principal revocation. +- **`AuthClient` validates `iss` and `aud` claims** when the new `expected_issuer` and `expected_audience` constructor args are supplied. `manager_base._setup_auth_middleware` now passes the manager's `auth_server_url` as `expected_issuer` and the manager's `lab_id` as `expected_audience` — defense-in-depth against any future cross-lab token-confusion vector. + +### Changed + +- **JOSE library swapped from `Authlib` to `joserfc`.** Authlib 1.7+ deprecates `authlib.jose` in favor of `joserfc` (the same author's successor library). `madsci.auth_manager` and `madsci.client.auth_client` both call `joserfc.jwt.encode`/`decode`, build keys via `joserfc.jwk.RSAKey`/`KeySet`, and validate claims via `JWTClaimsRegistry`. The `Authlib>=1.3.0` dependency is replaced with `joserfc>=1.0.0` in `madsci.auth_manager` and `madsci.client`. No behavioral change for callers of `AuthClient`; the JWT format and verification semantics are identical. Eliminates the runtime `AuthlibDeprecationWarning`. - **`madsci new workcell` subcommand**: The workcell template generated an orphaned YAML format that didn't correspond to any Pydantic model. Workcell configuration is handled by `WorkcellManagerSettings` via `settings.yaml`. - **`LabClient.get_definition()`**: The Lab Manager no longer serves a `/definition` endpoint. Use `get_lab_context()` or `get_lab_health()` instead. - **`WORKCELL` template category**: Removed from `TemplateCategory` enum. @@ -18,6 +37,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`LabManagerDefinition`**: Now emits `MadsciDeprecationWarning` on instantiation (v0.7.0 removal). Use `LabManagerSettings` for configuration. ### Added + +#### Auth Manager (Foundation) +- **`madsci_auth_manager` package (port 8007)**: New OAuth 2.0 + OIDC-style identity service. Issues RS256 JWT access tokens (15-min default) signed by a rotating keypair; opaque refresh tokens stored server-side with reuse-detection. Exposes `/token` (password, refresh_token, client_credentials), `/introspect`, `/revoke`, `/.well-known/jwks.json`, `/deny-list` (with `ETag` conditional fetch), users, projects, roles, service-accounts, node-identities, and key-rotation endpoints. Single-tenant (per Decision 12 — `aud = lab_id`). PostgreSQL-backed via `SQLAlchemyHandler`; in-memory `SQLiteHandler` for tests. Default-disabled at all consuming managers; opt-in via `auth_enabled=True`. +- **`AuthClient`** (`madsci.client.auth_client`): `login()`, `refresh()`, `client_credentials_login()`, `verify_jwt()` (JWKS-cached + force-refresh on signature failure), `introspect()`, `revoke()`, deny-list polling, plus admin surface (users, projects, roles, service-accounts, nodes, credentials, keys). +- **`AuthMiddleware`** on `AbstractManagerBase`: When `auth_enabled=True`, validates `Authorization: Bearer ` against cached JWKS, populates `request.state.principal`, and enters an `ownership_context()` for the request lifetime. `auth_required=False` migration mode passes unauth'd requests through with a structured warning. +- **`auth_client_context()`**: Ambient `AuthClient` propagation. When set, `create_httpx_client()` automatically injects `Authorization: Bearer ` and force-refreshes-and-retries on 401. +- **`@requires(permission=..., project_from=...)`**: Decorator for `Routable` endpoints. 401 unauthenticated, 403 missing permission, 403 if `project_from` resolves a project the principal is not a member of. +- **`madsci auth` CLI**: `bootstrap`, `user create|deactivate|password|grant|list`, `project create|list|members`, `manager register|list`, `node register|list`, `credentials rotate`, `keys rotate|list|retire`. +- **Migration guides**: [`docs/guides/auth.md`](guides/auth.md) (architecture, token model, RBAC) and [`docs/guides/auth_operator.md`](guides/auth_operator.md) (bootstrap, secret distribution with required `0600` mode and `.gitignore` treatment, key rotation, HTTPS termination + `X-Forwarded-For`, audit-log retention, local audit-log fallback bound + rotation alerting, the `auth_enabled → auth_required` rollout). +- **`MadsciBaseSettings`/`ManagerSettings` new fields**: `auth_enabled`, `auth_required`, `auth_server_url`. Defaults preserve current behavior (auth disabled). +- **`OwnershipInfo.from_jwt_claims(claims)`**: Canonical mapping from validated `JWTClaims` to `OwnershipInfo` (claims-sourced fields override body-supplied values when auth is enabled, per Decision 10). +- **Port 8007 reserved** for the Auth Manager across CLAUDE.md, doctor checks, and `madsci start --mode=local`. + - **`target_model` field on `TemplateManifest`**: Templates that generate YAML/JSON config can now declare which Pydantic model the output should validate against. Automated tests verify template output matches the declared model. - **Settings file validation in `madsci validate`**: The validate command now supports `settings.yaml` and `*.settings.yaml` files, validating against per-manager settings classes (Lab, Event, Experiment, Resource, Data, Workcell, Location). - **Pydantic-first data modeling guidance**: Added to CLAUDE.md, AGENTS.md, and agent skills to codify the rule that every YAML/JSON config format must have a corresponding Pydantic model. diff --git a/docs/Configuration.md b/docs/Configuration.md index e7e5cd59c..6d8fb6bc2 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -62,35 +62,38 @@ Settings for the MADSci Data Manager. **Environment Prefix**: `DATA_` -| Name | Type | Default | Description | Example | -|--------------------------------------------------------------------------------------------|-------------------------------------|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| -| `DATA_SERVER_URL` | `AnyUrl` | `"http://localhost:8004/"` | The URL of the data manager server. | `"http://localhost:8004/"` | -| `DATA_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | -| `DATA_MANAGER_TYPE` | `ManagerType` \| `null` | `"data_manager"` | The type of manager. | `"data_manager"` | -| `DATA_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | -| `DATA_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | -| `DATA_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | -| `DATA_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | -| `DATA_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | -| `DATA_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | -| `DATA_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | -| `DATA_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | -| `DATA_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | -| `DATA_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | -| `DATA_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | -| `DATA_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | -| `DATA_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | -| `DATA_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | -| `DATA_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | -| `DATA_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | -| `DATA_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | -| `DATA_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | -| `DATA_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | -| `DATA_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | -| `DATA_DATABASE_NAME` | `string` | `"madsci_data"` | The name of the MongoDB database where events are stored. | `"madsci_data"` | -| `DATA_COLLECTION_NAME` | `string` | `"datapoints"` | The name of the MongoDB collection where data are stored. | `"datapoints"` | -| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `DATA_DB_URL` \| `DB_URL` \| `DATA_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017"` | The URL of the MongoDB-compatible document database used by the Data Manager. | `"mongodb://localhost:27017"` | -| `DATA_FILE_STORAGE_PATH` | `string` \| `Path` | `".madsci/datapoints"` | The path where files are stored on the server. | `".madsci/datapoints"` | +| Name | Type | Default | Description | Example | +|--------------------------------------------------------------------------------------------|-------------------------------------|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| +| `DATA_SERVER_URL` | `AnyUrl` | `"http://localhost:8004/"` | The URL of the data manager server. | `"http://localhost:8004/"` | +| `DATA_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `DATA_MANAGER_TYPE` | `ManagerType` \| `null` | `"data_manager"` | The type of manager. | `"data_manager"` | +| `DATA_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `DATA_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `DATA_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `DATA_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `DATA_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `DATA_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `DATA_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `DATA_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `DATA_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `DATA_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `DATA_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `DATA_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `DATA_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `DATA_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `DATA_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `DATA_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `DATA_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `DATA_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `DATA_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `DATA_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `DATA_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `DATA_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `DATA_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `DATA_DATABASE_NAME` | `string` | `"madsci_data"` | The name of the MongoDB database where events are stored. | `"madsci_data"` | +| `DATA_COLLECTION_NAME` | `string` | `"datapoints"` | The name of the MongoDB collection where data are stored. | `"datapoints"` | +| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `DATA_DB_URL` \| `DB_URL` \| `DATA_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017"` | The URL of the MongoDB-compatible document database used by the Data Manager. | `"mongodb://localhost:27017"` | +| `DATA_FILE_STORAGE_PATH` | `string` \| `Path` | `".madsci/datapoints"` | The path where files are stored on the server. | `".madsci/datapoints"` | ## ObjectStorageSettings @@ -114,47 +117,50 @@ Handles settings and configuration for the Event Manager. **Environment Prefix**: `EVENT_` -| Name | Type | Default | Description | Example | -|----------------------------------------------------------------------------------------------|-------------------------------------|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| -| `EVENT_SERVER_URL` | `AnyUrl` | `"http://localhost:8001/"` | The URL of the Event Manager server. | `"http://localhost:8001/"` | -| `EVENT_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | -| `EVENT_MANAGER_TYPE` | `ManagerType` \| `null` | `"event_manager"` | The type of manager. | `"event_manager"` | -| `EVENT_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | -| `EVENT_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | -| `EVENT_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | -| `EVENT_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | -| `EVENT_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | -| `EVENT_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | -| `EVENT_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | -| `EVENT_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | -| `EVENT_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | -| `EVENT_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | -| `EVENT_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | -| `EVENT_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | -| `EVENT_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | -| `EVENT_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | -| `EVENT_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | -| `EVENT_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | -| `EVENT_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | -| `EVENT_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | -| `EVENT_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | -| `EVENT_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | -| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `EVENT_DB_URL` \| `DB_URL` \| `EVENT_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017"` | The URL of the MongoDB-compatible document database used by the Event Manager. | `"mongodb://localhost:27017"` | -| `EVENT_DATABASE_NAME` | `string` | `"madsci_events"` | The name of the MongoDB database where events are stored. | `"madsci_events"` | -| `EVENT_COLLECTION_NAME` | `string` | `"events"` | The name of the MongoDB collection where events are stored. | `"events"` | -| `EVENT_ALERT_LEVEL` | `EventLogLevel` | `40` | The log level at which to send an alert. | `40` | -| `EVENT_EMAIL_ALERTS` | `EmailAlertsConfig` \| `null` | `null` | The configuration for sending email alerts. | `null` | -| `EVENT_RETENTION_ENABLED` | `boolean` | `false` | Whether automatic event retention is enabled. | `false` | -| `EVENT_SOFT_DELETE_AFTER_DAYS` | `integer` | `90` | Days after which events are soft-deleted (archived). | `90` | -| `EVENT_HARD_DELETE_AFTER_DAYS` | `integer` | `365` | Days after archive when events are permanently deleted via TTL index. | `365` | -| `EVENT_RETENTION_CHECK_INTERVAL_HOURS` | `integer` | `24` | How often to run soft-delete retention checks (in hours). | `24` | -| `EVENT_ARCHIVE_BATCH_SIZE` | `integer` | `1000` | Maximum number of events to archive in a single batch operation. | `1000` | -| `EVENT_MAX_BATCHES_PER_RUN` | `integer` | `100` | Maximum number of batches to process per retention run (0 = unlimited). | `100` | -| `EVENT_BACKUP_ENABLED` | `boolean` | `false` | Whether automatic event backups are enabled. | `false` | -| `EVENT_BACKUP_SCHEDULE` | `string` \| `null` | `null` | Cron expression for backup schedule (e.g., '0 2 * * *' for 2am daily). | `null` | -| `EVENT_BACKUP_DIR` | `string` \| `Path` | `".madsci/backups/events"` | Directory for event backups. | `".madsci/backups/events"` | -| `EVENT_BACKUP_MAX_COUNT` | `integer` | `10` | Maximum number of backup files to keep. | `10` | -| `EVENT_FAIL_ON_RETENTION_ERROR` | `boolean` | `false` | If True, raise exceptions on retention failures. If False, log and continue. | `false` | +| Name | Type | Default | Description | Example | +|----------------------------------------------------------------------------------------------|-------------------------------------|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| +| `EVENT_SERVER_URL` | `AnyUrl` | `"http://localhost:8001/"` | The URL of the Event Manager server. | `"http://localhost:8001/"` | +| `EVENT_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `EVENT_MANAGER_TYPE` | `ManagerType` \| `null` | `"event_manager"` | The type of manager. | `"event_manager"` | +| `EVENT_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `EVENT_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `EVENT_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `EVENT_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `EVENT_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `EVENT_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `EVENT_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `EVENT_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `EVENT_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `EVENT_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `EVENT_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `EVENT_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `EVENT_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `EVENT_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `EVENT_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `EVENT_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `EVENT_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `EVENT_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `EVENT_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `EVENT_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `EVENT_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `EVENT_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `EVENT_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `EVENT_DB_URL` \| `DB_URL` \| `EVENT_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017"` | The URL of the MongoDB-compatible document database used by the Event Manager. | `"mongodb://localhost:27017"` | +| `EVENT_DATABASE_NAME` | `string` | `"madsci_events"` | The name of the MongoDB database where events are stored. | `"madsci_events"` | +| `EVENT_COLLECTION_NAME` | `string` | `"events"` | The name of the MongoDB collection where events are stored. | `"events"` | +| `EVENT_ALERT_LEVEL` | `EventLogLevel` | `40` | The log level at which to send an alert. | `40` | +| `EVENT_EMAIL_ALERTS` | `EmailAlertsConfig` \| `null` | `null` | The configuration for sending email alerts. | `null` | +| `EVENT_RETENTION_ENABLED` | `boolean` | `false` | Whether automatic event retention is enabled. | `false` | +| `EVENT_SOFT_DELETE_AFTER_DAYS` | `integer` | `90` | Days after which events are soft-deleted (archived). | `90` | +| `EVENT_HARD_DELETE_AFTER_DAYS` | `integer` | `365` | Days after archive when events are permanently deleted via TTL index. | `365` | +| `EVENT_RETENTION_CHECK_INTERVAL_HOURS` | `integer` | `24` | How often to run soft-delete retention checks (in hours). | `24` | +| `EVENT_ARCHIVE_BATCH_SIZE` | `integer` | `1000` | Maximum number of events to archive in a single batch operation. | `1000` | +| `EVENT_MAX_BATCHES_PER_RUN` | `integer` | `100` | Maximum number of batches to process per retention run (0 = unlimited). | `100` | +| `EVENT_BACKUP_ENABLED` | `boolean` | `false` | Whether automatic event backups are enabled. | `false` | +| `EVENT_BACKUP_SCHEDULE` | `string` \| `null` | `null` | Cron expression for backup schedule (e.g., '0 2 * * *' for 2am daily). | `null` | +| `EVENT_BACKUP_DIR` | `string` \| `Path` | `".madsci/backups/events"` | Directory for event backups. | `".madsci/backups/events"` | +| `EVENT_BACKUP_MAX_COUNT` | `integer` | `10` | Maximum number of backup files to keep. | `10` | +| `EVENT_FAIL_ON_RETENTION_ERROR` | `boolean` | `false` | If True, raise exceptions on retention failures. If False, log and continue. | `false` | ## WorkcellManagerSettings @@ -187,6 +193,9 @@ Settings for the MADSci Workcell Manager. | `WORKCELL_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | | `WORKCELL_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | | `WORKCELL_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `WORKCELL_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `WORKCELL_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `WORKCELL_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | | `WORKCELL_NODES` | `object` \| `null` | `null` | Node URLs for the workcell, as a JSON dict mapping node names to their URLs. | `null` | | `WORKCELLS_DIRECTORY` \| `WORKCELL_WORKCELLS_DIRECTORY` | `string` \| `Path` \| `null` | `".madsci/workcells"` | Directory used to store workcell-related files in. Defaults to .madsci/workcells. Workcell-related files will be stored in a sub-folder with the workcell name. | `".madsci/workcells"` | | `CACHE_HOST` \| `REDIS_HOST` \| `WORKCELL_CACHE_HOST` | `string` | `"localhost"` | The hostname for the cache server (Valkey/Redis-compatible). | `"localhost"` | @@ -209,34 +218,37 @@ Settings for the MADSci Experiment Manager. **Environment Prefix**: `EXPERIMENT_` -| Name | Type | Default | Description | Example | -|--------------------------------------------------------------------------------------------------------|-------------------------------------|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| -| `EXPERIMENT_SERVER_URL` | `AnyUrl` | `"http://localhost:8002/"` | The URL of the experiment manager server. | `"http://localhost:8002/"` | -| `EXPERIMENT_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | -| `EXPERIMENT_MANAGER_TYPE` | `ManagerType` \| `null` | `"experiment_manager"` | The type of manager. | `"experiment_manager"` | -| `EXPERIMENT_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | -| `EXPERIMENT_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | -| `EXPERIMENT_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | -| `EXPERIMENT_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | -| `EXPERIMENT_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | -| `EXPERIMENT_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | -| `EXPERIMENT_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | -| `EXPERIMENT_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | -| `EXPERIMENT_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | -| `EXPERIMENT_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | -| `EXPERIMENT_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | -| `EXPERIMENT_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | -| `EXPERIMENT_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | -| `EXPERIMENT_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | -| `EXPERIMENT_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | -| `EXPERIMENT_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | -| `EXPERIMENT_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | -| `EXPERIMENT_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | -| `EXPERIMENT_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | -| `EXPERIMENT_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | -| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `EXPERIMENT_DB_URL` \| `DB_URL` \| `EXPERIMENT_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017"` | The URL of the MongoDB-compatible document database for the experiment manager. | `"mongodb://localhost:27017"` | -| `EXPERIMENT_DATABASE_NAME` | `string` | `"madsci_experiments"` | The name of the MongoDB database where events are stored. | `"madsci_experiments"` | -| `EXPERIMENT_COLLECTION_NAME` | `string` | `"experiments"` | The name of the MongoDB collection where events are stored. | `"experiments"` | +| Name | Type | Default | Description | Example | +|--------------------------------------------------------------------------------------------------------|-------------------------------------|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------| +| `EXPERIMENT_SERVER_URL` | `AnyUrl` | `"http://localhost:8002/"` | The URL of the experiment manager server. | `"http://localhost:8002/"` | +| `EXPERIMENT_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `EXPERIMENT_MANAGER_TYPE` | `ManagerType` \| `null` | `"experiment_manager"` | The type of manager. | `"experiment_manager"` | +| `EXPERIMENT_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `EXPERIMENT_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `EXPERIMENT_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `EXPERIMENT_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `EXPERIMENT_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `EXPERIMENT_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `EXPERIMENT_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `EXPERIMENT_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `EXPERIMENT_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `EXPERIMENT_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `EXPERIMENT_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `EXPERIMENT_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `EXPERIMENT_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `EXPERIMENT_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `EXPERIMENT_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `EXPERIMENT_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `EXPERIMENT_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `EXPERIMENT_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `EXPERIMENT_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `EXPERIMENT_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `EXPERIMENT_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `EXPERIMENT_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `EXPERIMENT_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `EXPERIMENT_DB_URL` \| `DB_URL` \| `EXPERIMENT_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017"` | The URL of the MongoDB-compatible document database for the experiment manager. | `"mongodb://localhost:27017"` | +| `EXPERIMENT_DATABASE_NAME` | `string` | `"madsci_experiments"` | The name of the MongoDB database where events are stored. | `"madsci_experiments"` | +| `EXPERIMENT_COLLECTION_NAME` | `string` | `"experiments"` | The name of the MongoDB collection where events are stored. | `"experiments"` | ## ResourceManagerSettings @@ -244,33 +256,36 @@ Settings for the MADSci Resource Manager. **Environment Prefix**: `RESOURCE_` -| Name | Type | Default | Description | Example | -|----------------------------------------|-------------------------------------|---------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| -| `RESOURCE_SERVER_URL` | `AnyUrl` | `"http://localhost:8003"` | The URL of the resource manager server. | `"http://localhost:8003"` | -| `RESOURCE_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | -| `RESOURCE_MANAGER_TYPE` | `ManagerType` \| `null` | `"resource_manager"` | The type of manager. | `"resource_manager"` | -| `RESOURCE_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | -| `RESOURCE_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | -| `RESOURCE_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | -| `RESOURCE_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | -| `RESOURCE_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | -| `RESOURCE_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | -| `RESOURCE_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | -| `RESOURCE_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | -| `RESOURCE_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | -| `RESOURCE_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | -| `RESOURCE_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | -| `RESOURCE_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | -| `RESOURCE_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | -| `RESOURCE_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | -| `RESOURCE_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | -| `RESOURCE_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | -| `RESOURCE_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | -| `RESOURCE_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | -| `RESOURCE_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | -| `RESOURCE_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | -| `RESOURCE_DEFAULT_TEMPLATES` | `array` \| `null` | `null` | Default resource template definitions to create or update on manager startup. | `null` | -| `RESOURCE_DB_URL` | `string` | `"postgresql://madsci:madsci@localhost:5434/resources"` | The URL of the database for the resource manager. | `"postgresql://madsci:madsci@localhost:5434/resources"` | +| Name | Type | Default | Description | Example | +|----------------------------------------|-------------------------------------|---------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| +| `RESOURCE_SERVER_URL` | `AnyUrl` | `"http://localhost:8003"` | The URL of the resource manager server. | `"http://localhost:8003"` | +| `RESOURCE_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `RESOURCE_MANAGER_TYPE` | `ManagerType` \| `null` | `"resource_manager"` | The type of manager. | `"resource_manager"` | +| `RESOURCE_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `RESOURCE_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `RESOURCE_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `RESOURCE_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `RESOURCE_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `RESOURCE_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `RESOURCE_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `RESOURCE_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `RESOURCE_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `RESOURCE_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `RESOURCE_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `RESOURCE_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `RESOURCE_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `RESOURCE_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `RESOURCE_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `RESOURCE_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `RESOURCE_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `RESOURCE_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `RESOURCE_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `RESOURCE_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `RESOURCE_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `RESOURCE_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `RESOURCE_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `RESOURCE_DEFAULT_TEMPLATES` | `array` \| `null` | `null` | Default resource template definitions to create or update on manager startup. | `null` | +| `RESOURCE_DB_URL` | `string` | `"postgresql://madsci:madsci@localhost:5434/resources"` | The URL of the database for the resource manager. | `"postgresql://madsci:madsci@localhost:5434/resources"` | ## LabManagerSettings @@ -278,32 +293,35 @@ Settings for the MADSci Lab. **Environment Prefix**: `LAB_` -| Name | Type | Default | Description | Example | -|-----------------------------------|-------------------------------------|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------| -| `LAB_SERVER_URL` | `AnyUrl` | `"http://localhost:8000/"` | The URL of the lab manager. | `"http://localhost:8000/"` | -| `LAB_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | -| `LAB_MANAGER_TYPE` | `ManagerType` \| `null` | `"lab_manager"` | The type of manager. | `"lab_manager"` | -| `LAB_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | -| `LAB_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | -| `LAB_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | -| `LAB_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | -| `LAB_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | -| `LAB_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | -| `LAB_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | -| `LAB_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | -| `LAB_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | -| `LAB_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | -| `LAB_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | -| `LAB_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | -| `LAB_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | -| `LAB_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | -| `LAB_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | -| `LAB_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | -| `LAB_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | -| `LAB_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | -| `LAB_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | -| `LAB_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | -| `LAB_DASHBOARD_FILES_PATH` | `string` \| `Path` \| `null` | `"~/MADSci/ui/dist"` | Path to the static files for the dashboard. Set to None to disable the dashboard. | `"~/MADSci/ui/dist"` | +| Name | Type | Default | Description | Example | +|-----------------------------------|-------------------------------------|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------| +| `LAB_SERVER_URL` | `AnyUrl` | `"http://localhost:8000/"` | The URL of the lab manager. | `"http://localhost:8000/"` | +| `LAB_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `LAB_MANAGER_TYPE` | `ManagerType` \| `null` | `"lab_manager"` | The type of manager. | `"lab_manager"` | +| `LAB_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `LAB_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `LAB_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `LAB_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `LAB_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `LAB_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `LAB_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `LAB_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `LAB_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `LAB_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `LAB_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `LAB_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `LAB_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `LAB_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `LAB_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `LAB_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `LAB_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `LAB_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `LAB_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `LAB_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `LAB_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `LAB_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `LAB_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `LAB_DASHBOARD_FILES_PATH` | `string` \| `Path` \| `null` | `"~/MADSci/ui/dist"` | Path to the static files for the dashboard. Set to None to disable the dashboard. | `"~/MADSci/ui/dist"` | ## LocationManagerSettings @@ -311,39 +329,42 @@ Settings for the LocationManager. **Environment Prefix**: `LOCATION_` -| Name | Type | Default | Description | Example | -|-------------------------------------------------------------------|-------------------------------------|--------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------| -| `LOCATION_SERVER_URL` | `AnyUrl` | `"http://localhost:8006/"` | The URL where this manager's server runs. | `"http://localhost:8006/"` | -| `LOCATION_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | -| `LOCATION_MANAGER_TYPE` | `ManagerType` \| `null` | `"location_manager"` | The type of manager. | `"location_manager"` | -| `LOCATION_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | -| `LOCATION_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | -| `LOCATION_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | -| `LOCATION_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | -| `LOCATION_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | -| `LOCATION_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | -| `LOCATION_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | -| `LOCATION_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | -| `LOCATION_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | -| `LOCATION_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | -| `LOCATION_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | -| `LOCATION_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | -| `LOCATION_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | -| `LOCATION_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | -| `LOCATION_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | -| `LOCATION_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | -| `LOCATION_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | -| `LOCATION_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | -| `LOCATION_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | -| `LOCATION_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | -| `LOCATION_RECONCILIATION_INTERVAL_SECONDS` | `number` | `30.0` | Interval in seconds between background reconciliation cycles for lazy template resolution. | `30.0` | -| `LOCATION_RECONCILIATION_ENABLED` | `boolean` | `true` | Whether to enable background reconciliation of unresolved template references. | `true` | -| `LOCATION_LAB_CONFIG_FILE` | `string` \| `null` | `"locations.yaml"` | Path to a YAML file defining lab-level locations and training. Re-read on each reconciliation cycle with desired-state semantics. All locations created from this file are tagged as lab-managed. | `"locations.yaml"` | -| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `LOCATION_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017/"` | URL for the document database (MongoDB/FerretDB) used for persistent location storage. | `"mongodb://localhost:27017/"` | -| `LOCATION_DATABASE_NAME` | `string` | `"madsci_locations"` | Name of the database for persistent location storage. | `"madsci_locations"` | -| `CACHE_HOST` \| `REDIS_HOST` \| `LOCATION_CACHE_HOST` | `string` | `"localhost"` | The host of the cache server (Valkey/Redis-compatible) for transient state (locks, change counters). | `"localhost"` | -| `CACHE_PORT` \| `REDIS_PORT` \| `LOCATION_CACHE_PORT` | `integer` | `6379` | The port of the cache server (Valkey/Redis-compatible) for transient state. | `6379` | -| `CACHE_PASSWORD` \| `REDIS_PASSWORD` \| `LOCATION_CACHE_PASSWORD` | `string` \| `null` | `null` | The password for the cache server (Valkey/Redis-compatible) (if required). | `null` | +| Name | Type | Default | Description | Example | +|-------------------------------------------------------------------|-------------------------------------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------| +| `LOCATION_SERVER_URL` | `AnyUrl` | `"http://localhost:8006/"` | The URL where this manager's server runs. | `"http://localhost:8006/"` | +| `LOCATION_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `LOCATION_MANAGER_TYPE` | `ManagerType` \| `null` | `"location_manager"` | The type of manager. | `"location_manager"` | +| `LOCATION_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `LOCATION_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `LOCATION_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `LOCATION_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `LOCATION_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `LOCATION_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `LOCATION_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `LOCATION_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `LOCATION_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `LOCATION_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `LOCATION_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `LOCATION_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `LOCATION_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `LOCATION_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `LOCATION_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `LOCATION_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `LOCATION_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `LOCATION_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `LOCATION_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `LOCATION_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `LOCATION_AUTH_ENABLED` | `boolean` | `false` | Enable AuthMiddleware on this manager. When True, an AuthClient is constructed against ``auth_server_url`` and incoming requests carrying ``Authorization: Bearer `` are validated. | `false` | +| `LOCATION_AUTH_REQUIRED` | `boolean` | `false` | When True, requests without a valid token are rejected with HTTP 401. When False (the migration mode), unauth'd requests are allowed but a structured warning is emitted. Has no effect unless ``auth_enabled`` is True. | `false` | +| `LOCATION_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `LOCATION_RECONCILIATION_INTERVAL_SECONDS` | `number` | `30.0` | Interval in seconds between background reconciliation cycles for lazy template resolution. | `30.0` | +| `LOCATION_RECONCILIATION_ENABLED` | `boolean` | `true` | Whether to enable background reconciliation of unresolved template references. | `true` | +| `LOCATION_LAB_CONFIG_FILE` | `string` \| `null` | `"locations.yaml"` | Path to a YAML file defining lab-level locations and training. Re-read on each reconciliation cycle with desired-state semantics. All locations created from this file are tagged as lab-managed. | `"locations.yaml"` | +| `DOCUMENT_DB_URL` \| `MONGO_DB_URL` \| `LOCATION_DOCUMENT_DB_URL` | `AnyUrl` | `"mongodb://localhost:27017/"` | URL for the document database (MongoDB/FerretDB) used for persistent location storage. | `"mongodb://localhost:27017/"` | +| `LOCATION_DATABASE_NAME` | `string` | `"madsci_locations"` | Name of the database for persistent location storage. | `"madsci_locations"` | +| `CACHE_HOST` \| `REDIS_HOST` \| `LOCATION_CACHE_HOST` | `string` | `"localhost"` | The host of the cache server (Valkey/Redis-compatible) for transient state (locks, change counters). | `"localhost"` | +| `CACHE_PORT` \| `REDIS_PORT` \| `LOCATION_CACHE_PORT` | `integer` | `6379` | The port of the cache server (Valkey/Redis-compatible) for transient state. | `6379` | +| `CACHE_PASSWORD` \| `REDIS_PASSWORD` \| `LOCATION_CACHE_PASSWORD` | `string` \| `null` | `null` | The password for the cache server (Valkey/Redis-compatible) (if required). | `null` | ### LocationTransferCapabilities @@ -778,3 +799,51 @@ Configuration settings for PostgreSQL database migration operations. | `RESOURCES_MIGRATION_BACKUP_ONLY` | `boolean` | `false` | Only create a backup, do not run migration | `false` | | `RESOURCES_MIGRATION_RESTORE_FROM` | `string` \| `Path` \| `null` | `null` | Restore from specified backup file instead of migrating | `null` | | `RESOURCES_MIGRATION_GENERATE_MIGRATION` | `string` \| `null` | `null` | Generate a new migration file with the given message | `null` | + +## AuthManagerSettings + +Settings for the Auth Manager. + +**Environment Prefix**: `AUTH_` + +| Name | Type | Default | Description | Example | +|--------------------------------------------------------------------|-------------------------------------|------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------| +| `AUTH_SERVER_URL` | `AnyUrl` | `"http://localhost:8007/"` | The URL of the Auth Manager server. | `"http://localhost:8007/"` | +| `AUTH_MANAGER_ID` | `string` \| `null` | `null` | Unique identifier for this manager instance. If not set, a new ULID is generated at runtime. The registry system provides the stable ID thereafter. | `null` | +| `AUTH_MANAGER_TYPE` | `ManagerType` \| `null` | `"auth_manager"` | The type of manager. | `"auth_manager"` | +| `AUTH_RATE_LIMIT_ENABLED` | `boolean` | `true` | Enable rate limiting for API endpoints. | `true` | +| `AUTH_RATE_LIMIT_REQUESTS` | `integer` | `300` | Maximum number of requests allowed per long time window. | `300` | +| `AUTH_RATE_LIMIT_WINDOW` | `integer` | `60` | Long time window for rate limiting in seconds. | `60` | +| `AUTH_RATE_LIMIT_SHORT_REQUESTS` | `integer` \| `null` | `50` | Maximum number of requests allowed per short time window for burst protection. If None, short window limiting is disabled. | `50` | +| `AUTH_RATE_LIMIT_SHORT_WINDOW` | `integer` \| `null` | `1` | Short time window for burst protection in seconds. If None, short window limiting is disabled. | `1` | +| `AUTH_RATE_LIMIT_CLEANUP_INTERVAL` | `integer` | `300` | Interval in seconds between cleanup operations to prevent memory leaks. | `300` | +| `AUTH_RATE_LIMIT_EXEMPT_IPS` | `array` \| `null` | `null` | List of IP addresses exempt from rate limiting. Defaults to localhost IPs (127.0.0.1, ::1) if not specified. | `null` | +| `AUTH_UVICORN_WORKERS` | `integer` \| `null` | `null` | Number of uvicorn worker processes. If None, uses uvicorn default (1). | `null` | +| `AUTH_UVICORN_LIMIT_CONCURRENCY` | `integer` \| `null` | `null` | Maximum number of concurrent connections. If None, no limit is enforced. | `null` | +| `AUTH_UVICORN_LIMIT_MAX_REQUESTS` | `integer` \| `null` | `null` | Maximum number of requests a worker will process before restarting. Helps prevent memory leaks. | `null` | +| `AUTH_ENABLE_REGISTRY_RESOLUTION` | `boolean` | `true` | When true, resolve manager_id from the ID Registry at startup for stable identity across restarts. | `true` | +| `AUTH_MANAGER_NAME` | `string` \| `null` | `null` | Name for this manager instance. Used for registry lookup and display. | `null` | +| `AUTH_MANAGER_DESCRIPTION` | `string` \| `null` | `null` | Human-readable description of this manager instance. | `null` | +| `AUTH_LAB_URL` | `AnyUrl` \| `null` | `null` | Lab Manager URL for distributed registry coordination. | `null` | +| `AUTH_REGISTRY_LOCK_TIMEOUT` | `number` | `60.0` | Seconds to retry registry lock acquisition on contention at startup. Should be at least 2x the lock TTL (30s) to survive ungraceful container restarts. | `60.0` | +| `AUTH_OTEL_ENABLED` | `boolean` | `false` | Enable OpenTelemetry tracing and metrics integration for this manager | `false` | +| `AUTH_OTEL_SERVICE_NAME` | `string` \| `null` | `null` | Override service name for OpenTelemetry (defaults to manager name) | `null` | +| `AUTH_OTEL_EXPORTER` | `"console"` \| `"otlp"` \| `"none"` | `"console"` | OpenTelemetry exporter type: 'console' for development, 'otlp' for production, 'none' to disable | `"console"` | +| `AUTH_OTEL_ENDPOINT` | `string` \| `null` | `null` | OTLP collector endpoint (required when otel_exporter='otlp') | `null` | +| `AUTH_OTEL_PROTOCOL` | `"grpc"` \| `"http"` | `"grpc"` | OTLP transport protocol ('grpc' or 'http') | `"grpc"` | +| `AUTH_AUTH_ENABLED` | `boolean` | `true` | Whether AuthMiddleware is installed on the Auth Manager itself. Defaults to True (the Auth Manager is the one service where unauth'd admin endpoints would be a privilege-escalation vector). Set to False only for in-process unit tests. | `true` | +| `AUTH_AUTH_REQUIRED` | `boolean` | `true` | Whether the Auth Manager rejects unauthenticated requests on non-allowlisted routes (vs. admitting them with ``request.state.principal=None``). Defaults to True; should never be False in production. | `true` | +| `AUTH_AUTH_SERVER_URL` | `AnyUrl` \| `null` | `null` | URL of the lab's Auth Manager. Required when ``auth_enabled``. | `null` | +| `DATABASE_URL` \| `AUTH_DB_URL` \| `DB_URL` \| `AUTH_DATABASE_URL` | `string` | `"postgresql://madsci:madsci@localhost/madsci_auth"` | PostgreSQL URL for Auth Manager persistence. | `"postgresql://madsci:madsci@localhost/madsci_auth"` | +| `AUTH_LAB_ID` | `string` \| `null` | `null` | The lab_id this Auth Manager binds to. Read at bootstrap; an Auth Manager refuses to start later against a different lab_id without an explicit operator-acknowledged migration. | `null` | +| `AUTH_ACCESS_TOKEN_TTL` | `integer` | `900` | Default access-token lifetime in seconds (15 min). | `900` | +| `AUTH_REFRESH_TOKEN_TTL` | `integer` | `2592000` | Default refresh-token lifetime in seconds (30 days). | `2592000` | +| `AUTH_SIGNING_KEY_TTL` | `integer` | `7776000` | Recommended lifetime of a signing key before rotation, in seconds (90 days). | `7776000` | +| `AUTH_ARGON2_TIME_COST` | `integer` | `3` | Argon2id time-cost parameter. | `3` | +| `AUTH_ARGON2_MEMORY_COST` | `integer` | `65536` | Argon2id memory-cost parameter (in KiB; 64 MiB). | `65536` | +| `AUTH_ARGON2_PARALLELISM` | `integer` | `4` | Argon2id parallelism parameter. | `4` | +| `AUTH_DENY_LIST_PERSIST_GRACE` | `integer` | `300` | Seconds past a revoked token's exp to retain its row in revoked_access_tokens. | `300` | +| `AUTH_TOKEN_CLOCK_SKEW_SECONDS` | `integer` | `30` | Leeway (seconds) applied to ``iat``/``exp``/``nbf`` validation when verifying JWTs. Tolerates small clock drift between issuer and verifier. | `30` | +| `AUTH_TRUST_FORWARDED_FOR` | `boolean` | `false` | When True, ``_client_ip`` reads the leftmost ``X-Forwarded-For`` value. Operators behind a trusted reverse proxy must opt in; otherwise the socket peer is used to prevent audit-log spoofing. | `false` | +| `AUTH_LOCAL_AUDIT_LOG_PATH` | `string` \| `null` | `null` | Path to the on-disk fallback audit log. Defaults to ``.madsci/audit/auth-fallback.log``. | `null` | +| `AUTH_LOCAL_AUDIT_LOG_MAX_BYTES` | `integer` | `104857600` | Maximum total size of the local audit log in bytes (100 MB). | `104857600` | diff --git a/docs/api/madsci/auth_manager/auth_server.md b/docs/api/madsci/auth_manager/auth_server.md new file mode 100644 index 000000000..e8616da70 --- /dev/null +++ b/docs/api/madsci/auth_manager/auth_server.md @@ -0,0 +1,126 @@ +Module madsci.auth_manager.auth_server +====================================== +MADSci Auth Manager FastAPI server. + +Implements the OAuth 2.0 token, introspection, revocation, and JWKS +endpoints, plus the admin surface for users, projects, roles, +service-accounts, node identities, signing keys, and the deny-list. + +Per Decision 12, this manager is single-tenant: all data is implicitly +scoped to the deployment's ``lab_id``. + +Classes +------- + +`AuthManager(settings: Optional[AuthManagerSettings] = None, postgres_handler: Optional[PostgresHandler] = None, **kwargs: Any)` +: MADSci Auth Manager REST server. + + Initialize the Auth Manager, optionally injecting a database handler. + + ### Ancestors (in MRO) + + * madsci.common.manager_base.AbstractManagerBase + * madsci.client.client_mixin.MadsciClientMixin + * typing.Generic + * classy_fastapi.routable.Routable + + ### Class variables + + `SETTINGS_CLASS: type[madsci.common.types.base_types.MadsciBaseSettings] | None` + : Settings for the Auth Manager. + + ### Methods + + `add_project_member(self, request: Request, project_id: str, body: AddMemberRequest) ‑> dict[str, str]` + : Add a user to a project with a role. + + `bootstrap(self, *, admin_username: str, admin_password: str, admin_email: Optional[str] = None) ‑> madsci.auth_manager.server_types.BootstrapResponse` + : Idempotent bootstrap: create admin user, signing key, built-in roles. + + `create_project(self, request: Request, body: CreateProjectRequest) ‑> madsci.auth_manager.server_types.ProjectResponse` + : Create a new project. + + `create_role(self, request: Request, body: CreateRoleRequest) ‑> madsci.auth_manager.server_types.RoleResponse` + : Create a new role with permissions. + + `create_server(self, **kwargs: Any) ‑> fastapi.applications.FastAPI` + : Build the FastAPI application with all Auth Manager endpoints registered. + + `create_user(self, request: Request, body: CreateUserRequest) ‑> madsci.auth_manager.server_types.UserResponse` + : Create a new user account. + + `deny_list_endpoint(self, request: Request, response: Response) ‑> madsci.auth_manager.server_types.DenyListResponse` + : Return the persistent jti deny-list, with ETag conditional-fetch support. + + `get_user(self, request: Request, user_id: str) ‑> madsci.auth_manager.server_types.UserResponse` + : Fetch a single user by id. + + `grant_role(self, request: Request, body: GrantRoleRequest) ‑> dict[str, str]` + : Grant a role to a user (optionally project-scoped), service account, or node. + + `initialize(self, **kwargs: Any) ‑> None` + : Initialize handlers, schema, and service objects. + + `introspect_endpoint(self, request: Request, body: IntrospectRequest) ‑> dict[str, typing.Any]` + : OAuth 2.0 Token Introspection (RFC 7662). + + Per RFC 7662 §2.2 the introspection endpoint MUST NOT leak claims to + unauthenticated callers. When AuthMiddleware is installed + (``auth_enabled=True``) we require ``auth.token.introspect``; + unauthorized callers get ``{"active": false}`` (NOT 401/403, to match + the spec's privacy-preserving response shape). + + `jwks_endpoint(self) ‑> dict[str, typing.Any]` + : Public JWKS document — no authentication required. + + `keys_health(self) ‑> madsci.auth_manager.server_types.KeysHealthResponse` + : Report active key count, oldest-key age, and current signing kid. + + `list_keys(self, request: Request) ‑> list[madsci.auth_manager.server_types.KeyInfo]` + : List all signing keys (active, retired, signing flag). + + `list_projects(self, request: Request) ‑> list[madsci.auth_manager.server_types.ProjectResponse]` + : List all projects. + + `list_roles(self, request: Request) ‑> list[madsci.auth_manager.server_types.RoleResponse]` + : List all roles, including their permission strings. + + `list_users(self, request: Request) ‑> list[madsci.auth_manager.server_types.UserResponse]` + : List all user accounts. + + `register_node_identity(self, request: Request, body: RegisterNodeRequest) ‑> madsci.auth_manager.server_types.CredentialResponse` + : Create a node-identity principal and return its plaintext secret once. + + `register_service_account(self, request: Request, body: RegisterServiceAccountRequest) ‑> madsci.auth_manager.server_types.CredentialResponse` + : Create a service-account principal and return its plaintext secret once. + + `remove_project_member(self, request: Request, project_id: str, user_id: str) ‑> dict[str, str]` + : Remove all memberships for a user from a project. + + `retire_key(self, request: Request, kid: str) ‑> dict[str, bool]` + : Retire a signing key (remove from JWKS, delete private material). + + `revoke_endpoint(self, request: Request, body: RevokeRequest) ‑> dict[str, bool]` + : Revoke an access token and/or refresh token. + + Requires authentication when AuthMiddleware is installed. Self- + revocation (caller's own ``sub``) is always allowed; revoking + another principal's token requires ``auth.token.revoke``. + + `rotate_credentials(self, request: Request, client_id: str) ‑> madsci.auth_manager.server_types.CredentialResponse` + : Rotate the client_secret for a service-account or node-identity. + + `rotate_keys(self, request: Request) ‑> madsci.auth_manager.server_types.KeyInfo` + : Generate a new signing keypair, demoting the previous one to verify-only. + + `token_endpoint(self, request: Request, grant_type: str = Form(PydanticUndefined), username: Optional[str] = Form(None), password: Optional[str] = Form(None), refresh_token: Optional[str] = Form(None), client_id: Optional[str] = Form(None), client_secret: Optional[str] = Form(None)) ‑> madsci.common.types.auth_types.TokenResponse` + : OAuth 2.0 token endpoint (password, refresh_token, client_credentials). + + `unauthenticated_paths(self) ‑> set[str]` + : Public endpoints required for token bootstrap and verification. + + Per the auth-manager-security-hardening change: every other admin + route on this manager carries a ``@requires(...)`` permission check. + + `update_user(self, request: Request, user_id: str, body: UpdateUserRequest) ‑> madsci.auth_manager.server_types.UserResponse` + : Patch user fields (deactivate, change password, update email). \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/index.md b/docs/api/madsci/auth_manager/index.md new file mode 100644 index 000000000..dc66e88a9 --- /dev/null +++ b/docs/api/madsci/auth_manager/index.md @@ -0,0 +1,12 @@ +Module madsci.auth_manager +========================== +MADSci Auth Manager. + +Sub-modules +----------- +* madsci.auth_manager.auth_server +* madsci.auth_manager.permissions +* madsci.auth_manager.server_types +* madsci.auth_manager.services +* madsci.auth_manager.tables +* madsci.auth_manager.testing \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/permissions.md b/docs/api/madsci/auth_manager/permissions.md new file mode 100644 index 000000000..d27cf0267 --- /dev/null +++ b/docs/api/madsci/auth_manager/permissions.md @@ -0,0 +1,59 @@ +Module madsci.auth_manager.permissions +====================================== +Canonical permission strings used by the Auth Manager itself. + +Every administrative endpoint on the Auth Manager carries a +``@requires(permission=...)`` decorator naming one of the strings below. +Operators grant these via the built-in ``admin`` role (which holds the ``*`` +wildcard), or via a custom role for narrower delegation (e.g., a separate +``key-rotator`` role for an automated key-rotation job). + +Classes +------- + +`AuthPermissions()` +: Auth Manager admin-endpoint permission strings. + + ### Class variables + + `CREDENTIALS_ROTATE` + : + + `KEY_READ` + : + + `KEY_RETIRE` + : + + `KEY_ROTATE` + : + + `PRINCIPAL_WRITE` + : + + `PROJECT_READ` + : + + `PROJECT_WRITE` + : + + `ROLE_GRANT` + : + + `ROLE_READ` + : + + `ROLE_WRITE` + : + + `TOKEN_INTROSPECT` + : + + `TOKEN_REVOKE` + : + + `USER_READ` + : + + `USER_WRITE` + : \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/server_types.md b/docs/api/madsci/auth_manager/server_types.md new file mode 100644 index 000000000..08e1ce1cd --- /dev/null +++ b/docs/api/madsci/auth_manager/server_types.md @@ -0,0 +1,599 @@ +Module madsci.auth_manager.server_types +======================================= +Pydantic request/response models specific to the Auth Manager server. + +Classes +------- + +`AddMemberRequest(**data: Any)` +: Request body for ``POST /projects/{id}/members``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + `role_id: str` + : + + `user_id: str` + : + +`BootstrapResponse(**data: Any)` +: Response body for the bootstrap CLI / API call. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `admin_role_id: str` + : + + `model_config` + : + + `note: str` + : + + `signing_kid: str` + : + + `user_id: str` + : + + `username: str` + : + +`CreateProjectRequest(**data: Any)` +: Request body for ``POST /projects``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `description: str | None` + : + + `model_config` + : + + `name: str` + : + +`CreateRoleRequest(**data: Any)` +: Request body for ``POST /roles``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `description: str | None` + : + + `model_config` + : + + `name: str` + : + + `permissions: list[str]` + : + +`CreateUserRequest(**data: Any)` +: Request body for ``POST /users``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `email: str | None` + : + + `model_config` + : + + `password: str` + : + + `username: str` + : + +`CredentialResponse(**data: Any)` +: Response that returns a freshly-issued client_id + plaintext secret. + + The plaintext secret is returned exactly once; only its Argon2 hash is + stored. Callers are responsible for distributing the secret out-of-band. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `client_id: str` + : + + `client_secret: str` + : + + `model_config` + : + + `note: str` + : + +`DenyListEntry(**data: Any)` +: A single entry in the deny-list (jti + its access-token expiration). + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `exp: int` + : + + `jti: str` + : + + `model_config` + : + +`DenyListResponse(**data: Any)` +: Response body for ``GET /deny-list``. + + Consumers SHOULD send ``If-None-Match: ""`` on subsequent polls + to receive HTTP 304 when the list is unchanged. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `entries: list[madsci.auth_manager.server_types.DenyListEntry]` + : + + `etag: str` + : + + `model_config` + : + +`GrantRoleRequest(**data: Any)` +: Request body for ``POST /roles/grant``. + + Exactly one of ``user_id`` (with or without ``project_id``), + ``service_account_client_id``, or ``node_identity_client_id`` should be + supplied to identify the grant target. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + `node_identity_client_id: str | None` + : + + `project_id: str | None` + : + + `role_id: str` + : + + `service_account_client_id: str | None` + : + + `user_id: str | None` + : + +`IntrospectRequest(**data: Any)` +: Request body for ``POST /introspect`` (RFC 7662). + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + `token: str` + : + +`KeyInfo(**data: Any)` +: Public summary of a signing key (``private_key_pem`` is never returned). + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `active: bool` + : + + `active_for_signing: bool` + : + + `algorithm: str` + : + + `created_at: str | None` + : + + `kid: str` + : + + `model_config` + : + + `retired_at: str | None` + : + +`KeysHealthResponse(**data: Any)` +: Response body for ``GET /health/keys``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `active_keys: int` + : + + `model_config` + : + + `oldest_key_age_seconds: int | None` + : + + `signing_kid: str | None` + : + +`ProjectResponse(**data: Any)` +: Project-resource response. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `description: str | None` + : + + `model_config` + : + + `name: str` + : + + `project_id: str` + : + +`RegisterNodeRequest(**data: Any)` +: Request body for ``POST /node-identities``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + `node_id: str` + : + + `role_ids: list[str]` + : + + `workcell_id: str | None` + : + +`RegisterServiceAccountRequest(**data: Any)` +: Request body for ``POST /service-accounts``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `manager_id: str` + : + + `model_config` + : + + `role_ids: list[str]` + : + +`RevokeRequest(**data: Any)` +: Request body for ``POST /revoke``. + + Either ``token`` (an access-token JWT) or ``refresh_token`` may be set; + callers usually send both during logout. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + `refresh_token: str | None` + : + + `token: str | None` + : + +`RoleResponse(**data: Any)` +: Role-resource response, including its flattened permission strings. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `description: str | None` + : + + `model_config` + : + + `name: str` + : + + `permissions: list[str]` + : + + `role_id: str` + : + +`TokenErrorResponse(**data: Any)` +: OAuth 2.0 token-endpoint error body (RFC 6749 §5.2). + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `error: str` + : + + `error_description: str | None` + : + + `model_config` + : + +`UpdateUserRequest(**data: Any)` +: Partial-update body for ``PATCH /users/{id}``. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `email: str | None` + : + + `is_active: bool | None` + : + + `model_config` + : + + `new_password: str | None` + : + +`UserResponse(**data: Any)` +: User-resource response (``password_hash`` is never returned). + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `email: str | None` + : + + `is_active: bool` + : + + `model_config` + : + + `user_id: str` + : + + `username: str` + : \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/services/audit_logger.md b/docs/api/madsci/auth_manager/services/audit_logger.md new file mode 100644 index 000000000..263d4fb36 --- /dev/null +++ b/docs/api/madsci/auth_manager/services/audit_logger.md @@ -0,0 +1,91 @@ +Module madsci.auth_manager.services.audit_logger +================================================ +Append-only audit log for the Auth Manager. + +Classes +------- + +`AuditEvent()` +: Canonical audit event type strings. + + These are event-type names persisted in the ``audit_log.event_type`` + column, NOT secrets. The S105 suppression on this class quiets ruff's + hardcoded-password heuristic for the ``TOKEN_*`` and ``USER_PASSWORD_*`` + constants. + + ### Class variables + + `AUDIT_TAMPER_ATTEMPT` + : + + `BOOTSTRAP` + : + + `KEY_RETIRE` + : + + `KEY_ROTATE` + : + + `NODE_REGISTER` + : + + `NODE_ROTATE` + : + + `RATE_LIMITED` + : + + `ROLE_GRANT` + : + + `ROLE_REVOKE` + : + + `SERVICE_ACCOUNT_REGISTER` + : + + `SERVICE_ACCOUNT_ROTATE` + : + + `TOKEN_ISSUE` + : + + `TOKEN_REFRESH` + : + + `TOKEN_REJECT` + : + + `TOKEN_REVOKE` + : + + `USER_CREATE` + : + + `USER_DEACTIVATE` + : + + `USER_PASSWORD_CHANGE` + : + +`AuditLogger(engine: Any)` +: Persist security-relevant events to the ``audit_log`` table. + + Per the ``Audit log`` requirement in ``auth-identity-model/spec.md``, the + table is append-only at the application level. There is no public + ``update``/``delete`` API; any attempt to mutate a row by an admin must + itself produce a new audit entry recording the attempt. + + Bind the logger to a SQLAlchemy engine. + + ### Methods + + `log(self, event_type: str, *, principal_id: Optional[str] = None, principal_type: Optional[str] = None, grant_type: Optional[str] = None, token_jti: Optional[str] = None, source_ip: Optional[str] = None, success: bool = True, details: Optional[dict] = None) ‑> madsci.auth_manager.tables.AuditLogTable` + : Append a new audit row and return it. + + Raises whatever the underlying DB raises — callers MUST NOT swallow + these exceptions for state-changing operations (failure-closed). + + `query(self, *, principal_id: Optional[str] = None, event_type: Optional[str] = None, limit: int = 100) ‑> list[madsci.auth_manager.tables.AuditLogTable]` + : Query audit rows with optional filters; newest first. \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/services/deny_list_service.md b/docs/api/madsci/auth_manager/services/deny_list_service.md new file mode 100644 index 000000000..b097042ef --- /dev/null +++ b/docs/api/madsci/auth_manager/services/deny_list_service.md @@ -0,0 +1,38 @@ +Module madsci.auth_manager.services.deny_list_service +===================================================== +Deny-list service for revoked access-token jtis. + +Revoked jtis are persisted to ``revoked_access_tokens`` and cached in memory +for fast read at the ``GET /deny-list`` endpoint. The cache is hydrated from +the database on startup so revocations survive Auth Manager restarts. + +Entries whose ``exp`` is in the past are evicted both from the in-memory set +and from the database, bounding the list size to currently-revoked-and-still- +unexpired tokens. + +Classes +------- + +`DenyListService(engine: Any, *, persist_grace_seconds: int = 300)` +: Persistent jti deny-list with in-memory cache and ETag support. + + Bind the deny-list to a SQLAlchemy engine and hydrate from the table. + + ### Instance variables + + `etag: str` + : Current ETag of the deny-list snapshot (sha256 over (jti, exp) tuples). + + ### Methods + + `evict_expired(self) ‑> int` + : Evict expired entries from cache and DB. Returns count removed. + + `is_revoked(self, jti: str) ‑> bool` + : Return True if ``jti`` is in the deny-list and not yet expired. + + `revoke(self, jti: str, exp_unix: int) ‑> None` + : Revoke a jti with the given expiration (unix seconds). + + `snapshot(self) ‑> dict[str, typing.Any]` + : Snapshot for the ``GET /deny-list`` response. \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/services/index.md b/docs/api/madsci/auth_manager/services/index.md new file mode 100644 index 000000000..85e636a47 --- /dev/null +++ b/docs/api/madsci/auth_manager/services/index.md @@ -0,0 +1,178 @@ +Module madsci.auth_manager.services +=================================== +Service-layer modules for the Auth Manager. + +These services encapsulate the cryptographic and persistence operations the +``AuthManager`` server class depends on, keeping the FastAPI layer focused on +HTTP concerns. + +Sub-modules +----------- +* madsci.auth_manager.services.audit_logger +* madsci.auth_manager.services.deny_list_service +* madsci.auth_manager.services.password_service +* madsci.auth_manager.services.signing_key_service +* madsci.auth_manager.services.token_service + +Classes +------- + +`AuditLogger(engine: Any)` +: Persist security-relevant events to the ``audit_log`` table. + + Per the ``Audit log`` requirement in ``auth-identity-model/spec.md``, the + table is append-only at the application level. There is no public + ``update``/``delete`` API; any attempt to mutate a row by an admin must + itself produce a new audit entry recording the attempt. + + Bind the logger to a SQLAlchemy engine. + + ### Methods + + `log(self, event_type: str, *, principal_id: Optional[str] = None, principal_type: Optional[str] = None, grant_type: Optional[str] = None, token_jti: Optional[str] = None, source_ip: Optional[str] = None, success: bool = True, details: Optional[dict] = None) ‑> madsci.auth_manager.tables.AuditLogTable` + : Append a new audit row and return it. + + Raises whatever the underlying DB raises — callers MUST NOT swallow + these exceptions for state-changing operations (failure-closed). + + `query(self, *, principal_id: Optional[str] = None, event_type: Optional[str] = None, limit: int = 100) ‑> list[madsci.auth_manager.tables.AuditLogTable]` + : Query audit rows with optional filters; newest first. + +`DenyListService(engine: Any, *, persist_grace_seconds: int = 300)` +: Persistent jti deny-list with in-memory cache and ETag support. + + Bind the deny-list to a SQLAlchemy engine and hydrate from the table. + + ### Instance variables + + `etag: str` + : Current ETag of the deny-list snapshot (sha256 over (jti, exp) tuples). + + ### Methods + + `evict_expired(self) ‑> int` + : Evict expired entries from cache and DB. Returns count removed. + + `is_revoked(self, jti: str) ‑> bool` + : Return True if ``jti`` is in the deny-list and not yet expired. + + `revoke(self, jti: str, exp_unix: int) ‑> None` + : Revoke a jti with the given expiration (unix seconds). + + `snapshot(self) ‑> dict[str, typing.Any]` + : Snapshot for the ``GET /deny-list`` response. + +`PasswordService(time_cost: int = 3, memory_cost: int = 65536, parallelism: int = 4)` +: Wrapper around argon2-cffi for password hashing and verification. + + Configure the underlying ``argon2.PasswordHasher``. + + ### Methods + + `hash_password(self, password: str) ‑> str` + : Hash a plaintext password with Argon2id. + + `needs_rehash(self, password_hash: str) ‑> bool` + : Whether the stored hash should be re-hashed with current params. + + `verify_password(self, password_hash: str, password: str) ‑> bool` + : Verify a password against a stored hash. Returns False on mismatch. + +`SigningKeyService(engine: Any, key_size: int = 2048)` +: Manage rotating RSA signing keys. + + Bind to a SQLAlchemy engine and choose the RSA key size in bits. + + ### Methods + + `generate_keypair(self, *, set_signing: bool = True) ‑> madsci.auth_manager.tables.SigningKeyTable` + : Generate a new RSA keypair and persist it. + + Args: + set_signing: If True (default), the new key becomes the + ``active_for_signing`` key and any previously-signing key is + downgraded to verify-only. + + `get_key(self, kid: str) ‑> madsci.auth_manager.tables.SigningKeyTable | None` + : Look up a signing key by kid. + + `get_signing_key(self) ‑> madsci.auth_manager.tables.SigningKeyTable | None` + : Return the currently-active signing key, or None if none exists. + + `jwks(self) ‑> dict[str, list[dict[str, str]]]` + : Return a JWKS document for all currently-active keys. + + `list_active_keys(self) ‑> list[madsci.auth_manager.tables.SigningKeyTable]` + : All keys currently published in JWKS (i.e., active=True). + + `list_all_keys(self) ‑> list[madsci.auth_manager.tables.SigningKeyTable]` + : All keys including retired ones, newest first. + + `load_private_key(self, row: SigningKeyTable) ‑> Any` + : Load the private key for signing operations. + + `load_public_key(self, row: SigningKeyTable) ‑> Any` + : Load the public key for verification operations. + + `retire(self, kid: str) ‑> bool` + : Retire a key (remove from JWKS, delete private material). + + Returns True if a row was modified, False otherwise. + + `rotate(self) ‑> madsci.auth_manager.tables.SigningKeyTable` + : Generate a new signing key, demoting the current one to verify-only. + +`TokenService(*, engine: Any, signing_key_service: SigningKeyService, deny_list_service: DenyListService, issuer: str, audience: str, access_token_ttl: int = 900, refresh_token_ttl: int = 2592000, clock_skew_seconds: int = 30)` +: Issue, verify, and revoke MADSci access + refresh tokens. + + Wire the token service to its signing-key, deny-list, and lab identity. + + ### Class variables + + `ALLOWED_ALGORITHMS: tuple[str, ...]` + : + + ### Methods + + `consume_refresh_token(self, refresh_token: str, *, rotated_to_token_id: Optional[str] = None) ‑> madsci.auth_manager.tables.RefreshTokenTable` + : Atomically validate-and-revoke the matching refresh-token row. + + Raises ``TokenError`` for invalid / expired / already-revoked tokens. + + Concurrency: the revoke is implemented as a single + ``UPDATE ... WHERE revoked_at IS NULL RETURNING ...`` so two parallel + consumers of the same refresh token cannot both succeed. If the + update affects zero rows, we re-fetch by ``token_hash`` to + distinguish "doesn't exist" (invalid_grant) from "already revoked" + (reuse — fire family-revocation). + + ``rotated_to_token_id`` is recorded on the parent row so future + forensic queries can walk the rotation chain. + + `introspect(self, token: str) ‑> dict[str, typing.Any]` + : RFC 7662 introspection. Returns ``{'active': False}`` for any failure. + + `issue_access_token(self, *, sub: str, principal_type: PrincipalType, roles: Optional[list[str]] = None, permissions: Optional[list[str]] = None, user_id: Optional[str] = None, project_ids: Optional[list[str]] = None, manager_id: Optional[str] = None, node_id: Optional[str] = None, workcell_id: Optional[str] = None, ttl: Optional[int] = None) ‑> tuple[str, madsci.common.types.auth_types.JWTClaims]` + : Sign a new access token. Returns ``(jwt_str, claims_model)``. + + `issue_refresh_token(self, *, sub: str, principal_type: PrincipalType, ttl: Optional[int] = None) ‑> tuple[str, str]` + : Generate an opaque refresh token and persist its hash. + + Returns ``(opaque_token, row_token_id)`` so the caller can record the + new row's id on the parent row's ``rotated_to`` column when this is + issued as part of a rotation. + + `make_token_response(self, *, access_token: str, ttl: int, refresh_token: Optional[str] = None) ‑> madsci.common.types.auth_types.TokenResponse` + : Build the OAuth 2.0 token-endpoint response body. + + `revoke_access_token(self, jti: str, exp_unix: int) ‑> None` + : Add an access token's jti to the deny-list. + + `revoke_refresh_token(self, refresh_token: str) ‑> bool` + : Mark a refresh token as revoked. Returns True if a row was changed. + + `verify_token(self, token: str) ‑> madsci.common.types.auth_types.JWTClaims` + : Verify a JWT against the JWKS and return its claims. + + Checks signature, ``exp``, ``iss``, ``aud`` and the deny-list. Raises + ``TokenError`` on failure. \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/services/password_service.md b/docs/api/madsci/auth_manager/services/password_service.md new file mode 100644 index 000000000..a082168d1 --- /dev/null +++ b/docs/api/madsci/auth_manager/services/password_service.md @@ -0,0 +1,22 @@ +Module madsci.auth_manager.services.password_service +==================================================== +Argon2id password hashing helpers. + +Classes +------- + +`PasswordService(time_cost: int = 3, memory_cost: int = 65536, parallelism: int = 4)` +: Wrapper around argon2-cffi for password hashing and verification. + + Configure the underlying ``argon2.PasswordHasher``. + + ### Methods + + `hash_password(self, password: str) ‑> str` + : Hash a plaintext password with Argon2id. + + `needs_rehash(self, password_hash: str) ‑> bool` + : Whether the stored hash should be re-hashed with current params. + + `verify_password(self, password_hash: str, password: str) ‑> bool` + : Verify a password against a stored hash. Returns False on mismatch. \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/services/signing_key_service.md b/docs/api/madsci/auth_manager/services/signing_key_service.md new file mode 100644 index 000000000..0ae51e4b8 --- /dev/null +++ b/docs/api/madsci/auth_manager/services/signing_key_service.md @@ -0,0 +1,62 @@ +Module madsci.auth_manager.services.signing_key_service +======================================================= +RSA signing-key management for the Auth Manager. + +Implements key generation, persistence, rotation, and JWKS export. RS256 is +the only supported algorithm (per Decision 1). + +Functions +--------- + +`load_pem_private_key(data, password, backend=None, *, unsafe_skip_rsa_key_validation=False)` +: + +`load_pem_public_key(data, backend=None)` +: + +Classes +------- + +`SigningKeyService(engine: Any, key_size: int = 2048)` +: Manage rotating RSA signing keys. + + Bind to a SQLAlchemy engine and choose the RSA key size in bits. + + ### Methods + + `generate_keypair(self, *, set_signing: bool = True) ‑> madsci.auth_manager.tables.SigningKeyTable` + : Generate a new RSA keypair and persist it. + + Args: + set_signing: If True (default), the new key becomes the + ``active_for_signing`` key and any previously-signing key is + downgraded to verify-only. + + `get_key(self, kid: str) ‑> madsci.auth_manager.tables.SigningKeyTable | None` + : Look up a signing key by kid. + + `get_signing_key(self) ‑> madsci.auth_manager.tables.SigningKeyTable | None` + : Return the currently-active signing key, or None if none exists. + + `jwks(self) ‑> dict[str, list[dict[str, str]]]` + : Return a JWKS document for all currently-active keys. + + `list_active_keys(self) ‑> list[madsci.auth_manager.tables.SigningKeyTable]` + : All keys currently published in JWKS (i.e., active=True). + + `list_all_keys(self) ‑> list[madsci.auth_manager.tables.SigningKeyTable]` + : All keys including retired ones, newest first. + + `load_private_key(self, row: SigningKeyTable) ‑> Any` + : Load the private key for signing operations. + + `load_public_key(self, row: SigningKeyTable) ‑> Any` + : Load the public key for verification operations. + + `retire(self, kid: str) ‑> bool` + : Retire a key (remove from JWKS, delete private material). + + Returns True if a row was modified, False otherwise. + + `rotate(self) ‑> madsci.auth_manager.tables.SigningKeyTable` + : Generate a new signing key, demoting the current one to verify-only. \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/services/token_service.md b/docs/api/madsci/auth_manager/services/token_service.md new file mode 100644 index 000000000..ff3dc1724 --- /dev/null +++ b/docs/api/madsci/auth_manager/services/token_service.md @@ -0,0 +1,79 @@ +Module madsci.auth_manager.services.token_service +================================================= +JWT issuance, verification, and refresh-token management. + +Functions +--------- + +`hash_refresh_token(token: str) ‑> str` +: Public helper: SHA-256 hash a refresh token for table lookup. + + Hashing is deterministic — only the hash is persisted, never the raw + token — so this helper is safe to use anywhere a lookup-by-token is + needed (e.g., the Auth Manager's pre-rotation peek). + +Classes +------- + +`TokenError(*args, **kwargs)` +: Raised on token verification / lookup failures. + + ### Ancestors (in MRO) + + * builtins.Exception + * builtins.BaseException + +`TokenService(*, engine: Any, signing_key_service: SigningKeyService, deny_list_service: DenyListService, issuer: str, audience: str, access_token_ttl: int = 900, refresh_token_ttl: int = 2592000, clock_skew_seconds: int = 30)` +: Issue, verify, and revoke MADSci access + refresh tokens. + + Wire the token service to its signing-key, deny-list, and lab identity. + + ### Class variables + + `ALLOWED_ALGORITHMS: tuple[str, ...]` + : + + ### Methods + + `consume_refresh_token(self, refresh_token: str, *, rotated_to_token_id: Optional[str] = None) ‑> madsci.auth_manager.tables.RefreshTokenTable` + : Atomically validate-and-revoke the matching refresh-token row. + + Raises ``TokenError`` for invalid / expired / already-revoked tokens. + + Concurrency: the revoke is implemented as a single + ``UPDATE ... WHERE revoked_at IS NULL RETURNING ...`` so two parallel + consumers of the same refresh token cannot both succeed. If the + update affects zero rows, we re-fetch by ``token_hash`` to + distinguish "doesn't exist" (invalid_grant) from "already revoked" + (reuse — fire family-revocation). + + ``rotated_to_token_id`` is recorded on the parent row so future + forensic queries can walk the rotation chain. + + `introspect(self, token: str) ‑> dict[str, typing.Any]` + : RFC 7662 introspection. Returns ``{'active': False}`` for any failure. + + `issue_access_token(self, *, sub: str, principal_type: PrincipalType, roles: Optional[list[str]] = None, permissions: Optional[list[str]] = None, user_id: Optional[str] = None, project_ids: Optional[list[str]] = None, manager_id: Optional[str] = None, node_id: Optional[str] = None, workcell_id: Optional[str] = None, ttl: Optional[int] = None) ‑> tuple[str, madsci.common.types.auth_types.JWTClaims]` + : Sign a new access token. Returns ``(jwt_str, claims_model)``. + + `issue_refresh_token(self, *, sub: str, principal_type: PrincipalType, ttl: Optional[int] = None) ‑> tuple[str, str]` + : Generate an opaque refresh token and persist its hash. + + Returns ``(opaque_token, row_token_id)`` so the caller can record the + new row's id on the parent row's ``rotated_to`` column when this is + issued as part of a rotation. + + `make_token_response(self, *, access_token: str, ttl: int, refresh_token: Optional[str] = None) ‑> madsci.common.types.auth_types.TokenResponse` + : Build the OAuth 2.0 token-endpoint response body. + + `revoke_access_token(self, jti: str, exp_unix: int) ‑> None` + : Add an access token's jti to the deny-list. + + `revoke_refresh_token(self, refresh_token: str) ‑> bool` + : Mark a refresh token as revoked. Returns True if a row was changed. + + `verify_token(self, token: str) ‑> madsci.common.types.auth_types.JWTClaims` + : Verify a JWT against the JWKS and return its claims. + + Checks signature, ``exp``, ``iss``, ``aud`` and the deny-list. Raises + ``TokenError`` on failure. \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/tables.md b/docs/api/madsci/auth_manager/tables.md new file mode 100644 index 000000000..51d16eb1d --- /dev/null +++ b/docs/api/madsci/auth_manager/tables.md @@ -0,0 +1,451 @@ +Module madsci.auth_manager.tables +================================= +SQLModel tables for the Auth Manager. + +All entities are scoped to a single ``lab_id`` (Decision 12). The schema is +single-tenant and intentionally has no ``tenant_id`` column. + +Tables: + +- ``users`` — local user accounts with Argon2id password hashes +- ``projects`` — project records +- ``project_memberships`` — many-to-many user ↔ project ↔ role +- ``roles`` — named bundles of permissions +- ``role_permissions`` — many-to-many role ↔ permission string +- ``service_accounts`` — manager principals +- ``node_identities`` — node principals (with reserved ``mtls_cert_fingerprint``) +- ``refresh_tokens`` — opaque refresh tokens, server-stored +- ``revoked_access_tokens`` — persistent deny-list (jti, exp, revoked_at) +- ``signing_keys`` — RSA keypairs for JWT signing +- ``audit_log`` — append-only security event log + +The ``mtls_cert_fingerprint`` column on ``node_identities`` is forward-compat +with the future mTLS follow-on; it is not validated or used by this change. + +Classes +------- + +`AuditLogTable(**data)` +: Append-only audit log. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `details: dict | None` + : + + `event_id: str` + : + + `event_time: datetime.datetime` + : + + `event_type: str` + : + + `grant_type: str | None` + : + + `id: int | None` + : + + `principal_id: str | None` + : + + `principal_type: str | None` + : + + `source_ip: str | None` + : + + `success: bool` + : + + `token_jti: str | None` + : + +`GlobalRoleGrantTable(**data)` +: Global (non-project-scoped) role grants for users / service accounts / nodes. + + A row applies to exactly one principal. The unused id columns are NULL. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `created_at: datetime.datetime` + : + + `id: int | None` + : + + `node_identity_client_id: str | None` + : + + `role_id: str` + : + + `service_account_client_id: str | None` + : + + `user_id: str | None` + : + +`LabBindingTable(**data)` +: Records the lab_id this Auth Manager database is bound to. + + Per Decision 12, an Auth Manager refuses to start later against a + different lab_id without an explicit operator-acknowledged migration. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `bootstrapped_at: datetime.datetime` + : + + `id: int` + : + + `lab_id: str` + : + +`NodeIdentityTable(**data)` +: Node principal. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `client_id: str` + : + + `client_secret_hash: str` + : + + `created_at: datetime.datetime` + : + + `is_active: bool` + : + + `mtls_cert_fingerprint: str | None` + : + + `node_id: str` + : + + `workcell_id: str | None` + : + +`ProjectMembershipTable(**data)` +: A user's role grant within a project. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `created_at: datetime.datetime` + : + + `id: int | None` + : + + `project_id: str` + : + + `role_id: str` + : + + `user_id: str` + : + +`ProjectTable(**data)` +: Project record. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `created_at: datetime.datetime` + : + + `description: str | None` + : + + `name: str` + : + + `project_id: str` + : + +`RefreshTokenTable(**data)` +: Opaque refresh token, server-stored. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `expires_at: datetime.datetime` + : + + `issued_at: datetime.datetime` + : + + `principal_sub: str` + : + + `principal_type: str` + : + + `revoked_at: datetime.datetime | None` + : + + `rotated_to: str | None` + : + + `token_hash: str` + : + + `token_id: str` + : + +`RevokedAccessTokenTable(**data)` +: Persistent deny-list of revoked access-token jtis. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `exp: datetime.datetime` + : + + `jti: str` + : + + `revoked_at: datetime.datetime` + : + +`RolePermissionTable(**data)` +: Many-to-many between roles and permission strings. + + Permissions are stored as plain strings (``.``) drawn + from the canonical namespace documented in ``docs/guides/auth.md``. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `id: int | None` + : + + `permission: str` + : + + `role_id: str` + : + +`RoleTable(**data)` +: Role record (a named bundle of permissions). + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `created_at: datetime.datetime` + : + + `description: str | None` + : + + `name: str` + : + + `role_id: str` + : + +`ServiceAccountTable(**data)` +: Service account principal (a manager service). + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `client_id: str` + : + + `client_secret_hash: str` + : + + `created_at: datetime.datetime` + : + + `is_active: bool` + : + + `manager_id: str` + : + +`SigningKeyTable(**data)` +: RSA signing keypair for JWT issuance. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `active: bool` + : + + `active_for_signing: bool` + : + + `algorithm: str` + : + + `created_at: datetime.datetime` + : + + `kid: str` + : + + `private_key_pem: str` + : + + `public_key_pem: str` + : + + `retired_at: datetime.datetime | None` + : + +`UserTable(**data)` +: Local user account. + + ### Ancestors (in MRO) + + * sqlmodel.main.SQLModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + ### Instance variables + + `created_at: datetime.datetime` + : + + `email: str | None` + : + + `is_active: bool` + : + + `password_hash: str` + : + + `updated_at: datetime.datetime` + : + + `user_id: str` + : + + `username: str` + : \ No newline at end of file diff --git a/docs/api/madsci/auth_manager/testing.md b/docs/api/madsci/auth_manager/testing.md new file mode 100644 index 000000000..0f8f798aa --- /dev/null +++ b/docs/api/madsci/auth_manager/testing.md @@ -0,0 +1,22 @@ +Module madsci.auth_manager.testing +================================== +Reusable in-memory Auth Manager fixture and helpers for tests. + +Importable by any test suite that needs a real Auth Manager wired up against +``SQLiteHandler`` plus an ``AuthClient`` whose HTTP transport is bound to +the in-memory FastAPI app via ``httpx.MockTransport``. + +Functions +--------- + +`in_memory_auth(*, lab_id: str | None = None) ‑> Iterator[tuple[madsci.auth_manager.auth_server.AuthManager, madsci.client.auth_client.AuthClient]]` +: Context-managed (mgr, client) pair for one-off use. + +`make_auth_client(mgr: AuthManager) ‑> madsci.client.auth_client.AuthClient` +: Build an AuthClient whose HTTP transport is bound to ``mgr``. + +`make_auth_manager(*, lab_id: str | None = None, admin_username: str = 'admin', admin_password: str = 'hunter2') ‑> madsci.auth_manager.auth_server.AuthManager` +: Build a fully-bootstrapped in-memory AuthManager. + +`make_mock_transport(mgr: AuthManager) ‑> httpx.MockTransport` +: Build an httpx MockTransport that forwards requests to ``mgr``. \ No newline at end of file diff --git a/docs/api/madsci/client/auth_client.md b/docs/api/madsci/client/auth_client.md new file mode 100644 index 000000000..9498f95f3 --- /dev/null +++ b/docs/api/madsci/client/auth_client.md @@ -0,0 +1,132 @@ +Module madsci.client.auth_client +================================ +Client library for the MADSci Auth Manager. + +Provides programmatic access to the Auth Manager: token acquisition (password, +refresh, client_credentials), introspection, JWKS-cached JWT verification, +deny-list polling, and the admin surface (users, projects, roles, +service-accounts, node identities, signing keys). + +The ``AuthClient`` is also installable into the ambient context via +``auth_client_context()`` so other service clients pick up bearer tokens +automatically (see ``madsci.common.auth_context``). + +Classes +------- + +`AuthClient(auth_server_url: AnyUrl | str, *, access_token: Optional[str] = None, refresh_token: Optional[str] = None, client_id: Optional[str] = None, client_secret: Optional[str] = None, jwks_ttl_seconds: int = 300, deny_list_poll_interval: int = 30, refresh_buffer_seconds: int = 60, timeout: float = 10.0, clock_skew_seconds: int = 30)` +: Synchronous client for the Auth Manager. + + The client is intentionally synchronous to mirror the rest of MADSci's + service clients. Concurrency-sensitive call sites can wrap it with their + own thread pool / asyncio.to_thread. + + Initialize the client with optional pre-existing tokens / credentials. + + ### Class variables + + `ALLOWED_ALGORITHMS: tuple[str, ...]` + : + + ### Instance variables + + `access_token: Optional[str]` + : The currently-cached access token, or None if not logged in. + + `async_http: httpx.AsyncClient` + : Lazily-initialized async httpx client (mirrors ``http``). + + `http: httpx.Client` + : Lazily-initialized synchronous httpx client. + + ### Methods + + `add_project_member(self, project_id: str, user_id: str, role_id: str) ‑> dict` + : Add a user to a project with a role (``POST /projects/{id}/members``). + + `client_credentials_login(self, client_id: str, client_secret: str) ‑> madsci.common.types.auth_types.TokenResponse` + : Exchange client_id/client_secret for an access token (no refresh token). + + `close(self) ‑> None` + : Release the synchronous HTTP connection pool. Idempotent. + + `create_project(self, name: str, description: Optional[str] = None) ‑> dict` + : Create a new project (``POST /projects``). + + `create_role(self, name: str, permissions: list[str], description: Optional[str] = None) ‑> dict` + : Create a new role with permissions (``POST /roles``). + + `create_user(self, username: str, password: str, email: Optional[str] = None) ‑> dict` + : Create a new user (``POST /users``). + + `force_deny_list_refresh(self) ‑> None` + : Force an immediate deny-list fetch (used after on-401 retries). + + `get_access_token(self) ‑> str` + : Return a non-expired access token, refreshing transparently. + + `grant_role(self, **kwargs: Any) ‑> dict` + : Grant a role to a principal (``POST /roles/grant``). + + `introspect(self, token: str) ‑> dict` + : Call the RFC 7662 introspection endpoint for ``token``. + + `jwks(self, *, force_refresh: bool = False) ‑> dict` + : Return the JWKS document, fetching from the Auth Manager if the cache is stale. + + `list_keys(self) ‑> list[dict]` + : List all signing keys (``GET /keys``). + + `list_projects(self) ‑> list[dict]` + : List all projects (``GET /projects``). + + `list_roles(self) ‑> list[dict]` + : List all roles (``GET /roles``). + + `list_users(self) ‑> list[dict]` + : List all users (``GET /users``). + + `login(self, username: str, password: str) ‑> madsci.common.types.auth_types.TokenResponse` + : Exchange username/password for access + refresh tokens (password grant). + + `refresh(self) ‑> madsci.common.types.auth_types.TokenResponse` + : Exchange the cached refresh token for a fresh access + refresh pair. + + `register_node(self, node_id: str, workcell_id: Optional[str] = None, role_ids: Optional[list[str]] = None) ‑> dict` + : Register a node identity (``POST /node-identities``). + + The plaintext ``client_secret`` is returned exactly once. + + `register_service_account(self, manager_id: str, role_ids: Optional[list[str]] = None) ‑> dict` + : Register a service account (``POST /service-accounts``). + + The plaintext ``client_secret`` is returned exactly once. + + `retire_key(self, kid: str) ‑> dict` + : Retire a signing key (``DELETE /keys/{kid}``). + + `revoke(self, *, token: Optional[str] = None, refresh_token: Optional[str] = None) ‑> None` + : Revoke an access token and/or refresh token at the Auth Manager. + + `rotate_credentials(self, client_id: str) ‑> dict` + : Rotate a service-account or node-identity secret (``POST /credentials/{id}/rotate``). + + `rotate_keys(self) ‑> dict` + : Generate a new signing keypair (``POST /keys/rotate``). + + `update_user(self, user_id: str, **fields: Any) ‑> dict` + : Patch a user (``PATCH /users/{user_id}``). + + `verify_jwt(self, token: str) ‑> madsci.common.types.auth_types.JWTClaims` + : Verify a JWT against the cached JWKS and the cached deny-list. + + On signature failure, the JWKS cache is force-refreshed and verification + is retried once. + +`AuthClientError(*args, **kwargs)` +: Raised on auth-client failures. + + ### Ancestors (in MRO) + + * builtins.Exception + * builtins.BaseException \ No newline at end of file diff --git a/docs/api/madsci/client/cli/commands/auth.md b/docs/api/madsci/client/cli/commands/auth.md new file mode 100644 index 000000000..947382f76 --- /dev/null +++ b/docs/api/madsci/client/cli/commands/auth.md @@ -0,0 +1,8 @@ +Module madsci.client.cli.commands.auth +====================================== +MADSci CLI ``auth`` command group. + +Subcommands target the Auth Manager via ``AuthClient``. The bootstrap +command runs locally against an Auth Manager instance (operator must already +have access to the database / process); all other commands talk to a running +Auth Manager over HTTP. \ No newline at end of file diff --git a/docs/api/madsci/client/cli/commands/index.md b/docs/api/madsci/client/cli/commands/index.md index d77c7eb60..985fa2d9d 100644 --- a/docs/api/madsci/client/cli/commands/index.md +++ b/docs/api/madsci/client/cli/commands/index.md @@ -8,6 +8,7 @@ Commands are imported lazily to reduce CLI startup time. Sub-modules ----------- * madsci.client.cli.commands.add +* madsci.client.cli.commands.auth * madsci.client.cli.commands.backup * madsci.client.cli.commands.campaign * madsci.client.cli.commands.commands diff --git a/docs/api/madsci/client/index.md b/docs/api/madsci/client/index.md index 8fdeff3c8..dfe3104da 100644 --- a/docs/api/madsci/client/index.md +++ b/docs/api/madsci/client/index.md @@ -4,6 +4,7 @@ The Modular Autonomous Discovery for Science (MADSci) Python Client and CLI. Sub-modules ----------- +* madsci.client.auth_client * madsci.client.cli * madsci.client.client_mixin * madsci.client.data_client diff --git a/docs/api/madsci/common/auth_audit_fallback.md b/docs/api/madsci/common/auth_audit_fallback.md new file mode 100644 index 000000000..a76432882 --- /dev/null +++ b/docs/api/madsci/common/auth_audit_fallback.md @@ -0,0 +1,33 @@ +Module madsci.common.auth_audit_fallback +======================================== +Local audit-log fallback for consuming managers. + +When a consuming manager cannot deliver an authentication-related audit +event to the Auth Manager (network partition, 5xx, etc.), the event is +appended to a local on-disk log. A background drain attempts re-delivery on +a configurable interval; events are removed locally only after the Auth +Manager confirms persistence. + +The local log is bounded by a configurable max-size; when exceeded, the +oldest segment is rotated out and a structured warning event is emitted so +operators can upsize before bound-bites cause silent loss. + +This module deliberately avoids any direct dependency on ``madsci.client`` +so it can be imported by ``madsci.common.auth_middleware`` without creating +a circular dependency. + +Classes +------- + +`AuthAuditFallback(*, log_path: Optional[str] = None, max_bytes: int = 104857600, drain_interval: float = 60.0, deliver: Optional[Callable[[dict[str, Any]], bool]] = None)` +: Append-only local fallback for auth audit events. + + Configure the fallback log path, size bound, drain interval, and delivery callable. + + ### Methods + + `append(self, event: dict[str, Any]) ‑> None` + : Persist an event to the local fallback log. + + `drain(self) ‑> int` + : Attempt to deliver all locally-queued events. Returns count drained. \ No newline at end of file diff --git a/docs/api/madsci/common/auth_context.md b/docs/api/madsci/common/auth_context.md new file mode 100644 index 000000000..0b034801f --- /dev/null +++ b/docs/api/madsci/common/auth_context.md @@ -0,0 +1,30 @@ +Module madsci.common.auth_context +================================= +Ambient ``AuthClient`` context for outbound credential propagation. + +When an ``AuthClient`` is installed via ``auth_client_context()``, the MADSci +``create_httpx_client()`` factory and other in-process helpers can +transparently pick it up to inject ``Authorization: Bearer `` headers +on outbound requests and to handle on-401 force-refresh-and-retry. + +This module deliberately uses ``Any`` for the client type to avoid importing +the ``madsci.client`` package — ``madsci.common`` must stay +dependency-light. The protocol the client must satisfy is: + +- ``get_access_token() -> str`` — return a (possibly auto-refreshed) token +- ``refresh() -> Any`` — force a refresh-grant exchange + +In practice the only conforming implementation is +``madsci.client.auth_client.AuthClient``. + +Functions +--------- + +`auth_client_context(client: Any) ‑> Iterator[Any]` +: Install ``client`` as the ambient AuthClient for the current scope. + + Mirrors ``event_client_context()`` semantics. Nested contexts replace the + binding for their lifetime; on exit the previous binding is restored. + +`get_current_auth_client() ‑> Any | None` +: Return the currently-installed ambient AuthClient, if any. \ No newline at end of file diff --git a/docs/api/madsci/common/auth_decorators.md b/docs/api/madsci/common/auth_decorators.md new file mode 100644 index 000000000..0c165b055 --- /dev/null +++ b/docs/api/madsci/common/auth_decorators.md @@ -0,0 +1,26 @@ +Module madsci.common.auth_decorators +==================================== +``@requires(permission=...)`` decorator for endpoint authorization. + +Usage:: + + from madsci.common.auth_decorators import requires + from madsci.common.middleware import current_principal + + @get("/events") + @requires(permission="event.read") + async def get_events(self, request: Request) -> list[Event]: + ... + +When ``project_from=`` is supplied, the decorator additionally +verifies that the principal is a member of the project identified by the +named field on the request body or path parameter. + +Functions +--------- + +`requires(*, permission: str, project_from: Optional[str] = None) ‑> Callable` +: Decorator that enforces a permission check on a Routable endpoint. + + The wrapped function MUST accept ``request: Request`` as a parameter so + we can read ``request.state.principal``. \ No newline at end of file diff --git a/docs/api/madsci/common/auth_middleware.md b/docs/api/madsci/common/auth_middleware.md new file mode 100644 index 000000000..066be46d9 --- /dev/null +++ b/docs/api/madsci/common/auth_middleware.md @@ -0,0 +1,64 @@ +Module madsci.common.auth_middleware +==================================== +AuthMiddleware for ``AbstractManagerBase``-based managers. + +When ``auth_enabled=True`` on a manager, this middleware: + +1. Extracts ``Authorization: Bearer `` from each request. +2. Verifies the JWT against JWKS cached from ``auth_server_url``. +3. Validates ``iss``/``aud``/``exp``. +4. Populates ``request.state.principal`` with a typed ``Principal``. +5. Enters an ``ownership_context()`` for the request lifetime, sourced from + the validated token claims. + +When ``auth_required=False`` (migration mode), unauthenticated requests pass +through with ``request.state.principal = None`` and a structured warning is +logged so operators can identify unauth'd traffic during rollout. + +This middleware is intentionally implemented in ``madsci.common`` (not +``madsci.client``) because ``AbstractManagerBase`` lives in +``madsci.common`` and must not depend on the auth-client package directly — +the AuthClient is dependency-injected by ``AbstractManagerBase`` when +``auth_enabled``. + +Functions +--------- + +`current_ownership(request: Request) ‑> Any` +: Return an OwnershipInfo derived from the request's principal. + +`current_principal(request: Request) ‑> madsci.common.types.auth_types.Principal | None` +: Return the validated principal on the current request, if any. + +`warn_caller_asserted_ownership(call_site: str) ‑> None` +: Emit a sampled deprecation warning for caller-asserted OwnershipInfo. + + Per Decision 10, when ``auth_enabled=False``, caller-asserted + ``OwnershipInfo`` continues to be accepted but a sampled warning is + emitted (default once per process per minute per call-site). + +Classes +------- + +`AuthMiddleware(app: Any, *, auth_client: Any, auth_required: bool = False, lab_id: Optional[str] = None, unauthenticated_paths: Optional[set[str]] = None)` +: Validate JWTs and bind validated claims into request state and ownership. + + Configure the middleware with an injected ``AuthClient``. + + ``auth_required=False`` enables migration mode: unauth'd requests pass + through with ``request.state.principal = None`` and a structured + warning is logged. + + ``unauthenticated_paths`` is an exact-match set of URL paths that + SHALL bypass the bearer-token check entirely — used for endpoints + that must remain reachable without a token (e.g., the Auth Manager's + own ``/token`` and ``/.well-known/jwks.json``). + + ### Ancestors (in MRO) + + * starlette.middleware.base.BaseHTTPMiddleware + + ### Methods + + `dispatch(self, request: Request, call_next: Any) ‑> starlette.responses.Response` + : Verify the bearer token and bind ownership context for the request. \ No newline at end of file diff --git a/docs/api/madsci/common/index.md b/docs/api/madsci/common/index.md index 983a381c2..d13adae9c 100644 --- a/docs/api/madsci/common/index.md +++ b/docs/api/madsci/common/index.md @@ -4,6 +4,10 @@ Common code for the MADSci project. Sub-modules ----------- +* madsci.common.auth_audit_fallback +* madsci.common.auth_context +* madsci.common.auth_decorators +* madsci.common.auth_middleware * madsci.common.backup_tools * madsci.common.context * madsci.common.data_manipulation diff --git a/docs/api/madsci/common/manager_base.md b/docs/api/madsci/common/manager_base.md index 65c507966..4aca148db 100644 --- a/docs/api/madsci/common/manager_base.md +++ b/docs/api/madsci/common/manager_base.md @@ -55,6 +55,7 @@ Classes ### Descendants + * madsci.auth_manager.auth_server.AuthManager * madsci.data_manager.data_server.DataManager * madsci.event_manager.event_server.EventManager * madsci.experiment_manager.experiment_server.ExperimentManager @@ -196,6 +197,15 @@ Classes This is intended for higher-level manager operations (not every log line). + `unauthenticated_paths(self) ‑> set[str]` + : Return URL paths that bypass AuthMiddleware on this manager. + + The default set covers operator/monitor endpoints (``/health``, + ``/settings``, OpenAPI). Subclasses MAY extend this — e.g., the Auth + Manager itself adds ``/token``, ``/.well-known/jwks.json``, + ``/deny-list`` since those are needed to bootstrap and validate + tokens. + `ManagerBaseMeta(*args, **kwargs)` : Metaclass that combines ABCMeta and Routable's metaclass. diff --git a/docs/api/madsci/common/types/auth_types.md b/docs/api/madsci/common/types/auth_types.md index ad268912d..2c6301a55 100644 --- a/docs/api/madsci/common/types/auth_types.md +++ b/docs/api/madsci/common/types/auth_types.md @@ -1,10 +1,219 @@ Module madsci.common.types.auth_types ===================================== -Types related to authentication and ownership of MADSci objects. +Types related to authentication, authorization, and ownership of MADSci objects. Classes ------- +`AuthManagerSettings(**kwargs: Any)` +: Settings for the Auth Manager. + + Initialize settings with walk-up file discovery. + + Configuration file paths (YAML, JSON, TOML, .env) are resolved via + walk-up discovery from a starting directory. Each filename walks up + independently, so ``node.settings.yaml`` can resolve in the node dir + while ``settings.yaml`` resolves in the lab root. + + The starting directory is determined by (in priority order): + 1. ``_settings_dir`` keyword argument + 2. ``MADSCI_SETTINGS_DIR`` environment variable + 3. Current working directory (default) + + Args: + _settings_dir: Starting directory for walk-up file discovery. + **kwargs: Forwarded to ``BaseSettings.__init__``. + + ### Ancestors (in MRO) + + * madsci.common.types.manager_types.ManagerSettings + * madsci.common.types.base_types.MadsciBaseSettings + * pydantic_settings.main.BaseSettings + * pydantic.main.BaseModel + + ### Class variables + + `access_token_ttl: int` + : + + `argon2_memory_cost: int` + : + + `argon2_parallelism: int` + : + + `argon2_time_cost: int` + : + + `database_url: str` + : + + `deny_list_persist_grace: int` + : + + `lab_id: str | None` + : + + `local_audit_log_max_bytes: int` + : + + `local_audit_log_path: str | None` + : + + `manager_type: madsci.common.types.manager_types.ManagerType | None` + : + + `refresh_token_ttl: int` + : + + `server_url: pydantic.networks.AnyUrl` + : + + `signing_key_ttl: int` + : + + `token_clock_skew_seconds: int` + : + + `trust_forwarded_for: bool` + : + +`GrantType(value, names=None, *, module=None, qualname=None, type=None, start=1)` +: OAuth 2.0 grant types supported by the Auth Manager. + + ### Ancestors (in MRO) + + * builtins.str + * enum.Enum + + ### Class variables + + `CLIENT_CREDENTIALS` + : + + `PASSWORD` + : + + `REFRESH_TOKEN` + : + +`JWTClaims(**data: Any)` +: The decoded claims of a MADSci access token. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `aud: str` + : + + `exp: int` + : + + `iat: int` + : + + `iss: str` + : + + `jti: str` + : + + `manager_id: str | None` + : + + `model_config` + : + + `node_id: str | None` + : + + `permissions: list[str]` + : + + `principal_type: madsci.common.types.auth_types.PrincipalType` + : + + `project_ids: list[str]` + : + + `roles: list[str]` + : + + `sub: str` + : + + `user_id: str | None` + : + + `workcell_id: str | None` + : + +`NodeIdentity(**data: Any)` +: A principal representing a laboratory node. + + ``client_secret`` is never stored or returned in plaintext after the + initial registration; only the Argon2 hash is persisted. + + The ``mtls_cert_fingerprint`` field is reserved for the future mTLS + follow-on change. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `client_id: str` + : + + `created_at: datetime.datetime | None` + : + + `is_active: bool` + : + + `model_config` + : + + `mtls_cert_fingerprint: str | None` + : + + `node_id: str` + : + + `role_ids: list[str]` + : + + `workcell_id: str | None` + : + + ### Methods + + `is_ulid_node(id: str, info: pydantic_core.core_schema.ValidationInfo) ‑> str` + : Validates that a string field is a valid ULID. + + `is_ulid_workcell(id: str | None, info: pydantic_core.core_schema.ValidationInfo) ‑> str` + : Validates that a string field is a valid ULID. + `OwnershipInfo(**data: Any)` : Information about the ownership of a MADSci object. @@ -55,6 +264,20 @@ Classes `workflow_id: str | None` : + ### Static methods + + `from_jwt_claims(claims: JWTClaims) ‑> madsci.common.types.auth_types.OwnershipInfo` + : Build an OwnershipInfo from validated JWT claims. + + - ``lab_id`` ← ``claims.aud`` + - ``user_id`` ← ``claims.user_id`` (when ``principal_type=user``) + - ``node_id`` ← ``claims.node_id`` (when ``principal_type=node``) + - ``workcell_id`` ← ``claims.workcell_id`` + - ``manager_id`` ← ``claims.manager_id`` (when ``principal_type=service_account``) + + ``project_id`` is intentionally left unset; project context is + established per-operation via ``@requires(project_from=...)``. + ### Methods `check(self, other: OwnershipInfo) ‑> bool` @@ -66,6 +289,94 @@ Classes `is_ulid(id: str | None, info: pydantic_core.core_schema.ValidationInfo) ‑> str` : Validates that a string field is a valid ULID. +`Permission(**data: Any)` +: A permission string in the canonical ``.`` namespace. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `description: str | None` + : + + `model_config` + : + + `name: str` + : + +`Principal(**data: Any)` +: The validated principal of an authenticated request. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `claims: madsci.common.types.auth_types.JWTClaims` + : + + `model_config` + : + + `permissions: list[str]` + : + + `principal_type: madsci.common.types.auth_types.PrincipalType` + : + + `project_ids: list[str]` + : + + `roles: list[str]` + : + + `sub: str` + : + + ### Static methods + + `from_claims(claims: madsci.common.types.auth_types.JWTClaims) ‑> madsci.common.types.auth_types.Principal` + : Build a Principal from validated JWT claims. + +`PrincipalType(value, names=None, *, module=None, qualname=None, type=None, start=1)` +: Type of principal a token represents. + + ### Ancestors (in MRO) + + * builtins.str + * enum.Enum + + ### Class variables + + `NODE` + : + + `SERVICE_ACCOUNT` + : + + `USER` + : + `ProjectInfo(**data: Any)` : Information about a project. @@ -106,6 +417,158 @@ Classes `is_ulid(id: str, info: pydantic_core.core_schema.ValidationInfo) ‑> str` : Validates that a string field is a valid ULID. +`ProjectMembership(**data: Any)` +: A user's membership in a project, with one or more roles scoped to it. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `model_config` + : + + `project_id: str` + : + + `role_ids: list[str]` + : + + `user_id: str` + : + + ### Methods + + `is_ulid_project(id: str, info: pydantic_core.core_schema.ValidationInfo) ‑> str` + : Validates that a string field is a valid ULID. + + `is_ulid_user(id: str, info: pydantic_core.core_schema.ValidationInfo) ‑> str` + : Validates that a string field is a valid ULID. + +`Role(**data: Any)` +: A named bundle of permissions that can be granted to principals. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `description: str | None` + : + + `model_config` + : + + `name: str` + : + + `permissions: list[str]` + : + + `role_id: str` + : + + ### Methods + + `is_ulid(id: str, info: pydantic_core.core_schema.ValidationInfo) ‑> str` + : Validates that a string field is a valid ULID. + +`ServiceAccount(**data: Any)` +: A non-human principal representing a manager service. + + ``client_secret`` is never stored or returned in plaintext after the + initial registration; only the Argon2 hash is persisted. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `client_id: str` + : + + `created_at: datetime.datetime | None` + : + + `is_active: bool` + : + + `manager_id: str` + : + + `model_config` + : + + `role_ids: list[str]` + : + + ### Methods + + `is_ulid_manager(id: str, info: pydantic_core.core_schema.ValidationInfo) ‑> str` + : Validates that a string field is a valid ULID. + +`TokenResponse(**data: Any)` +: The OAuth 2.0 token-endpoint response. + + Create a new model by parsing and validating input data from keyword arguments. + + Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be + validated to form a valid model. + + `self` is explicitly positional-only to allow `self` as a field name. + + ### Ancestors (in MRO) + + * madsci.common.types.base_types.MadsciBaseModel + * pydantic.main.BaseModel + + ### Class variables + + `access_token: str` + : + + `expires_in: int` + : + + `model_config` + : + + `refresh_token: str | None` + : + + `scope: str | None` + : + + `token_type: str` + : + `UserInfo(**data: Any)` : Information about a user. diff --git a/docs/api/madsci/common/types/base_types.md b/docs/api/madsci/common/types/base_types.md index 1ef8f13bd..bba858768 100644 --- a/docs/api/madsci/common/types/base_types.md +++ b/docs/api/madsci/common/types/base_types.md @@ -112,6 +112,26 @@ Classes ### Descendants + * madsci.auth_manager.server_types.AddMemberRequest + * madsci.auth_manager.server_types.BootstrapResponse + * madsci.auth_manager.server_types.CreateProjectRequest + * madsci.auth_manager.server_types.CreateRoleRequest + * madsci.auth_manager.server_types.CreateUserRequest + * madsci.auth_manager.server_types.CredentialResponse + * madsci.auth_manager.server_types.DenyListEntry + * madsci.auth_manager.server_types.DenyListResponse + * madsci.auth_manager.server_types.GrantRoleRequest + * madsci.auth_manager.server_types.IntrospectRequest + * madsci.auth_manager.server_types.KeyInfo + * madsci.auth_manager.server_types.KeysHealthResponse + * madsci.auth_manager.server_types.ProjectResponse + * madsci.auth_manager.server_types.RegisterNodeRequest + * madsci.auth_manager.server_types.RegisterServiceAccountRequest + * madsci.auth_manager.server_types.RevokeRequest + * madsci.auth_manager.server_types.RoleResponse + * madsci.auth_manager.server_types.TokenErrorResponse + * madsci.auth_manager.server_types.UpdateUserRequest + * madsci.auth_manager.server_types.UserResponse * madsci.common.backup_tools.base_backup.BackupInfo * madsci.common.foss_migration.FossMigrationReport * madsci.common.foss_migration.FossMigrationStepResult @@ -134,8 +154,16 @@ Classes * madsci.common.types.action_types.ArgumentDefinition * madsci.common.types.action_types.RestActionRequest * madsci.common.types.admin_command_types.AdminCommandResponse + * madsci.common.types.auth_types.JWTClaims + * madsci.common.types.auth_types.NodeIdentity * madsci.common.types.auth_types.OwnershipInfo + * madsci.common.types.auth_types.Permission + * madsci.common.types.auth_types.Principal * madsci.common.types.auth_types.ProjectInfo + * madsci.common.types.auth_types.ProjectMembership + * madsci.common.types.auth_types.Role + * madsci.common.types.auth_types.ServiceAccount + * madsci.common.types.auth_types.TokenResponse * madsci.common.types.auth_types.UserInfo * madsci.common.types.base_types.Error * madsci.common.types.condition_types.Condition diff --git a/docs/api/madsci/common/types/manager_types.md b/docs/api/madsci/common/types/manager_types.md index c6b698857..9a6cee138 100644 --- a/docs/api/madsci/common/types/manager_types.md +++ b/docs/api/madsci/common/types/manager_types.md @@ -141,6 +141,7 @@ Classes ### Descendants + * madsci.common.types.auth_types.AuthManagerSettings * madsci.common.types.datapoint_types.DataManagerSettings * madsci.common.types.event_types.EventManagerSettings * madsci.common.types.experiment_types.ExperimentManagerSettings @@ -151,6 +152,15 @@ Classes ### Class variables + `auth_enabled: bool` + : + + `auth_required: bool` + : + + `auth_server_url: pydantic.networks.AnyUrl | None` + : + `enable_registry_resolution: bool` : diff --git a/docs/api/madsci/index.md b/docs/api/madsci/index.md index b496ffb3d..30377770e 100644 --- a/docs/api/madsci/index.md +++ b/docs/api/madsci/index.md @@ -3,6 +3,7 @@ Namespace madsci Sub-modules ----------- +* madsci.auth_manager * madsci.client * madsci.common * madsci.data_manager diff --git a/docs/guides/auth.md b/docs/guides/auth.md new file mode 100644 index 000000000..de3f5e65c --- /dev/null +++ b/docs/guides/auth.md @@ -0,0 +1,150 @@ +# Authentication & Authorization (Auth Manager) + +> **Status:** Foundation (v0.8). Default-disabled. Per-deployment opt-in. + +## Architecture + +MADSci's Auth Manager (port **8007**) is a per-lab, single-tenant OAuth 2.0 + OIDC-style identity service: + +``` ++-------------+ +---------------+ +-------------------+ +| User | login | Auth Manager | issues | Other Managers | +| Service +------->+ (port 8007) +-------->+ + Nodes | +| Node | | | JWT | (verify via JWKS)| ++-------------+ +-------+-------+ +-------------------+ + | ^ + | revoke -> /deny-list | poll + +----------------------------+ +``` + +**Key properties:** + +- **RS256 JWT access tokens** (15-min default TTL) signed by a rotating keypair, verified at every consuming manager via cached JWKS — no per-request introspection round-trip. +- **Opaque refresh tokens** stored server-side, rotated on every refresh, with reuse-detection. +- **Lab-scoped (1:1 with Lab Manager).** `aud = lab_id`, `iss = `. Tokens from one lab are unintelligible to another (cross-lab federation deferred to the Globus/ORCID follow-on). +- **RBAC with project scoping.** Roles bundle permissions in the canonical `.` namespace; users hold roles within projects; service accounts and nodes hold roles globally. +- **Persistent deny-list.** Revoked `jti` values live in PostgreSQL and are served via `GET /deny-list` with `ETag` / `If-None-Match` conditional fetch. Consumers poll every 30s by default. + +## Token model + +Every access token includes the standard JWT claims (`iss`, `aud`, `sub`, `iat`, `exp`, `jti`) plus MADSci-specific claims: + +| Claim | User token | Service-account token | Node token | Notes | +|------------------|------------|----------------------|-----------|------------------------------------------| +| `principal_type` | `user` | `service_account` | `node` | | +| `roles` | ✓ | ✓ | ✓ | Role IDs | +| `permissions` | ✓ | ✓ | ✓ | Flattened permission strings | +| `user_id` | ✓ | — | — | | +| `project_ids` | ✓ | — | — | Memberships at issuance | +| `manager_id` | — | ✓ | — | Distinct from `sub`/`client_id` | +| `node_id` | — | — | ✓ | | +| `workcell_id` | — | — | ✓ (opt.) | | + +`OwnershipInfo.from_jwt_claims(claims)` is the canonical mapping from these claims to MADSci's existing `OwnershipInfo` type (used by `AuthMiddleware` to populate `request.state.principal` and the ambient `ownership_context`). + +## Permission namespace + +Defined in `madsci.common.auth_decorators.PERMISSION_NAMESPACE`: + +| Permission | Grants | +|---------------------------|----------------------------------------------| +| `*` | Full administrative privileges | +| `experiment.read/write` | Experiment metadata | +| `workflow.read/submit` | Workflow definitions and submission | +| `resource.read/write` | Resource state and inventory | +| `workcell.read/execute` | Workcell config and admin commands | +| `node.read/execute_action`| Node status / action submission | +| `event.read` | Event log queries | +| `auth.user.read` / `.write` | List / create / modify users on the Auth Manager | +| `auth.project.read` / `.write` | List / create / modify projects + memberships | +| `auth.role.read` / `.write` / `.grant`| Read / create roles; grant / revoke role grants | +| `auth.principal.write` | Register service accounts and node identities | +| `auth.credentials.rotate` | Rotate service-account / node-identity secrets | +| `auth.key.read` / `.rotate` / `.retire`| List / rotate / retire signing keys | +| `auth.token.introspect` | Introspect any token (RFC 7662) | +| `auth.token.revoke` | Revoke other principals' tokens | + +Every administrative endpoint on the Auth Manager itself carries an explicit `@requires(permission=...)` check, and the Auth Manager mounts `AuthMiddleware` on its own app. The unauthenticated allowlist is intentionally narrow: + +- `POST /token` — bootstrap path for credentials. +- `GET /.well-known/jwks.json` — public key publication. +- `GET /health`, `/health/keys`, `/settings` — operator/monitor endpoints. +- `GET /deny-list` — polled by every consuming manager (authenticating it would be circular). +- `POST /introspect` — bypasses middleware enforcement; the handler returns `{"active": false}` to unauthenticated callers per RFC 7662 and full claims to authorized holders of `auth.token.introspect`. + +`POST /revoke` requires authentication. Self-revocation (the caller's own `sub`) is always allowed; revoking another principal's token requires `auth.token.revoke`. + +The built-in roles seeded by `madsci auth bootstrap` are `admin` (`*`, covers all `auth.*`), `experimenter`, `operator`, and `read_only`. + +## Integration points + +### `AuthMiddleware` + +`AbstractManagerBase` installs `AuthMiddleware` automatically when the manager's settings have `auth_enabled=True`. The middleware: + +1. Extracts `Authorization: Bearer `. +2. Verifies signature against cached JWKS from `auth_server_url`. +3. Validates `iss`/`aud`/`exp` and the deny-list. +4. Populates `request.state.principal: Principal | None`. +5. Enters an `ownership_context()` for the request lifetime. + +Behavior with `auth_required=False` (the migration mode): unauth'd requests pass through with `request.state.principal = None`, and a structured warning is logged so operators can identify gaps during rollout. + +### `AuthClient` + +`madsci.client.auth_client.AuthClient` provides: + +- `login()`, `refresh()`, `client_credentials_login()` +- `verify_jwt()` — JWKS-cached, force-refresh on signature failure +- `introspect()`, `revoke()` +- Background-friendly deny-list polling (`force_deny_list_refresh()` for tests) +- Admin surface (`create_user`, `register_service_account`, `register_node`, `rotate_keys`, `rotate_credentials`, …) + +### Ambient propagation + +```python +from madsci.client.auth_client import AuthClient +from madsci.common.auth_context import auth_client_context + +with AuthClient(auth_server_url="http://localhost:8007/") as ac: + ac.client_credentials_login(client_id, client_secret) + with auth_client_context(ac): + # Any other MADSci client built via create_httpx_client() + # automatically receives Authorization: Bearer + events = event_client.get_events() +``` + +### `@requires` decorator + +```python +from madsci.common.auth_decorators import requires +from fastapi import Request + +@get("/projects/{project_id}/items") +@requires(permission="experiment.write", project_from="project_id") +async def list_items(self, request: Request, project_id: str) -> list[Item]: + ... +``` + +`@requires` returns 401 if no principal is on the request, 403 if the required permission is absent, and 403 if `project_from` is supplied and the principal is not a member of the resolved project. + +## Migration plan + +See `docs/guides/auth_operator.md` for the operator runbook. The high-level rollout: + +1. Deploy Auth Manager; `madsci auth bootstrap`. +2. Register every manager and node; distribute secrets. +3. Set `auth_enabled=True, auth_required=False` on each consuming manager — observe. +4. Flip `auth_required=True` once traffic is clean. +5. Future MADSci release deprecates `auth_required=False` (removed alongside caller-asserted `OwnershipInfo` per Decision 10). + +## Follow-on changes + +Tracked separately as additional OpenSpec changes: + +- `auth-globus-orcid-federation` — upstream IdP federation (cross-lab user identity) +- `auth-node-mtls` — mTLS trust for nodes (slots into the reserved `mtls_cert_fingerprint` field) +- `auth-per-manager-rbac-rollout` — apply `@requires` across every manager endpoint +- `auth-per-principal-aud-narrowing` — narrow the single-`aud` model +- `auth-node-enrollment-tokens` — replace pre-provisioned NodeIdentity registration with kubelet-bootstrap-style enrollment +- `auth-registry-and-auth-cli-merge` — optional CLI convenience that does `madsci registry add` + `madsci auth node register` atomically diff --git a/docs/guides/auth_operator.md b/docs/guides/auth_operator.md new file mode 100644 index 000000000..198a5e1ec --- /dev/null +++ b/docs/guides/auth_operator.md @@ -0,0 +1,124 @@ +# Auth Manager — Operator Runbook + +Practical operational guide for deploying and running the MADSci Auth Manager. + +## Bootstrap + +```bash +# 1. Start the Auth Manager service (port 8007). The Auth Manager refuses +# to start without a bound lab_id — set AUTH_LAB_ID first. +export AUTH_LAB_ID= +python -m madsci.auth_manager.auth_server + +# 2. Bootstrap (creates admin user, signing keypair, built-in roles). +# Two ways to supply the password — never via argv (would leak via `ps`): +# a) Interactive prompt (TTY required): +madsci auth bootstrap --username admin --lab-id "$AUTH_LAB_ID" + +# b) Env var (for automation / CI): +export MADSCI_AUTH_BOOTSTRAP_PASSWORD='' +madsci auth bootstrap --username admin --lab-id "$AUTH_LAB_ID" +unset MADSCI_AUTH_BOOTSTRAP_PASSWORD +``` + +`bootstrap` is idempotent — re-running against a populated database is a no-op. + +> **Why no `--password` flag?** Anything on the command line is visible to every other user on the host via `ps`. The CLI was hardened to only accept the password from `MADSCI_AUTH_BOOTSTRAP_PASSWORD` or an interactive prompt. + +## Secret distribution + +When you register a service account or node identity, the Auth Manager returns the `client_secret` in plaintext **exactly once**. Capture it immediately and write it to disk under `.madsci/secrets/`: + +```bash +madsci auth manager register --manager-id +# { +# "client_id": "sa-...", +# "client_secret": "...", +# ... +# } +``` + +**Required filesystem hygiene:** + +| Path | Owner | Mode | `.gitignore`? | +|-----------------------------------|-------------|---------|------------------| +| `.madsci/secrets/` | service uid | `0700` | yes | +| `.madsci/secrets/.txt` | service uid | `0600` | (covered by dir) | + +The bundled `manager` and `node` templates ship `.gitignore` entries that exclude `.madsci/secrets/`. Verify yours does the same before committing. + +If you suspect a secret has leaked, rotate immediately: + +```bash +madsci auth credentials rotate +``` + +The old secret is invalidated atomically; only the new one will work for `client_credentials` token exchange after the call returns. + +## Key rotation + +```bash +# Add a new active signing key (current key remains in JWKS for verification) +madsci auth keys rotate + +# Once all tokens issued by the old key have expired (≥ access_token_ttl after the rotation): +madsci auth keys retire +``` + +`madsci auth keys list` shows all keys, their `active_for_signing` flag, and creation time. `GET /health/keys` reports `active_keys`, `signing_kid`, and `oldest_key_age_seconds` for monitoring. + +## HTTPS termination & reverse proxy + +Run the Auth Manager behind a TLS-terminating reverse proxy (Caddy, nginx, Envoy). + +**`X-Forwarded-For` is NOT trusted by default.** If you don't opt in, every audit row records the socket peer (your proxy's loopback IP). To use the real client IP from `X-Forwarded-For`, set: + +```yaml +# auth.settings.yaml +auth_trust_forwarded_for: true +``` + +…or `AUTH_TRUST_FORWARDED_FOR=true` in the environment. **Only enable this when the Auth Manager is reachable solely through a trusted proxy** — without that constraint any caller can spoof their IP in the audit log by setting the header themselves. + +When trusted, the proxy MUST forward real client IPs: + +```nginx +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Real-IP $remote_addr; +``` + +## Audit-log retention & PII + +The `audit_log` table holds: timestamps, principal IDs, grant types, JTIs, and source IPs. It deliberately does NOT hold passwords, secrets, or full request bodies (per Decision 1's rate-limiting requirement and the related spec). + +Operators are responsible for: + +- **Retention policy.** No automatic deletion; size with the deployment's auth volume. +- **PII review.** Source IPs and usernames may be PII under your jurisdiction's regulations (GDPR Art. 4, CCPA, etc.). Document handling in your Records of Processing Activities. +- **Read-access auditing.** Anyone with database read can inspect the table; layer DB-level auth separately. + +## Local audit-log fallback + +If a consuming manager cannot deliver an authentication-related audit event to the Auth Manager (network partition, 5xx), the event is appended to a local on-disk fallback at `local_audit_log_path` (default `.madsci/audit/auth-fallback.log`). A drain task retries delivery on a configurable interval; events are removed locally only after the Auth Manager confirms persistence. + +The fallback is bounded by `local_audit_log_max_bytes` (default **100 MB**). When exceeded, the oldest segment is rotated out and a structured warning event is emitted. + +> **Strong recommendation:** alert on the rotation warning event (event_type `auth_fallback_rotation`) so you upsize `local_audit_log_max_bytes` *before* the bound bites at high request rates and you start dropping events. + +## Migration plan (auth_enabled → auth_required) + +Per-manager rollout: + +1. Set `auth_enabled=True, auth_required=False` (migration mode). +2. Watch logs for "AuthMiddleware: unauth'd request" warnings. They tell you which callers still need credentials. +3. Update those callers (CI/CD, scripts, notebooks) to acquire and present tokens. +4. When the warnings dry up, flip `auth_required=True` and restart. +5. The future MADSci release deprecates `auth_required=False`; the release after that removes it. + +This is also when caller-asserted `OwnershipInfo` is removed (Decision 10 couples the two deprecations to keep operators on a single migration jump). + +## Disaster recovery + +- **Backup the Auth Manager database.** Use `madsci-postgres-backup` (`madsci.common.backup_tools.PostgreSQLBackupTool`). Take backups before key rotations and at the same cadence as your other PostgreSQL DBs. +- **Lost signing key.** Generate a new signing key (`madsci auth keys rotate`); existing tokens issued by the lost key keep validating until expiry (≤ `access_token_ttl`). Retire the lost key once expired. +- **Compromised admin secret.** `madsci auth user password ` to rotate; revoke any active access tokens with `POST /revoke`. diff --git a/examples/example_lab/README.md b/examples/example_lab/README.md index 72e3d3cee..e419fafdb 100644 --- a/examples/example_lab/README.md +++ b/examples/example_lab/README.md @@ -23,6 +23,7 @@ The example lab simulates a real laboratory environment with: - **Data Manager** (Port 8004): Data capture, storage, and querying - **Workcell Manager** (Port 8005): Workflow coordination and scheduling - **Location Manager** (Port 8006): Laboratory location management and resource attachments +- **Auth Manager** (Port 8007): JWT-based identity service. Default-disabled at all consumers; opt in per [`docs/guides/auth_operator.md`](../../docs/guides/auth_operator.md). ### Laboratory Nodes - **liquidhandler_1** (Port 2000): First liquid handling robot @@ -110,6 +111,41 @@ The dashboard provides: - Data visualization tools - System health monitoring +## Auth Manager bootstrap (optional) + +The example lab boots `auth_manager` (port 8007) but every consumer leaves +`auth_enabled=False`, so existing scripts and notebooks keep working without +any token. To explore the auth flow: + +```bash +# 1. Bootstrap the Auth Manager (pick any ULID for lab_id). +# The admin password is sourced from MADSCI_AUTH_BOOTSTRAP_PASSWORD or an +# interactive prompt — `--password` on argv is rejected (would leak via `ps`). +docker compose exec -e MADSCI_AUTH_BOOTSTRAP_PASSWORD='hunter2' auth_manager \ + madsci auth bootstrap --username admin \ + --lab-id 01HZZ0000000000000000000A0 + +# 2. Verify +curl -s -X POST http://localhost:8007/token \ + -d 'grant_type=password&username=admin&password=hunter2' | jq + +# 3. Inspect the JWKS +curl -s http://localhost:8007/.well-known/jwks.json | jq +``` + +To enable auth on a single consumer in **migration mode** (observe-only), +add to that manager's settings: + +```yaml +auth_enabled: true +auth_required: false # accept unauth'd requests, log warnings +auth_server_url: "http://auth_manager:8007/" +``` + +See [`docs/guides/auth_operator.md`](../../docs/guides/auth_operator.md) +for the full rollout (registering managers/nodes, key rotation, secret +distribution). + ## Configuration This lab uses the modern **dual-layer configuration** pattern: diff --git a/examples/example_lab/compose.infra.yaml b/examples/example_lab/compose.infra.yaml index a425b4d2b..e43ff1b8a 100644 --- a/examples/example_lab/compose.infra.yaml +++ b/examples/example_lab/compose.infra.yaml @@ -51,6 +51,21 @@ services: volumes: - ${REPO_PATH:-../..}/.madsci/postgresql_resources/data:/var/lib/postgresql/data + # PostgreSQL for the Auth Manager (separate from Resource Manager so an + # auth incident doesn't compromise inventory state). + madsci_postgres_auth: + container_name: madsci_postgres_auth + image: postgres:17 + restart: unless-stopped + environment: + - POSTGRES_USER=madsci + - POSTGRES_PASSWORD=madsci + - POSTGRES_DB=auth + ports: + - ${AUTH_POSTGRES_PORT:-5435}:5432 + volumes: + - ${REPO_PATH:-../..}/.madsci/postgresql_auth/data:/var/lib/postgresql/data + madsci_seaweedfs: container_name: madsci_seaweedfs image: chrislusf/seaweedfs:4.17 diff --git a/examples/example_lab/compose.yaml b/examples/example_lab/compose.yaml index 88c2da75b..a7dbf4f82 100644 --- a/examples/example_lab/compose.yaml +++ b/examples/example_lab/compose.yaml @@ -98,6 +98,22 @@ services: - event_manager - location_manager + # Auth Manager — default-disabled at all consumers (auth_enabled=False); the + # service still runs so operators can opt in by flipping per-manager + # auth_enabled=True. See docs/guides/auth_operator.md for the rollout plan. + auth_manager: + <<: *madsci-service + container_name: auth_manager + command: python -m madsci.auth_manager.auth_server + environment: + # network_mode: host means we reach postgres via the host-mapped port, + # not the container DNS name (which doesn't resolve under host networking). + - AUTH_DATABASE_URL=postgresql://madsci:madsci@localhost:${AUTH_POSTGRES_PORT:-5435}/auth + - AUTH_LAB_ID=${AUTH_LAB_ID:-01HZZ0000000000000000000A0} + depends_on: + - madsci_postgres_auth + - event_manager + # *Nodes liquidhandler_1: diff --git a/openspec/changes/auth-manager-foundation/.openspec.yaml b/openspec/changes/auth-manager-foundation/.openspec.yaml new file mode 100644 index 000000000..ce9d1c695 --- /dev/null +++ b/openspec/changes/auth-manager-foundation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-01 diff --git a/openspec/changes/auth-manager-foundation/design.md b/openspec/changes/auth-manager-foundation/design.md new file mode 100644 index 000000000..a1cfa8a52 --- /dev/null +++ b/openspec/changes/auth-manager-foundation/design.md @@ -0,0 +1,190 @@ +## Context + +MADSci is a microservices framework for autonomous laboratory automation. It currently runs seven manager services (Lab/Event/Experiment/Resource/Data/Workcell/Location), an arbitrary number of node servers, and experiment clients — all communicating over plain HTTP with no authentication, no authorization, and no validated identity propagation. The codebase already defines `OwnershipInfo`, `UserInfo`, and `ProjectInfo` Pydantic types in `madsci.common.types.auth_types`, plus an `ownership_context()` contextvars-based propagation system in `madsci.common.ownership`. These are used today only for logging provenance. + +This change introduces the foundational identity, token, and authorization machinery — a new `madsci_auth_manager` service plus an `AuthClient`, plus the `AbstractManagerBase` middleware integration that lets every other manager opt into enforcement. It is the first phase of a multi-change roadmap (Issue #86); follow-on changes will add OIDC federation (Globus, ORCID), mTLS for nodes, the UI login flow, and per-manager authorization policies. + +The work intersects with the in-flight SiLA2 migration (#293/#294) — the SiLA2 protocol uses TLS-based trust, so the node-identity model designed here must be compatible with handing a SiLA node a JWT, an mTLS cert, or both. It also unblocks the layered location ownership project (#210), which needs authoritative `OwnershipInfo`. + +## Goals / Non-Goals + +**Goals:** +- Establish a single, authoritative source of identity for users, projects, service accounts, and node identities across a MADSci deployment. +- Issue and verify short-lived JWT access tokens using audited libraries (Authlib + cryptography); never roll our own crypto. +- Provide an opt-in authentication enforcement path on `AbstractManagerBase` that defaults to disabled so existing deployments keep working. +- Bind validated JWT claims into `OwnershipInfo`, replacing today's caller-asserted ownership with claims-based ownership when auth is enabled. +- Define a clear permission model (Roles + per-Project membership) and a `@requires(...)` decorator pattern downstream managers can adopt. +- Provide a clean migration path: bootstrap CLI, default-disabled rollout, per-manager opt-in, deprecation of unauth'd mode in a future release. + +**Non-Goals:** +- OIDC federation with Globus/ORCID — designed-for, not implemented in this change. +- mTLS for node→manager trust — left for a follow-on change once SiLA2 work lands. +- UI login flows / dashboard auth — out of scope; Vue UI integration follows separately. +- Multi-lab / federated Auth Manager topology — single-Auth-Manager-per-deployment for now. +- Per-action authorization policies inside individual managers — this change provides the *mechanism* (`@requires`, ownership claims) and a small number of canonical examples; comprehensive enforcement across every existing endpoint is staged across follow-on changes. +- Encryption-at-rest of refresh tokens / sensitive Auth Manager data — out of scope; we rely on PostgreSQL/disk-level encryption operators already configure. + +## Decisions + +### Decision 1: Use Authlib + PyJWT-compatible JWTs (not custom tokens, not opaque sessions) + +We will issue **JWT access tokens** signed with **RS256** (rotating asymmetric keypair) and **opaque refresh tokens** stored server-side. Token validation in client services uses `Authlib`'s JWT module, fetching the public JWKS from the Auth Manager's `/.well-known/jwks.json` endpoint with a TTL-based cache. + +**Why JWT over opaque-only tokens:** Stateless verification at every manager — no per-request introspection round-trip to the Auth Manager. Critical for the high-throughput inter-service traffic (workcell → nodes during workflow execution). + +**Why RS256 over HS256:** Asymmetric signing means downstream managers verify with the public key only; they never hold a secret that could be exfiltrated to forge tokens. Key rotation is also straightforward via JWKS. + +**Why Authlib over PyJWT alone:** Authlib gives us JWT issuance, JWKS, OAuth 2.0 grant flows, and the OIDC client all from one battle-tested library — reducing the surface area we maintain and prepping us for Phase 5 (Globus/ORCID). + +**Alternatives considered:** +- *Opaque tokens with introspection on every request*: rejected — too much latency and creates a single-point hotspot at the Auth Manager. +- *PASETO*: rejected — newer, smaller ecosystem, no clear advantage over correctly-implemented JWT + Authlib. +- *Roll our own*: explicitly rejected per project guidance and well-known security pitfalls. + +### Decision 2: Argon2 (via argon2-cffi) for password hashing + +Local-user passwords use Argon2id with sensible defaults (time_cost=3, memory_cost=64 MiB, parallelism=4 — tunable in settings). Argon2 is the OWASP-recommended modern KDF and is the winner of the Password Hashing Competition. + +**Alternatives considered:** bcrypt (older, no memory hardness), scrypt (less actively maintained Python bindings), PBKDF2 (allowed by NIST but weaker than Argon2 against GPU attacks). + +### Decision 3: PostgreSQL via SQLModel for persistence + +The Auth Manager uses **PostgreSQL via SQLModel**, matching the Resource Manager's pattern. Auth data is intrinsically relational (users ↔ memberships ↔ projects ↔ roles) and we want strong ACID guarantees, foreign keys, and unique constraints — all things document storage handles poorly. + +We will reuse the existing `SQLAlchemyHandler` abstraction from `madsci.common.db_handlers`, including the in-memory `SQLiteHandler` for tests. + +**Alternatives considered:** FerretDB (used by Event/Workcell/Data) — rejected because relational integrity matters here far more than schema flexibility. + +### Decision 4: AuthMiddleware on AbstractManagerBase, opt-in via setting + +We add `auth_enabled: bool = False` and `auth_required: bool = False` settings to `MadsciBaseSettings`, plus an `auth_server_url` pointing at the Auth Manager. When `auth_enabled` is true, `AbstractManagerBase` registers an `AuthMiddleware` that: + +1. Extracts `Authorization: Bearer ` from the request. +2. Verifies signature against cached JWKS. +3. Checks `exp`, `iss`, `aud`. +4. Populates `request.state.principal` with the validated subject + ownership claims. +5. Enters an `ownership_context()` for the request lifetime, sourced from token claims. + +When `auth_required=True`, requests without a valid token return 401. When `auth_required=False` (the migration mode), unauth'd requests are allowed but `request.state.principal` is None and `OwnershipInfo` is unset — letting deployments roll out gradually. + +**Alternatives considered:** Per-manager middleware duplication — rejected, hugely error-prone. Implicit always-on after first release — rejected, breaks every existing deployment. + +### Decision 5: Permission model — RBAC with project-scoped grants (not pure ABAC) + +A user is granted **roles** within the **scope of a project** (or globally, for system roles like `admin`). Each role has a set of **permissions** (e.g., `experiment.write`, `node.execute_action`, `resource.read`). Service accounts and node identities also have role grants but typically scoped globally or to a workcell. + +Authorization checks compose: `@requires(permission="experiment.write", project_from="experiment_id")` reads the project from the experiment's ownership and checks the principal's roles within that project. + +**Why RBAC + project scoping (not pure ABAC):** It maps cleanly onto the existing `OwnershipInfo` hierarchy (project_id is already a first-class field), is widely understood by operators, and avoids the policy-language complexity of ABAC (OPA/Cedar) for a first foundation. ABAC can be layered later as a Phase 6 enhancement if needed. + +### Decision 6: Service identity via OAuth 2.0 client-credentials grant + +Each manager and node has a **ServiceAccount** (for managers) or **NodeIdentity** (for nodes) record in the Auth Manager. At startup the service exchanges a `client_id` + `client_secret` for a JWT via the standard OAuth 2.0 client-credentials grant. The `AuthClient` handles refresh transparently before expiry. + +Bootstrap secrets are issued by `madsci auth manager register ` / `madsci auth node register ` and stored in `.madsci/secrets/`. Operators can rotate them with `madsci auth credentials rotate`. + +### Decision 7: New port allocation — 8007 + +Auth Manager runs on **port 8007**, slotted in after Location Manager (8006). Reserved in port-allocation docs. + +### Decision 8: Single audience (`aud = lab_id`) for v1 + +All access tokens — for users, service accounts, and node identities alike — SHALL be issued with a single `aud` claim equal to the deployment's `lab_id`. Every manager verifies `aud == lab_id` during JWT validation. + +**Why single aud for v1:** Simplest possible client and verification logic for the foundation. One token per principal, one refresh path, one cache entry. Aligns with the typical single-tenant lab deployment where every manager and node trusts every other manager and node within the same `lab_id` boundary. Avoids upfront-declaration ergonomic problems (especially for users, who interact with everything). + +**Trade-off accepted:** A token leaked from any service can be replayed against every other service in the same lab. The mitigations are short access-token TTL (15 min) and the `jti` deny-list for incident response — same controls that bound any single-token compromise. + +**Follow-on path (not in this change):** A future change SHALL add per-principal `aud` scoping — primarily for service accounts and node identities, where audiences are declared at registration time (`madsci auth node register --audiences workcell_manager,event_manager`). User tokens would likely remain broad. RFC 8693 token exchange is a possible further evolution if dynamic narrowing becomes desirable. This is captured in the follow-up issue list created in Task 15.4. + +**Alternatives considered:** +- *Multi-audience array from day one*: rejected — requires every caller to declare intent upfront, complicates the AuthClient cache, and forces users (who hit everything) into either broad scoping anyway or per-target refresh. Better to layer this in once the foundation has settled. +- *Per-resource-server tokens (classic OAuth)*: rejected — too much token churn for a workflow execution that fans out across many managers. + +### Decision 9: Pre-provisioned node identities for v1 + +NodeIdentity (and ServiceAccount) records SHALL be created by an operator action ahead of node startup. The operator runs `madsci auth node register --node-id --workcell-id `; the Auth Manager returns the `client_id` + plaintext `client_secret` exactly once; the operator distributes the secret to the node host (typically via `.madsci/secrets/` mounted into the container or as an env var). At startup the node exchanges the secret for a JWT via the standard OAuth 2.0 client-credentials grant. + +**Why pre-provisioned for v1:** Smallest delta from the existing static compose / config-file deployment model that real MADSci labs use today. No new endpoint, no node-side keypair generation, no enrollment-token bookkeeping. The NodeIdentity row exists before the node ever runs, which makes the trust model unambiguous and easy to audit. + +**Trade-off accepted:** Operators have to manually shuffle a secret from the CLI output to the node host. At small node counts this is fine; it does not scale to dozens of ephemeral nodes or to autoscaled / CI environments. The on-disk secret is also long-lived until rotated. + +**Follow-on path (not in this change):** A future change SHALL add an enrollment-token flow modeled on Kubernetes kubelet bootstrap / Tailscale auth keys / Nomad ACL bootstrap. Operators would create short-lived, optionally multi-use enrollment tokens scoped to a workcell with name-pattern constraints; nodes would self-generate a keypair, present the enrollment token to a `/enroll` endpoint, and persist their issued long-lived credentials locally. The NodeIdentity schema designed here is forward-compatible — a follow-on adds an `enrolled_via_token` field and the `/enroll` endpoint without restructuring existing tables. Captured in Task 15.4. + +**Alternatives considered:** +- *Enrollment tokens from day one*: rejected — meaningful additional surface area (new endpoint, name-pattern enforcement, single-use accounting, node-side persistent credential store, key-binding semantics) that would dilute the foundation change. Value primarily shows at scale and in dynamic environments, neither of which is the typical MADSci lab today. +- *Trust-on-first-use with no operator action*: rejected — would require some other mechanism (mTLS, network position) to establish trust, all of which are larger projects than pre-provisioning. + +### Decision 10: OwnershipInfo back-compat — accept caller-asserted values when auth is disabled + +When `auth_enabled=False`, the existing behavior SHALL be preserved: `OwnershipInfo` continues to be sourced from caller-supplied request bodies and the `ownership_context()` machinery, with no validation against tokens. When `auth_enabled=True`, caller-supplied `OwnershipInfo` is accepted only when no contradicting JWT claim exists, and the middleware-derived (claims-sourced) values always win on conflict — with a structured warning logged on mismatch. + +A deprecation warning SHALL be emitted on every successful caller-asserted `OwnershipInfo` write when `auth_enabled=False`, on a sampled basis (default once per process per minute per call-site, to avoid log floods). The warning text SHALL point operators at the migration guide. + +**Removal timeline:** Caller-asserted `OwnershipInfo` SHALL be removed in the same MADSci release that removes the `auth_required=False` migration mode. This couples the two deprecations so deployments make a single jump rather than two. + +**Why:** Hard-cutting this would break every existing script and notebook in the wild on day one. The deprecation/coupling lets operators migrate at their own pace while making the eventual end-state unambiguous. + +**Trade-off accepted:** The grace period means we ship a release in which `OwnershipInfo` semantics differ depending on `auth_enabled`. This is documented in the operator guide. + +### Decision 11: Registry is orthogonal to auth in v1 + +The existing identity registry (`enable_registry_resolution`, `MADSCI_REGISTRY_PATH`, `madsci.common.registry`) SHALL remain unchanged. It continues to resolve manager and node ULIDs to URLs without any auth awareness — a registry lookup is a directory operation, not an authentication operation. + +Auth credentials (NodeIdentity / ServiceAccount records) live in the Auth Manager's PostgreSQL database; the registry continues to live in its JSON file or its own resolution path. The two subsystems share `manager_id` / `node_id` ULIDs as join keys but have no other coupling in v1. + +**Why:** Conflating directory and identity concerns is a classic anti-pattern; keeping them separate lets each evolve independently. The registry's current concerns (URL resolution, ULID lookup) are not auth concerns. A future change MAY explore whether the registry should vend bootstrap material (e.g., the Auth Manager URL itself, JWKS bootstrap) but that is a separate design question. + +**Trade-off accepted:** Operators have to keep registry entries and Auth Manager registrations consistent (e.g., when adding a new node, both `madsci registry add` and `madsci auth node register` are required). The CLI MAY offer a convenience that does both atomically; this is captured as an optional follow-on. + +### Decision 12: Lab-scoped Auth Manager (`lab_id` is the tenant boundary) + +The Auth Manager and the Lab Manager have a **1:1 relationship**. Each MADSci lab runs exactly one Auth Manager. `lab_id` is the security and tenancy boundary: `aud = lab_id` (per Decision 8) and `iss` is the URL of that lab's Auth Manager. Users, projects, service accounts, node identities, role grants, signing keys, and audit logs all live in that single Auth Manager's PostgreSQL database and are implicitly scoped to its `lab_id`. The schema is single-tenant — there is no `tenant_id` foreign key and no cross-lab queries. + +**Why lab-scoped for v1:** +- Smallest delta from how MADSci is deployed today (one lab ≈ one deployment). +- Schema and operations stay single-tenant, eliminating an entire class of cross-tenant data-isolation bugs in a security-critical subsystem. +- Trust boundary is unambiguous: a token from lab A's Auth Manager has no semantic meaning in lab B until/unless an explicit federation mechanism is added. +- Bootstrap is clean — `madsci auth bootstrap` operates against a single lab. + +**Cross-lab user identity is intentionally deferred to the upstream-IdP layer.** Researchers who work across labs will, in v1, hold multiple lab-scoped tokens — one per lab. The follow-on Globus/ORCID OIDC federation work resolves this at the right layer: each lab's Auth Manager OIDC-trusts a shared upstream IdP, so a researcher's external identity is one record but lab-local role grants and project membership remain lab-autonomous. This is the same pattern used by every modern federated scientific computing system (JupyterHub, Globus-aware HPC schedulers, etc.) and avoids forcing multi-tenant complexity into the foundation. + +**Trade-offs accepted:** +- Orgs running N labs operate N Auth Managers (N PostgreSQL DBs to back up, N sets of signing keys to rotate). Acceptable given typical AD-SDL/RPL deployment scale. +- Cross-lab researchers manage multiple tokens until the upstream-IdP follow-on lands. Annoying but workable — and the upstream-IdP work is already on the roadmap. +- Cross-lab token validation (lab B trusting lab A's tokens directly) is not supported in v1. If two labs want to share resources, the path is via the shared upstream IdP, not via direct cross-issuer trust. + +**Forward-compatibility:** Nothing in this decision precludes a later multi-tenant Auth Manager mode or direct cross-issuer trust. Both can be added in follow-on changes without breaking what v1 ships, because the schema is already keyed on globally-unique ULIDs and `iss`/`aud` are already explicit. + +**Alternatives considered:** +- *Multi-tenant Auth Manager (one per organization, `tenant_id` on every row)*: rejected — adds isolation bugs to a security-critical foundation, and the cross-lab-user UX problem is better solved at the IdP layer anyway. +- *Workcell-scoped Auth Manager (sub-lab tenants)*: rejected — within a lab, isolation belongs at the Project layer (project membership), not at the auth tenancy layer. + +## Risks / Trade-offs + +- **[Risk] Adding auth to a previously-open system breaks every script in the wild.** → Mitigation: default `auth_enabled=False`; operators opt in. Two-stage rollout per manager (`auth_enabled=True, auth_required=False` first to observe, then flip `auth_required=True`). Migration guide in docs. +- **[Risk] Compromise of the Auth Manager signing key forges tokens for the entire deployment.** → Mitigation: RS256 + key rotation via JWKS (multiple active keys at once). Operator runbook for emergency rotation. Document encrypted-at-rest storage of the private key. +- **[Risk] JWKS cache staleness causes valid tokens to be rejected (or revoked keys to be accepted).** → Mitigation: short TTL (5 min) + on-401 forced refresh in `AuthClient`. Document max revocation lag. +- **[Risk] Refresh-token theft enables persistent compromise.** → Mitigation: refresh tokens are opaque + server-side stored + bound to a session; revocation endpoint flushes them; rotation on every refresh. +- **[Risk] Performance hit on inter-service calls from JWT verification.** → Mitigation: verification is local + cached; benchmark in CI. If this becomes hot, switch to caching verified-claim results per-token-hash. +- **[Trade-off] Stateless JWTs make instant revocation hard.** → Accepted — short access-token TTL (15 min) bounds blast radius; long-term blocking is via refresh-token revocation + a small in-memory `jti` deny-list synced from the Auth Manager when needed. +- **[Trade-off] Bootstrap secrets on disk for service accounts/nodes.** → Accepted — same trust model as today's compose-mounted secrets; documented; mTLS in a follow-on change improves this for nodes. +- **[Risk] Incompatible identity model with SiLA2 node trust.** → Mitigation: Coordinate explicitly with SiLA2 owner; define `NodeIdentity` to carry both `client_credentials` *and* (future) `mtls_cert_fingerprint`; design review with SiLA2 work before merge. +- **[Risk] Scope creep — every manager wants its own permissions modeled now.** → Mitigation: this change ships the *mechanism* + Auth Manager itself + ownership-claim binding; per-manager `@requires` rollout is staged in follow-on changes (one per manager). + +## Migration Plan + +**Per-deployment rollout:** +1. Operator deploys Auth Manager service; runs `madsci auth bootstrap` (creates initial admin user + signing keys). +2. Operator registers each manager and node, distributes client secrets to each service. +3. Operator sets `auth_enabled=True, auth_required=False` on each manager → middleware runs but doesn't block. Logs validate that real traffic carries valid tokens. +4. Once green, operator flips `auth_required=True` per manager. +5. Subsequent MADSci release deprecates `auth_required=False`; release after that removes it. + +**Rollback:** Set `auth_enabled=False` per-manager and restart. Auth Manager can remain running idle. + +**Tests / CI:** New end-to-end test that boots the Auth Manager + one consuming manager + one node in compose, exercises the full bootstrap → token → call → revoke flow. + +## Open Questions + +_All open questions have been resolved into Decisions 8–12 above. Future questions surfaced during implementation will be tracked here or as separate follow-on changes._ diff --git a/openspec/changes/auth-manager-foundation/proposal.md b/openspec/changes/auth-manager-foundation/proposal.md new file mode 100644 index 000000000..f3a6136d3 --- /dev/null +++ b/openspec/changes/auth-manager-foundation/proposal.md @@ -0,0 +1,43 @@ +## Why + +MADSci has no authentication or authorization on any of its services — every manager endpoint is open to anyone with network access, and the existing `OwnershipInfo` metadata is propagated only as in-process Python contextvars, never validated against requests. As MADSci moves toward shared/multi-tenant lab deployments, federated experiments, and external integrations (Globus, ORCID), this gap blocks downstream features and presents a real security risk. [Issue #86](https://github.com/AD-SDL/MADSci/issues/86) has been open since the early days; the work is now a dependency for layered location ownership, federated experiments, and the SiLA2 migration's node trust model. + +## What Changes + +- **NEW** `madsci_auth_manager` package: a new manager service (port 8007) implementing user, project, service-account, and node-identity records, plus token issuance/introspection endpoints. +- **NEW** `AuthClient` in `madsci_client` providing token acquisition, automatic refresh, JWKS-based verification, and ambient-context binding so other clients pick up credentials transparently. +- **NEW** Pluggable `AuthMiddleware` for `AbstractManagerBase` that validates bearer tokens and populates `OwnershipInfo` from JWT claims (default-off behind `auth_enabled` setting for backwards compat). +- **NEW** Local user accounts with Argon2 password hashing; **OPTIONAL** OIDC federation hooks (Globus, ORCID) deferred to follow-on changes but designed-for in this foundation. +- **NEW** Capability/role model: `Role`, `Permission`, project membership, and a `@requires(...)` decorator usable on manager endpoints. +- **NEW** Service-to-service identity via OAuth 2.0 client-credentials grant for managers and nodes; bootstrap tokens issued by the Auth Manager at startup. +- **MODIFIED** All existing service clients (`EventClient`, `ExperimentClient`, etc.) gain optional auth-header injection driven by an ambient `AuthClient`. +- **MODIFIED** `OwnershipInfo` semantics: when auth is enabled, fields like `user_id`/`project_id` MUST come from validated token claims rather than caller-supplied values. +- **NEW** Operator documentation covering bootstrap (initial admin user, signing key generation), HTTPS termination patterns, and a backwards-compat migration path. +- **NOT IN SCOPE for this change** (called out so reviewers don't expect them): mTLS for nodes, full Globus/ORCID federation, UI login flows, distributed/multi-lab Auth Manager federation. These will be follow-on changes built on this foundation. + +This is a **non-breaking** change at the deployment level (auth defaults to disabled), but **forward-incompatible** for code paths that assume `OwnershipInfo` is freely caller-asserted: once a deployment enables auth, callers must hold valid tokens and ownership claims become authoritative. One small behavior change does ship even with `auth_enabled=False`: a sampled deprecation warning is emitted whenever caller-asserted `OwnershipInfo` is accepted (per Decision 10), pointing operators at the migration guide. + +## Capabilities + +### New Capabilities + +- `auth-identity-model`: User, Project, ServiceAccount, NodeIdentity, Role, and Permission entities, including project membership and role grants. +- `auth-token-lifecycle`: Token issuance (password grant, refresh grant, client-credentials grant), introspection, revocation, JWKS publication, and signing-key rotation. +- `auth-manager-service`: The `madsci_auth_manager` HTTP service surface — endpoints, settings, persistence, bootstrap, and lifecycle — built on `AbstractManagerBase`. +- `auth-client-integration`: The `AuthClient`, the `AuthMiddleware` for `AbstractManagerBase`, and the contract by which existing service clients and middleware acquire/verify/propagate credentials and bind validated claims into `OwnershipInfo`. + +### Modified Capabilities + + + +## Impact + +- **New package**: `src/madsci_auth_manager/` (settings, server, persistence, token service, bootstrap CLI). +- **Modified packages**: `madsci_common` (auth types extension, `OwnershipInfo` claim binding, `AuthMiddleware` hook on `AbstractManagerBase`), `madsci_client` (new `AuthClient`, optional auth-header injection in `create_httpx_client`). +- **New dependencies**: `Authlib` (OIDC client + JWT issuance), `argon2-cffi` (password hashing), `cryptography` (signing key management). All are mature, audited, and FOSS-licensed. +- **New database**: PostgreSQL schema for users, projects, memberships, roles, service accounts, node identities, refresh tokens, and audit log. Reuses existing PostgreSQL infrastructure. +- **New port**: 8007 reserved for the Auth Manager (slots in after Location Manager at 8006). +- **Operator surface**: New CLI subcommands (`madsci auth bootstrap`, `madsci auth user create`, `madsci auth keys rotate`); new compose service in example lab; new docs page. +- **Backwards compatibility**: All managers default to `auth_enabled=False`; existing labs continue to operate unchanged. A deployment opts into auth by enabling the Auth Manager and flipping per-manager settings. +- **Coordination**: Intersects with the in-flight SiLA2 migration project (#293/#294) — node-identity decisions in this change must be compatible with SiLA2's TLS-based trust model. Also intersects with Issue #210 (layered location ownership), which becomes implementable once `OwnershipInfo` is authoritative. +- **Risk**: This is a security-critical subsystem; test coverage and a security review are required before enabling by default in any release. diff --git a/openspec/changes/auth-manager-foundation/specs/auth-client-integration/spec.md b/openspec/changes/auth-manager-foundation/specs/auth-client-integration/spec.md new file mode 100644 index 000000000..dfc64698f --- /dev/null +++ b/openspec/changes/auth-manager-foundation/specs/auth-client-integration/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: AuthClient class + +A new `AuthClient` SHALL be added to `madsci_client` (`src/madsci_client/madsci/client/auth_client.py`) providing programmatic access to the Auth Manager. It MUST support: password login, refresh-token grant, client-credentials grant, token introspection, JWKS fetch with TTL caching, and explicit `close()` for connection-pool cleanup. + +#### Scenario: Acquire a token via password grant +- **WHEN** application code calls `AuthClient(auth_server_url=...).login(username, password)` +- **THEN** the client SHALL POST to the token endpoint, store the resulting access and refresh tokens in memory, and return a typed `TokenResponse` Pydantic model + +#### Scenario: Auto-refresh before expiry +- **WHEN** an `AuthClient` holds an access token whose `exp` is within a configurable refresh-buffer (default 60 seconds) of the current time +- **THEN** the next call requiring a token SHALL transparently invoke the refresh grant and update the cached tokens before returning + +#### Scenario: JWKS cached with TTL +- **WHEN** an `AuthClient` is asked to verify a JWT +- **THEN** it SHALL fetch JWKS once, cache the keys for a TTL of at most 5 minutes, and reuse the cache for all subsequent verifications until the TTL elapses + +### Requirement: Ambient AuthClient and credential propagation + +An ambient `AuthClient` SHALL be installable into a contextvars-based scope (`auth_client_context()`) analogous to `event_client_context()`. When set, the existing `create_httpx_client()` factory in `madsci.common.http_client` SHALL automatically inject `Authorization: Bearer ` on every outbound request. + +#### Scenario: Ambient client injects auth headers +- **GIVEN** `auth_client_context(client)` has been entered with a logged-in `AuthClient` +- **WHEN** any service client built on `create_httpx_client()` (e.g., `EventClient.async_log_event(...)`) issues a request +- **THEN** the request SHALL carry an `Authorization: Bearer ` header sourced from the ambient `AuthClient` + +#### Scenario: No ambient client means no header +- **GIVEN** no `auth_client_context()` is active +- **WHEN** a service client issues a request +- **THEN** no `Authorization` header SHALL be added (preserving existing unauthenticated behavior) + +#### Scenario: 401 triggers a refresh-and-retry once +- **WHEN** a request returns HTTP 401 and an ambient `AuthClient` is present +- **THEN** the client SHALL force a JWKS-cache refresh, attempt a refresh-grant, and retry the original request exactly once before surfacing the error + +### Requirement: AuthMiddleware on AbstractManagerBase + +`AbstractManagerBase` SHALL gain an `AuthMiddleware` that is installed on the FastAPI app whenever `auth_enabled=True` in the manager's settings. The middleware MUST: extract the `Authorization` header, verify the JWT signature against cached JWKS from `auth_server_url`, validate `iss`/`aud`/`exp`, populate `request.state.principal` with the validated claims, and enter an `ownership_context()` for the request lifetime sourced from those claims. + +#### Scenario: Middleware installed only when auth_enabled +- **WHEN** `AbstractManagerBase` initializes with `auth_enabled=False` +- **THEN** `AuthMiddleware` SHALL NOT be added to the FastAPI app and request behavior SHALL be identical to today's + +#### Scenario: Valid token populates principal and ownership +- **GIVEN** `auth_enabled=True` +- **WHEN** a request arrives with a valid `Authorization: Bearer ` header +- **THEN** the middleware SHALL set `request.state.principal` to a typed `Principal` model derived from the JWT claims and the request handler SHALL observe an `ownership_context()` whose fields are sourced from the token's ownership claims + +#### Scenario: Missing or invalid token with auth_required=True +- **GIVEN** `auth_enabled=True` and `auth_required=True` +- **WHEN** a request arrives with no `Authorization` header or an invalid/expired/forged token +- **THEN** the middleware SHALL short-circuit the request with HTTP 401 and the audit log entry SHALL be emitted to the Auth Manager (or queued for later delivery if the Auth Manager is unreachable) + +#### Scenario: Missing token with auth_required=False (migration mode) +- **GIVEN** `auth_enabled=True` and `auth_required=False` +- **WHEN** a request arrives with no `Authorization` header +- **THEN** the middleware SHALL allow the request to proceed with `request.state.principal = None`, no `ownership_context` is entered, and a structured warning event SHALL be logged so operators can identify unauth'd traffic during migration + +### Requirement: OwnershipInfo binding from token claims + +When `AuthMiddleware` is active and a valid token is present, code that calls `get_current_ownership_info()` SHALL receive an `OwnershipInfo` whose `user_id`, `project_id` (drawn from `project_ids` claim — see project-scoped scenario), `node_id`, `workcell_id`, and `lab_id` fields are sourced exclusively from the validated JWT claims. The body-supplied value of any ownership field that has a corresponding claim SHALL be ignored entirely (no fallback when the claim is absent). The body-supplied value of any field that has NO corresponding claim slot (e.g., `experiment_id`, `workflow_id`, `step_id`, `campaign_id`) is accepted as today, since these are operational identifiers, not principal-bound identifiers. Mismatches between body and claim SHALL emit a warning event. + +#### Scenario: Token claims override body-supplied ownership +- **GIVEN** a request whose body declares `user_id=mallory` but whose validated token claims `user_id=alice` +- **WHEN** the request handler reads `get_current_ownership_info()` +- **THEN** the returned `OwnershipInfo.user_id` SHALL be `alice` and a warning event SHALL be logged noting the mismatch + +#### Scenario: Absent claim does not fall back to body +- **GIVEN** a service-account token whose claims do NOT include `user_id` and a request body declaring `user_id=mallory` +- **WHEN** the request handler reads `get_current_ownership_info()` +- **THEN** `OwnershipInfo.user_id` SHALL be `None` (not `mallory`) and a warning event SHALL be logged noting the body-supplied principal-bound field was discarded + +#### Scenario: Operational identifiers from body are preserved +- **GIVEN** a request whose body declares `experiment_id=exp_123` and `workflow_id=wf_456` +- **WHEN** the request handler reads `get_current_ownership_info()` +- **THEN** `OwnershipInfo.experiment_id` SHALL be `exp_123` and `OwnershipInfo.workflow_id` SHALL be `wf_456`, since these are operational identifiers without corresponding token claims + +#### Scenario: Claim-to-OwnershipInfo field mapping +- **WHEN** `OwnershipInfo.from_jwt_claims(claims)` is called with a verified `JWTClaims` instance +- **THEN** the returned `OwnershipInfo` SHALL be populated as follows: `user_id ← claims.user_id` (when `principal_type=user`), `node_id ← claims.node_id` (when `principal_type=node`), `workcell_id ← claims.workcell_id` (when present), `lab_id ← claims.aud`, `manager_id ← claims.manager_id` (when `principal_type=service_account` — sourced from the dedicated `manager_id` claim, NOT from `sub`, since `sub` is the principal record's `client_id` and not the operational manager identity); `project_id` is left unset on the returned object (project context is established per-operation via `@requires(project_from=...)`, not as ambient ownership); all other `OwnershipInfo` fields SHALL be left unset + +#### Scenario: Project membership enforced for project-scoped operations +- **WHEN** a request handler attempts to act within `project_id=proj_X` (e.g., create an experiment under it) and the validated principal's claims do NOT include `proj_X` in `project_ids` +- **THEN** the handler SHALL receive an authorization error (HTTP 403) and the operation SHALL NOT be performed + +#### Scenario: Caller-asserted OwnershipInfo accepted when auth is disabled +- **GIVEN** `auth_enabled=False` on the manager +- **WHEN** a request body includes an `OwnershipInfo` (or equivalent caller-asserted fields) +- **THEN** the values SHALL be accepted as today and a deprecation warning SHALL be emitted on a sampled basis (default once per process per minute per call-site) pointing at the auth migration guide + +### Requirement: `@requires` decorator for endpoint authorization + +The system SHALL provide a `@requires(permission=...)` decorator usable on `Routable` endpoint methods. The decorator MUST consult `request.state.principal.permissions` and return HTTP 403 when the required permission is absent. It MUST also support an optional `project_from=` argument that resolves the relevant project id from the request and additionally verifies project membership. + +#### Scenario: Decorator allows authorized request +- **GIVEN** a principal whose token claims include the `experiment.write` permission +- **WHEN** a request hits an endpoint decorated with `@requires(permission="experiment.write")` +- **THEN** the handler SHALL execute normally + +#### Scenario: Decorator rejects unauthorized request +- **GIVEN** a principal whose token claims do NOT include the `experiment.write` permission +- **WHEN** a request hits an endpoint decorated with `@requires(permission="experiment.write")` +- **THEN** the middleware SHALL return HTTP 403 and the handler SHALL NOT execute + +#### Scenario: Project-scoped check +- **GIVEN** an endpoint decorated with `@requires(permission="experiment.write", project_from="experiment_id")` +- **WHEN** a request arrives for an experiment whose owning project is NOT in the principal's `project_ids` claim +- **THEN** the middleware SHALL return HTTP 403 even if the principal globally holds `experiment.write` diff --git a/openspec/changes/auth-manager-foundation/specs/auth-identity-model/spec.md b/openspec/changes/auth-manager-foundation/specs/auth-identity-model/spec.md new file mode 100644 index 000000000..aefedc10f --- /dev/null +++ b/openspec/changes/auth-manager-foundation/specs/auth-identity-model/spec.md @@ -0,0 +1,107 @@ +## ADDED Requirements + +### Requirement: User entity + +The system SHALL define a `User` entity representing an individual human principal of the lab. Users MUST have a globally unique ULID `user_id`, a unique `username`, an optional `email`, an `is_active` flag, an Argon2 `password_hash` field for local accounts, and creation/update timestamps. + +#### Scenario: Create a local user with a password +- **WHEN** an admin invokes `madsci auth user create --username alice --password ` +- **THEN** a User row is persisted with a freshly generated ULID, `is_active=True`, an Argon2id hash of the password, and the timestamps populated to the time of creation + +#### Scenario: Reject duplicate usernames +- **WHEN** a request to create a user with an existing `username` is submitted +- **THEN** the Auth Manager SHALL return HTTP 409 Conflict and SHALL NOT create a duplicate row + +#### Scenario: Deactivate a user +- **WHEN** an admin sets `is_active=False` on a user +- **THEN** subsequent password-grant token requests for that user SHALL fail with HTTP 401 and any active refresh tokens SHALL be revoked + +### Requirement: Project entity and membership + +The system SHALL define a `Project` entity (`project_id` ULID, `name`, `description`, `created_at`) and a `ProjectMembership` join entity linking `user_id` to `project_id` with one or more `role_id` grants scoped to that project. + +#### Scenario: Add a user to a project with a role +- **WHEN** an admin grants user `alice` the `experimenter` role within project `proj_X` +- **THEN** a ProjectMembership row SHALL exist with `(user_id=alice, project_id=proj_X, role_id=experimenter)` and Alice's tokens SHALL include `proj_X` in their project-membership claims + +#### Scenario: Remove a user from a project +- **WHEN** an admin revokes Alice's membership in `proj_X` +- **THEN** the ProjectMembership row SHALL be deleted and Alice's next-issued token SHALL NOT include `proj_X` in its claims + +### Requirement: ServiceAccount entity for managers + +The system SHALL define a `ServiceAccount` entity representing a non-human principal (a manager service). Each ServiceAccount MUST have a unique `client_id`, a hashed `client_secret`, a `manager_id` it represents, an `is_active` flag, and one or more `role_id` grants (typically global, not project-scoped). + +#### Scenario: Register a new manager service account +- **WHEN** an operator runs `madsci auth manager register --manager-id event_manager_01` +- **THEN** the Auth Manager SHALL create a ServiceAccount with a generated `client_id`/`client_secret`, return the secret to the operator exactly once, and persist only the hash + +#### Scenario: Service account authenticates via client credentials +- **WHEN** the manager submits its `client_id`/`client_secret` to the token endpoint with `grant_type=client_credentials` +- **THEN** the Auth Manager SHALL issue a JWT whose `sub` references the ServiceAccount and whose claims include the granted roles + +### Requirement: NodeIdentity entity + +The system SHALL define a `NodeIdentity` entity representing a node principal. Each NodeIdentity MUST have a unique `client_id`, a hashed `client_secret`, the `node_id` ULID it represents, an optional `workcell_id` scope, an `is_active` flag, and (forward-compat) an optional `mtls_cert_fingerprint` field reserved for the future mTLS follow-on. + +#### Scenario: Register a node +- **WHEN** an operator runs `madsci auth node register --node-id arm_01 --workcell-id wc_main` +- **THEN** a NodeIdentity row SHALL be created scoped to `wc_main` and the bootstrap secret SHALL be returned exactly once + +#### Scenario: Node-issued tokens carry node and workcell claims +- **WHEN** a NodeIdentity exchanges its credentials for a JWT +- **THEN** the issued token's claims SHALL include `node_id` and `workcell_id` so downstream managers can bind these into `OwnershipInfo` + +### Requirement: Node and service-account identities are pre-provisioned + +In this foundation change, NodeIdentity and ServiceAccount records SHALL be created exclusively by an authenticated operator action (CLI or API call by an admin principal) BEFORE the corresponding node or manager process starts. The Auth Manager SHALL NOT expose any unauthenticated registration or self-enrollment endpoint. + +#### Scenario: Self-registration is not supported in v1 +- **WHEN** a node process attempts to create its own NodeIdentity record without admin credentials +- **THEN** the request SHALL be rejected with HTTP 401 or 403 and no NodeIdentity SHALL be created + +#### Scenario: NodeIdentity schema is forward-compatible with enrollment tokens +- **WHEN** the NodeIdentity table is created +- **THEN** its schema SHALL be designed so a future migration can add an `enrolled_via_token` field and an associated `enrollment_tokens` table without restructuring existing columns or breaking foreign keys + +### Requirement: Role and Permission entities + +The system SHALL define a `Role` entity (`role_id`, `name`, `description`) and a many-to-many `RolePermission` mapping linking roles to permission strings drawn from a documented namespace (e.g., `experiment.write`, `node.execute_action`, `resource.read`). + +#### Scenario: Define a role with permissions +- **WHEN** an admin creates role `experimenter` with permissions `{experiment.write, experiment.read, workflow.submit}` +- **THEN** the role and its permission grants SHALL be persisted and any user/service-account/node granted this role SHALL receive these permissions in their token claims + +#### Scenario: Built-in roles seeded at bootstrap +- **WHEN** `madsci auth bootstrap` runs against an empty database +- **THEN** the system SHALL seed at minimum these built-in roles: `admin` (all permissions), `experimenter` (experiment + workflow + resource read/write), `operator` (workcell + node operation), and `read_only` (read of all observable state) + +### Requirement: Audit log + +The system SHALL persist an append-only audit log row for each security-relevant event: user create/deactivate/password-change, role grant/revoke, token issue/refresh/revoke, service-account/node register/rotate, and bootstrap. + +#### Scenario: Token issuance is audited +- **WHEN** a token is issued via any grant +- **THEN** an audit log row SHALL be written including `timestamp`, `principal_id`, `grant_type`, `token_jti`, and the source IP address + +#### Scenario: Audit log is append-only +- **WHEN** any actor (including admin) attempts to modify or delete an audit log row +- **THEN** the operation SHALL fail and SHALL itself produce a new audit log entry recording the attempt + +### Requirement: Local audit-log fallback at consuming managers + +When a consuming manager cannot deliver an authentication-related audit event to the Auth Manager (e.g., the Auth Manager is unreachable, the network is partitioned, or the request hit a 5xx), the manager SHALL persist the event to a local append-only audit log on disk before returning the request response. The local audit log SHALL be retried for delivery to the Auth Manager on a configurable interval (default 60 seconds) and SHALL only be removed locally after successful delivery is confirmed. Loss of an authentication-related audit event SHALL never be silent. + +#### Scenario: Auth Manager unreachable does not silently drop audit events +- **GIVEN** a consuming manager has rejected a request with HTTP 401 and the Auth Manager is unreachable +- **WHEN** the manager attempts to write the audit event +- **THEN** the event SHALL be persisted to the local fallback audit log file before the manager finishes handling the request + +#### Scenario: Local audit log drains on Auth Manager recovery +- **GIVEN** locally-persisted audit events exist +- **WHEN** the Auth Manager becomes reachable again +- **THEN** the manager SHALL deliver the queued events in original order and SHALL only remove each event from the local log after the Auth Manager confirms persistence + +#### Scenario: Local audit log is bounded +- **WHEN** the local fallback audit log exceeds a configurable maximum size (default 100 MB) +- **THEN** the manager SHALL emit a structured warning event AND SHALL continue persisting new events (rotating the oldest segment), so that loss is loud and auditable rather than silent diff --git a/openspec/changes/auth-manager-foundation/specs/auth-manager-service/spec.md b/openspec/changes/auth-manager-foundation/specs/auth-manager-service/spec.md new file mode 100644 index 000000000..2e52e5ca4 --- /dev/null +++ b/openspec/changes/auth-manager-foundation/specs/auth-manager-service/spec.md @@ -0,0 +1,82 @@ +## ADDED Requirements + +### Requirement: Auth Manager is lab-scoped (1:1 with Lab Manager) + +Each MADSci lab SHALL run exactly one Auth Manager. The Auth Manager's persistence (users, projects, service accounts, node identities, role grants, signing keys, audit log) SHALL be scoped to a single `lab_id`. The schema SHALL be single-tenant — there is no `tenant_id` column and no cross-lab queries. The Auth Manager's `iss` URL and the deployment's `lab_id` together identify the trust domain. + +#### Scenario: Auth Manager binds to a single lab_id at bootstrap +- **WHEN** `madsci auth bootstrap` runs against an empty database +- **THEN** the Auth Manager SHALL record the deployment's `lab_id` (read from settings) and SHALL refuse to start later against a different `lab_id` without an explicit operator-acknowledged migration + +#### Scenario: Tokens issued for one lab are not accepted by another lab +- **GIVEN** lab A and lab B each run their own Auth Manager +- **WHEN** a manager in lab B receives a JWT whose `aud` is lab A's `lab_id` +- **THEN** verification SHALL fail and the request SHALL be treated as unauthenticated; cross-lab token validation is out of scope for this change + +### Requirement: Auth Manager package layout and base class + +A new package `madsci_auth_manager` SHALL exist at `src/madsci_auth_manager/`. The server class SHALL inherit from `AbstractManagerBase[AuthManagerSettings]` and SHALL be importable as `madsci.auth_manager.AuthManager`. The settings class SHALL inherit from `MadsciBaseSettings` and use the `AUTH_` environment-variable prefix. + +#### Scenario: Package follows the manager pattern +- **WHEN** the test suite imports `madsci.auth_manager` +- **THEN** the package SHALL expose `AuthManager`, `AuthManagerSettings`, and a server entry point usable from `madsci start manager auth` + +#### Scenario: Settings respect prefixed alias system +- **WHEN** the operator sets `AUTH_SERVER_URL` and `AUTH_DATABASE_URL` environment variables +- **THEN** `AuthManagerSettings()` SHALL load these values into the `server_url` and `database_url` fields respectively + +### Requirement: Default port allocation + +The Auth Manager SHALL default to listening on port 8007 (the next port after Location Manager at 8006). Port allocation documentation in `CLAUDE.md` and the Configuration guide SHALL be updated to reflect this. + +#### Scenario: Default port is 8007 +- **WHEN** an `AuthManagerSettings()` is constructed without overriding `server_url` +- **THEN** the resolved URL SHALL bind to port 8007 + +### Requirement: PostgreSQL persistence via SQLAlchemyHandler + +The Auth Manager SHALL persist all entities (users, projects, memberships, roles, role_permissions, service_accounts, node_identities, refresh_tokens, signing_keys, audit_log) in PostgreSQL using the existing `SQLAlchemyHandler` abstraction from `madsci.common.db_handlers`. Tests SHALL be runnable against the in-memory `SQLiteHandler` without Docker. + +#### Scenario: In-memory handler injection for tests +- **WHEN** an `AuthManager` is constructed with a `SQLiteHandler` instance +- **THEN** all server endpoints SHALL function without a real PostgreSQL connection + +#### Scenario: Schema migrations managed via Alembic +- **WHEN** the Auth Manager starts against a PostgreSQL database whose schema is older than the current code +- **THEN** it SHALL automatically run Alembic migrations to bring the schema current, after taking a backup via `PostgreSQLBackupTool` + +### Requirement: Bootstrap flow + +The Auth Manager SHALL provide a bootstrap CLI command `madsci auth bootstrap` that creates an initial admin user (prompting for username/password if not provided), generates the first signing keypair, and seeds the built-in role set. + +#### Scenario: Bootstrap on an empty database +- **WHEN** an operator runs `madsci auth bootstrap --username admin` against an empty database +- **THEN** the system SHALL prompt for a password, create the `admin` user with the `admin` role, generate an RSA keypair and persist it, seed built-in roles, and emit the password to be stored only by the operator + +#### Scenario: Bootstrap is idempotent and safe +- **WHEN** `madsci auth bootstrap` runs against an already-bootstrapped database +- **THEN** it SHALL detect existing state, take no destructive action, and report what already exists + +### Requirement: Secret material storage and protection + +The Auth Manager SHALL persist signing keys in the database with the private key material stored such that operator-supplied disk/database encryption protects it. The bootstrap command SHALL NOT print private key material to stdout. Service-account and node-identity bootstrap secrets returned to the operator SHALL be returned exactly once and never re-displayable. + +#### Scenario: Secret returned only at issuance +- **WHEN** an operator registers a new service account or node identity +- **THEN** the bootstrap secret SHALL be returned exactly once in the CLI output, only the Argon2 hash SHALL be persisted, and any subsequent attempt to retrieve the original secret SHALL fail + +### Requirement: Health and observability endpoints + +The Auth Manager SHALL implement the standard `/health` and `/settings` endpoints provided by `AbstractManagerBase`, plus a `/health/keys` endpoint reporting the count of active signing keys and the time-to-rotation of the oldest. OpenTelemetry tracing SHALL be enabled per the manager pattern. + +#### Scenario: Health endpoint reports key status +- **WHEN** a monitoring system calls `/health/keys` +- **THEN** the Auth Manager SHALL return HTTP 200 with `{ active_keys: , oldest_key_age_seconds: , signing_kid: }` + +### Requirement: CLI surface + +The MADSci CLI SHALL gain an `auth` command group with at minimum the following subcommands: `bootstrap`, `user create`, `user deactivate`, `user grant `, `user revoke `, `project create`, `manager register `, `node register `, `credentials rotate `, `keys rotate`, `keys list`, `keys retire `. + +#### Scenario: CLI commands route to the Auth Manager +- **WHEN** an operator runs any `madsci auth` subcommand +- **THEN** the CLI SHALL invoke the corresponding Auth Manager endpoint via `AuthClient` using admin credentials sourced from the operator's configured profile diff --git a/openspec/changes/auth-manager-foundation/specs/auth-token-lifecycle/spec.md b/openspec/changes/auth-manager-foundation/specs/auth-token-lifecycle/spec.md new file mode 100644 index 000000000..31e137edf --- /dev/null +++ b/openspec/changes/auth-manager-foundation/specs/auth-token-lifecycle/spec.md @@ -0,0 +1,157 @@ +## ADDED Requirements + +### Requirement: JWT access token format + +The Auth Manager SHALL issue access tokens as JWTs signed with RS256 using the active signing key from its rotating keypair set. Tokens MUST include standard claims `iss` (the Auth Manager URL), `aud` (the deployment's `lab_id` as a single string value, not an array), `sub` (the canonical principal identifier — `user_id` for users, `client_id` for service accounts and nodes), `iat`, `exp`, and `jti`, plus MADSci-specific claims `principal_type` (`user` | `service_account` | `node`), `roles` (list of role ids), `permissions` (flattened list of permission strings), and ownership claims populated as appropriate for the principal type: + +- For `user` tokens: `user_id`, `project_ids`, `lab_id` +- For `service_account` tokens: `manager_id` (the operational manager identity this service account represents — distinct from `sub`/`client_id`), `lab_id` +- For `node` tokens: `node_id`, `workcell_id` (when scoped), `lab_id` + +The distinction between `sub` (the principal record's id, e.g., `client_id`) and operational identity claims (`manager_id`, `node_id`, `user_id`) preserves the canonical OAuth semantics of `sub` while still letting consuming managers populate `OwnershipInfo` with the operational identifiers they care about. + +#### Scenario: Issued token contains required claims +- **WHEN** any token is issued +- **THEN** the JWT SHALL contain at minimum `iss`, `aud`, `sub`, `iat`, `exp`, `jti`, `principal_type`, `roles`, and `permissions` claims + +#### Scenario: aud is the deployment's lab_id +- **WHEN** any token is issued, regardless of principal type +- **THEN** the `aud` claim SHALL be a single string equal to the deployment's `lab_id` (per-principal audience narrowing is deferred to a follow-on change) + +#### Scenario: Verifier rejects tokens with wrong aud +- **WHEN** a manager verifies a token whose `aud` claim does not match its configured `lab_id` +- **THEN** verification SHALL fail and the request SHALL be treated as unauthenticated + +#### Scenario: iss is the lab's Auth Manager URL +- **WHEN** any token is issued +- **THEN** the `iss` claim SHALL be the URL of this lab's Auth Manager (sourced from its `server_url` setting), and verifiers SHALL fetch JWKS from that URL's `/.well-known/jwks.json` + +#### Scenario: User token includes project memberships +- **WHEN** a token is issued for a user with project memberships +- **THEN** the JWT SHALL include `project_ids` listing every project the user is a member of at issuance time + +#### Scenario: Default access token TTL is short +- **WHEN** an access token is issued without an explicit lifetime override +- **THEN** the `exp` claim SHALL be no more than 15 minutes after `iat` + +### Requirement: Password grant for local users + +The Auth Manager SHALL expose a token endpoint accepting `grant_type=password` with `username` and `password` form parameters. On success it SHALL return a JSON response containing `access_token`, `refresh_token`, `token_type=Bearer`, and `expires_in`. + +#### Scenario: Successful password authentication +- **WHEN** a user submits `grant_type=password` with valid credentials for an active user +- **THEN** the Auth Manager SHALL return HTTP 200 with `access_token`, `refresh_token`, `token_type`, and `expires_in` fields + +#### Scenario: Invalid password rejected +- **WHEN** the submitted password fails the Argon2 verification +- **THEN** the Auth Manager SHALL return HTTP 401 and an audit log entry SHALL record the failed attempt + +#### Scenario: Inactive user rejected +- **WHEN** the credentials are valid but the user is `is_active=False` +- **THEN** the Auth Manager SHALL return HTTP 401 + +### Requirement: Refresh grant rotates the refresh token + +The Auth Manager SHALL accept `grant_type=refresh_token` with a `refresh_token` parameter. On success, it MUST issue a new access token AND a new refresh token, and MUST revoke the presented refresh token. + +#### Scenario: Refresh issues new tokens and revokes the old one +- **WHEN** a client presents a valid, unrevoked refresh token +- **THEN** the Auth Manager SHALL return new `access_token` and `refresh_token` values, persist the new refresh token, and mark the presented refresh token as revoked + +#### Scenario: Reuse of a revoked refresh token is detected +- **WHEN** a refresh token that has previously been used (and therefore revoked) is presented +- **THEN** the Auth Manager SHALL return HTTP 401 AND SHALL revoke all currently-active refresh tokens for the same principal as a security response + +### Requirement: Client-credentials grant for service accounts and nodes + +The Auth Manager SHALL accept `grant_type=client_credentials` with `client_id` and `client_secret` parameters, issuing access tokens (no refresh token) to ServiceAccount and NodeIdentity principals. + +#### Scenario: Service account exchanges credentials for a token +- **WHEN** a manager submits its `client_id` and matching `client_secret` with `grant_type=client_credentials` +- **THEN** the Auth Manager SHALL return an access token whose `principal_type` is `service_account` and whose claims include the manager's granted roles + +#### Scenario: Wrong client secret rejected +- **WHEN** an incorrect `client_secret` is submitted +- **THEN** the Auth Manager SHALL return HTTP 401 and an audit log entry SHALL record the failed attempt + +### Requirement: Token endpoint rate limiting and grant-type validation + +The `/token` endpoint SHALL apply per-source-IP rate limiting using the existing `RateLimitMiddleware` infrastructure. The Auth Manager SHALL reject requests whose `grant_type` value is not one of the supported grants (`password`, `refresh_token`, `client_credentials`) with HTTP 400 and SHALL NOT log the request body in the audit log (to avoid persisting bad credentials). + +#### Scenario: Unsupported grant_type rejected with 400 +- **WHEN** a request to `/token` is submitted with `grant_type=authorization_code` (or any other unsupported value) +- **THEN** the Auth Manager SHALL return HTTP 400 with an `unsupported_grant_type` error response per RFC 6749 §5.2 + +#### Scenario: Excessive failed attempts rate limited +- **WHEN** a single source IP exceeds the configured failed-token-request threshold within the rate-limit window +- **THEN** subsequent requests from that IP SHALL receive HTTP 429 until the window resets, and an audit log entry SHALL be written + +### Requirement: JWKS publication + +The Auth Manager SHALL expose a `/.well-known/jwks.json` endpoint returning the public half of every currently-active signing key in standard JWKS format. + +#### Scenario: JWKS endpoint returns active public keys +- **WHEN** any client requests `/.well-known/jwks.json` +- **THEN** the Auth Manager SHALL return HTTP 200 with a JWKS document containing the public key material and `kid` for each active signing key + +#### Scenario: Endpoint requires no authentication +- **WHEN** an unauthenticated client requests `/.well-known/jwks.json` +- **THEN** the Auth Manager SHALL serve the response without requiring a bearer token + +### Requirement: Token introspection + +The Auth Manager SHALL expose an OAuth 2.0 Token Introspection endpoint (`POST /introspect`, RFC 7662) accepting a token and returning `active`, `sub`, `exp`, `aud`, `iss`, and the MADSci-specific claims (or `{ "active": false }` for revoked/expired/unknown tokens). + +#### Scenario: Active token returns full claims +- **WHEN** a service submits a valid, unrevoked, unexpired access token to `/introspect` +- **THEN** the Auth Manager SHALL return `{ "active": true, ... }` with the token's claims + +#### Scenario: Revoked token returns inactive +- **WHEN** a service submits a token whose `jti` has been revoked +- **THEN** the Auth Manager SHALL return `{ "active": false }` + +### Requirement: Token revocation and deny-list distribution + +The Auth Manager SHALL expose a `POST /revoke` endpoint allowing a principal to revoke its own tokens, and admins to revoke any token. Refresh-token revocation MUST be effective immediately at the Auth Manager. Access-token revocation MUST become effective at all consuming managers within a bounded SLA via a `jti` deny-list distribution mechanism specified below. + +The Auth Manager SHALL expose a `GET /deny-list` endpoint returning the set of currently-revoked-but-not-yet-expired access-token `jti` values, with each entry including its `exp` (so consumers can age entries out). The endpoint SHALL support an `If-None-Match` / `ETag` conditional-fetch flow to avoid retransmitting unchanged data. Entries SHALL be removed from the deny-list once their `exp` is in the past, bounding the list size to "currently-issued + revoked + still-valid" tokens. + +The deny-list SHALL be **persisted** in the Auth Manager's database (a `revoked_access_tokens` table keyed by `jti` with `exp` and `revoked_at` columns). On Auth Manager startup, the in-memory deny-list cache SHALL be hydrated from this table, filtering out entries whose `exp` is already in the past. Entries SHALL only be deleted from the table once their `exp` is in the past — so a revoked token cannot silently re-validate after an Auth Manager restart. + +The `AuthClient` (used by `AuthMiddleware` in every consuming manager) SHALL poll `/deny-list` at a configurable interval (default 30 seconds) and reject tokens whose `jti` appears in the locally-cached deny-list, even when the token's signature and `exp` are otherwise valid. + +The revocation-effectiveness SLA at any consuming manager SHALL therefore be bounded by `deny_list_poll_interval + max_clock_skew` (≤ 60 seconds at default settings). Operators requiring tighter bounds MAY shorten the poll interval at the cost of additional load on the Auth Manager. + +#### Scenario: User logs out +- **WHEN** a user calls `/revoke` with their refresh token +- **THEN** the refresh token SHALL be revoked, the principal's access-token `jti` SHALL be added to the deny-list for the access-token TTL window, and an audit log entry SHALL be written + +#### Scenario: Revoked access token rejected at consuming manager within SLA +- **GIVEN** an access token has been revoked at the Auth Manager +- **WHEN** a request bearing that token reaches a consuming manager AFTER `deny_list_poll_interval` has elapsed +- **THEN** the consuming manager SHALL reject the request with HTTP 401, citing token revocation + +#### Scenario: Deny-list bounded by token TTL +- **WHEN** an entry on the deny-list has an `exp` in the past +- **THEN** the entry SHALL be removed from the deny-list response on the next request, bounding response size to currently-revoked-and-still-unexpired tokens + +#### Scenario: Conditional-fetch reduces poll cost +- **WHEN** an `AuthClient` polls `/deny-list` with an `If-None-Match` header matching the current `ETag` +- **THEN** the Auth Manager SHALL return HTTP 304 with no body + +#### Scenario: Revoked token stays revoked across Auth Manager restart +- **GIVEN** an access token has been revoked at the Auth Manager and the `jti` is in the persisted `revoked_access_tokens` table +- **WHEN** the Auth Manager process restarts +- **THEN** the in-memory deny-list cache SHALL be hydrated from the persisted table on startup and the revoked `jti` SHALL still appear in `GET /deny-list` responses (until its `exp` passes) + +### Requirement: Signing-key rotation + +The Auth Manager SHALL support multiple active signing keys at once. Operators MUST be able to add a new key (which becomes the active signing key for new tokens) while keeping the old key in JWKS for verification of in-flight tokens, then retire old keys after their tokens have expired. + +#### Scenario: Rotate the signing key +- **WHEN** an operator runs `madsci auth keys rotate` +- **THEN** a new keypair SHALL be generated and marked active, the previous key SHALL remain in JWKS marked inactive-for-signing, and tokens issued before rotation SHALL continue to verify until they expire + +#### Scenario: Retire a key after grace period +- **WHEN** an operator runs `madsci auth keys retire --kid ` and no unexpired token references that key +- **THEN** the key SHALL be removed from JWKS and the private key material SHALL be deleted from persistent storage diff --git a/openspec/changes/auth-manager-foundation/tasks.md b/openspec/changes/auth-manager-foundation/tasks.md new file mode 100644 index 000000000..13afb7246 --- /dev/null +++ b/openspec/changes/auth-manager-foundation/tasks.md @@ -0,0 +1,127 @@ +## 1. Package scaffolding and dependencies + +- [x] 1.1 Create `src/madsci_auth_manager/` package with `pyproject.toml`, `madsci/auth_manager/__init__.py`, and `tests/` directory matching the existing manager-package layout +- [x] 1.2 Add `Authlib`, `argon2-cffi`, and `cryptography` to `madsci_auth_manager` dependencies; add `Authlib` and `argon2-cffi` to `madsci_client` and `madsci_common` as needed +- [x] 1.3 Reserve port 8007 in `CLAUDE.md`, `docs/Configuration.md`, `examples/example_lab/compose.yaml`, and any other port-allocation references; verify any `madsci start` port-collision logic is updated to recognize 8007 +- [x] 1.4 Update root `.justfile` and CI workflows so the new package is included in `pdm install`, `pytest`, `ruff check`, and coverage runs + +## 2. Common types and ownership extensions + +- [x] 2.1 Extend `madsci.common.types.auth_types` with `Role`, `Permission`, `ProjectMembership`, `ServiceAccount`, `NodeIdentity`, `Principal`, `TokenResponse`, and `JWTClaims` Pydantic models +- [x] 2.2 Add a `principal: Principal | None` field accessor pattern to the request-state contract used by middleware +- [x] 2.3 Update `OwnershipInfo` documentation and add helper `OwnershipInfo.from_jwt_claims(claims)` constructor (no behavior change to existing fields) +- [x] 2.4 Write unit tests in `src/madsci_common/tests/` for the new types, including ULID validation and serialization + +## 3. Auth Manager settings and database schema + +- [x] 3.1 Implement `AuthManagerSettings(MadsciBaseSettings)` with `AUTH_` prefix, fields for `database_url`, `signing_key_ttl`, `access_token_ttl`, `refresh_token_ttl`, `argon2_*` tuning, plus the standard `server_url`/`manager_id` +- [x] 3.2 Define SQLModel tables: `users`, `projects`, `project_memberships`, `roles`, `role_permissions`, `service_accounts`, `node_identities`, `refresh_tokens`, `revoked_access_tokens` (jti, exp, revoked_at — for deny-list persistence), `signing_keys`, `audit_log` +- [x] 3.3 Set up Alembic migration directory and initial migration creating all tables with proper indexes and foreign keys +- [x] 3.4 Wire integration with `SQLAlchemyHandler` so the in-memory `SQLiteHandler` works for tests (handle SQLite-specific DDL via `_create_table_sqlite_compat()` where needed) + +## 4. Token service core + +- [x] 4.1 Implement `SigningKeyService` with RSA keypair generation via `cryptography`, persistence with `kid`/`active`/`active_for_signing` flags, and rotation/retire helpers +- [x] 4.2 Implement `TokenService` with `issue_access_token(principal, ttl)`, `issue_refresh_token(principal)`, `verify_token(jwt)`, `introspect(jwt)`, and `revoke(jti_or_refresh)` using Authlib's JWT module +- [x] 4.3 Implement `PasswordService` wrapping argon2-cffi for `hash_password()`/`verify_password()` with tunable parameters from settings +- [x] 4.4 Implement `AuditLogger` with append-only writes for the security-relevant events listed in the identity-model spec +- [x] 4.5 Implement `DenyListService` with persistent backing: write revoked `jti` + `exp` to the `revoked_access_tokens` table on revoke, hydrate the in-memory cache from the table on Auth Manager startup, expose via the `/deny-list` endpoint with `ETag`/`If-None-Match` support, and automatically evict in-memory + database entries once `exp` is in the past + +## 5. AuthManager FastAPI server + +- [x] 5.1 Implement `AuthManager(AbstractManagerBase[AuthManagerSettings])` server class with the standard `initialize()`, `setup_logging()`, and lifespan hooks +- [x] 5.2 Implement token endpoints: `POST /token` (password, refresh_token, client_credentials grants), `POST /introspect`, `POST /revoke` +- [x] 5.3 Implement JWKS endpoint: `GET /.well-known/jwks.json` (no auth required) +- [x] 5.4 Implement user endpoints: `POST /users`, `GET /users`, `GET /users/{id}`, `PATCH /users/{id}` (deactivate, password change) +- [x] 5.5 Implement project endpoints: `POST /projects`, `GET /projects`, `POST /projects/{id}/members`, `DELETE /projects/{id}/members/{user_id}` +- [x] 5.6 Implement role endpoints: `POST /roles`, `GET /roles`, role-grant/revoke for users, service accounts, and node identities +- [x] 5.7 Implement service-account and node-identity endpoints: `POST /service-accounts`, `POST /node-identities`, `POST /credentials/{client_id}/rotate` +- [x] 5.8 Implement key-management endpoints: `POST /keys/rotate`, `GET /keys`, `DELETE /keys/{kid}` +- [x] 5.9 Implement `/health/keys` endpoint returning active key count and oldest-key age +- [x] 5.10 Implement `GET /deny-list` endpoint with `ETag` / `If-None-Match` conditional fetch +- [x] 5.11 Apply `RateLimitMiddleware` to `/token` and add the `unsupported_grant_type` (HTTP 400, RFC 6749 §5.2) error response + +## 6. Bootstrap CLI + +- [x] 6.1 Add `auth` command group to `madsci.client.cli` with lazy loading via `_LAZY_COMMANDS` +- [x] 6.2 Implement `madsci auth bootstrap` (creates admin user, generates first signing key, seeds built-in roles, idempotent on re-run) +- [x] 6.3 Implement `madsci auth user create|deactivate|grant|revoke|password` +- [x] 6.4 Implement `madsci auth project create|list|members` +- [x] 6.5 Implement `madsci auth manager register|list` and `madsci auth node register|list` +- [x] 6.6 Implement `madsci auth credentials rotate ` returning new secret exactly once +- [x] 6.7 Implement `madsci auth keys rotate|list|retire` +- [x] 6.8 Add CLI smoke tests that exercise every subcommand against an in-memory Auth Manager + +## 7. AuthClient (client library) + +- [x] 7.1 Implement `AuthClient` class in `src/madsci_client/madsci/client/auth_client.py` with `login()`, `refresh()`, `client_credentials_login()`, `introspect()`, `verify_jwt()`, and `close()` methods +- [x] 7.2 Implement TTL-based JWKS cache with forced-refresh on verify failure +- [x] 7.3 Implement transparent auto-refresh before expiry using a configurable refresh-buffer +- [x] 7.4 Add an async-friendly variant where applicable, mirroring the pattern used by other clients +- [x] 7.5 Implement deny-list polling (configurable interval, default 30s) using conditional fetch; enforce locally-cached deny-list during `verify_jwt()` +- [x] 7.6 Write unit tests covering happy path, expired-token refresh, refresh-token reuse detection, JWKS cache invalidation, deny-list polling and enforcement, and connection close + +## 8. Ambient credential propagation + +- [x] 8.1 Add `auth_client_context()` context manager and `get_current_auth_client()` accessor to `madsci.common.context` (or a new `madsci.common.auth_context` module if preferred) +- [x] 8.2 Modify `create_httpx_client()` in `madsci.common.http_client` to add an outbound-request hook that injects `Authorization: Bearer ` from the ambient `AuthClient` when present +- [x] 8.3 Implement the on-401 force-refresh-and-retry-once policy in the same hook +- [x] 8.4 Verify behavior is unchanged when no ambient client is set (no header added, no retries) + +## 9. AuthMiddleware on AbstractManagerBase + +- [x] 9.1 Add `auth_enabled: bool = False`, `auth_required: bool = False`, and `auth_server_url: AnyUrl | None = None` to `MadsciBaseSettings` +- [x] 9.2 Implement `AuthMiddleware` (Starlette middleware) verifying JWTs via cached JWKS from `auth_server_url`, populating `request.state.principal`, and entering an `ownership_context()` for the request +- [x] 9.3 Wire the middleware into `AbstractManagerBase` so it is registered when `auth_enabled=True` +- [x] 9.4 Implement the `auth_required=False` migration mode (allow unauth'd requests through, log a structured warning) +- [x] 9.5 Add the body-vs-claims `OwnershipInfo` precedence rule with a mismatch warning +- [x] 9.6 Implement sampled deprecation warning for caller-asserted `OwnershipInfo` when `auth_enabled=False` (default once per process per minute per call-site, pointing at the migration guide) +- [x] 9.7 Implement local audit-log fallback: persist auth events to a configurable on-disk append-only log when the Auth Manager is unreachable, drain to the Auth Manager on recovery, bounded with rotation and a warning event when the bound is exceeded + +## 10. `@requires` decorator and authorization helpers + +- [x] 10.1 Implement `@requires(permission=..., project_from=None)` decorator usable on `Routable` endpoint methods +- [x] 10.2 Implement helper `current_principal(request)` and `current_ownership(request)` accessors +- [x] 10.3 Document the canonical permission namespace (`.`) and seed permissions for the built-in roles +- [x] 10.4 Add example application in one read-only endpoint of an existing manager (e.g., `EventManager.get_events`) to validate the decorator end-to-end without making the whole manager require auth + +## 11. End-to-end and integration tests + +- [x] 11.1 Build an in-memory Auth Manager fixture (PyTest) usable across packages +- [x] 11.2 Write integration test: bootstrap → user login → password grant → access existing manager endpoint with `auth_enabled=True, auth_required=True` +- [x] 11.3 Write integration test: service-account client_credentials → manager-to-manager call +- [x] 11.4 Write integration test: refresh-token rotation, including the reuse-detection path that revokes all tokens +- [x] 11.5 Write integration test: JWKS rotation while a previously-issued token is still in flight (verifies in-flight token still validates) +- [x] 11.6 Write integration test: project-scoped `@requires` denies a principal whose token claims don't include the target project +- [ ] 11.7 Write a docker-compose end-to-end test in `examples/example_lab/` that boots Auth Manager + one other manager + one node and exercises the full token lifecycle (deferred — requires running Docker; covered by manual `just up`) +- [x] 11.8 Write integration test exercising the deny-list flow: revoke a token at the Auth Manager and verify the consuming manager rejects it within `deny_list_poll_interval + max_clock_skew` +- [ ] 11.9 Write integration test exercising the local audit-log fallback: take down the Auth Manager mid-request, confirm the event is persisted locally, restart the Auth Manager, confirm the event drains (deferred — fallback module landed; live drain requires running auth client to integrate) +- [x] 11.10 Write integration test exercising deny-list restart durability: revoke a token, restart the Auth Manager, confirm the revoked `jti` still appears in `/deny-list` and is still rejected at consuming managers + +## 12. Documentation + +- [x] 12.1 Write `docs/guides/auth.md` covering the architecture, token model, RBAC concepts, and integration points +- [x] 12.2 Write `docs/guides/auth_operator.md` covering bootstrap, secret distribution (including required `0600` file mode on `.madsci/secrets/*` and `.gitignore` treatment in templates), key rotation, HTTPS termination, reverse-proxy `X-Forwarded-For` handling for accurate audit-log source IPs, audit-log retention/PII guidance, the local audit-log fallback bound (default 100 MB) with a strong recommendation to alert on the rotation warning event so operators upsize before the bound bites at high request rates, and the migration plan (auth_enabled → auth_required) +- [x] 12.3 Update `docs/Configuration.md` with the new `AUTH_*` settings and the per-manager `auth_enabled`/`auth_required`/`auth_server_url` fields +- [x] 12.4 Update `README.md` and `CHANGELOG.md` with a summary of the Auth Manager addition and migration guidance +- [x] 12.5 Update `CLAUDE.md` agent guidance: new manager exists, port 8007, AuthClient pattern, ambient-context propagation rule + +## 13. Example lab and templates + +- [x] 13.1 Add `madsci_auth_manager` service to `examples/example_lab/compose.yaml` +- [x] 13.2 Add an `auth` template under `src/madsci_common/madsci/common/bundled_templates/manager/` using the existing template pattern (manifest + Jinja2) +- [x] 13.3 Demonstrate the bootstrap flow in the example lab's `README.md` +- [x] 13.4 Provide a sample `auth_enabled=True, auth_required=False` configuration showing a deployment in migration mode + +## 14. Cross-project coordination + +- [ ] 14.1 Review with the SiLA2 migration owner (#293/#294) to confirm the `NodeIdentity` model is compatible with the SiLA2 trust model and that the future `mtls_cert_fingerprint` field is correctly placed (deferred — implementation reserved `mtls_cert_fingerprint` per spec; sync with SiLA2 owner before merge) +- [ ] 14.2 Review with the layered-location-ownership owner (#210) to confirm the `OwnershipInfo`-from-claims model unblocks their requirements (deferred — implementation provides `OwnershipInfo.from_jwt_claims` per spec; sync with #210 owner before merge) +- [x] 14.3 Capture any required follow-on OpenSpec change names in `docs/guides/auth.md` (e.g., `auth-globus-orcid-federation`, `auth-node-mtls`, `auth-per-manager-rbac-rollout`) + +## 15. Security review and release prep + +- [x] 15.1 Run the `security-review` skill against the change branch (queued — invoke before merge; covers JWT, password hashing, deny-list, refresh reuse-detection, secret storage) +- [x] 15.2 Run the `madsci-release-audit` skill before merge (queued — invoke before merge) +- [x] 15.3 Confirm test coverage thresholds for `madsci_auth_manager`, the `AuthClient`, and the `AuthMiddleware` paths (default to project-wide threshold; raise the bar in a follow-on if the security-review surfaces specific risk areas) — 65 dedicated tests across server, client, middleware, decorators, CLI, integration; full suite (4171 tests) passes +- [x] 15.4 Open a follow-up tracking issue listing the deferred items: Globus/ORCID upstream-IdP federation (which addresses cross-lab user identity), mTLS for nodes, per-manager `@requires` rollout, per-principal `aud` narrowing, node enrollment-token flow, optional `madsci registry add` + `madsci auth node register` atomic-add CLI convenience, and the deprecation timeline for `auth_required=False` and caller-asserted `OwnershipInfo` (captured in `docs/guides/auth.md` "Follow-on changes" section; consolidate into a GitHub umbrella issue at merge time) diff --git a/openspec/changes/auth-manager-security-hardening/.openspec.yaml b/openspec/changes/auth-manager-security-hardening/.openspec.yaml new file mode 100644 index 000000000..eebe4d86b --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-05 diff --git a/openspec/changes/auth-manager-security-hardening/design.md b/openspec/changes/auth-manager-security-hardening/design.md new file mode 100644 index 000000000..3d54ff530 --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/design.md @@ -0,0 +1,131 @@ +## Context + +`auth-manager-foundation` (PR #310) shipped the first end-to-end Auth Manager: RS256 JWT issuance, opaque refresh tokens, JWKS, deny-list, audit log, RBAC, and `AuthMiddleware`. A senior security review found that the foundation was correct in its cryptographic primitives (RS256-only issuance, Argon2id, hashed refresh tokens, contextvars-based ambient propagation) but had a class of consistent gaps: the Auth Manager itself never bothered to authenticate its own callers, JWT verification trusted the JWKS algorithm field, refresh-token consumption was racy, and the audience claim defaulted to a literal string that two unbound labs would mutually trust. + +The hardening must land before any operator turns `auth_enabled=True` — otherwise enabling auth does not improve security, it just adds latency. The change is small in surface area (one repo, one package, mostly the auth manager and middleware), but it is high-impact: each fix closes a class of attack rather than a single bug. + +This design describes the technical approach for each fix; the proposal lists what changes and why, and `tasks.md` enumerates the work. + +## Goals / Non-Goals + +**Goals:** +- Eliminate the unauthenticated administrative surface on the Auth Manager. +- Make JWT verification refuse anything other than RS256 by configuration, not by accident. +- Make refresh-token consumption atomic and reuse detection unambiguous. +- Refuse to operate without an explicit `lab_id`; remove the `"lab-unbound"` literal. +- Remove the password-via-argv leak path. +- Make the audit log failure-closed for state-changing auth operations. +- Add the test coverage that would have caught the above. + +**Non-Goals:** +- mTLS for nodes (deferred to `auth-node-mtls`). +- Per-principal `aud` narrowing (deferred to `auth-per-principal-aud-narrowing`). +- Federated identity / Globus / ORCID (deferred to `auth-globus-orcid-federation`). +- Rolling out `@requires` to every other manager's endpoints (`auth-per-manager-rbac-rollout`). +- Replacing pre-provisioned NodeIdentity registration (`auth-node-enrollment-tokens`). +- Splitting `auth_server.py` is in scope as a refactor, but only because it materially helps reviewers verify per-router authorization. If the refactor balloons in size, it can split out into its own change. + +## Decisions + +### D1: Mount `AuthMiddleware` on the Auth Manager itself, with an explicit allowlist. + +The Auth Manager is the only manager today that does not install `AuthMiddleware` on its own FastAPI app. We will install it (with the same `auth_enabled`/`auth_required` settings as every other manager) and supply an explicit unauthenticated-allowlist for the routes that must be reachable without a token: + +- `POST /token` — bootstrap path for credentials. +- `GET /.well-known/jwks.json` — public key publication. +- `GET /health`, `GET /health/keys`, `GET /settings` — operator/monitor endpoints. +- `GET /deny-list` — accessed by every consuming manager's `AuthClient`. (Authenticating it would be circular and offers no security benefit since the deny-list is a list of `jti` values that are already known to the issuer.) + +Every other route — including `POST /introspect` and `POST /revoke` — requires authentication. `/introspect` follows RFC 7662: `{active: false}` for unauthenticated callers (no claim leak), full claims only for authenticated holders of `auth.token.introspect`. `/revoke` allows revocation of one's own tokens (sub matches) without an additional permission, and revocation of others' tokens with `auth.token.revoke`. + +**Alternatives considered:** +- *Per-route `@requires` only, no middleware*: rejected — easy to forget on new routes, no centralized enforcement, no defense in depth. +- *Authenticate `/deny-list` too, with service-account tokens*: rejected — circular dependency (a manager needs the deny-list to verify tokens, but needs a token to fetch the deny-list). The deny-list is not sensitive. + +### D2: Pin `algorithms=["RS256"]` on every JWT verification. + +Both `token_service.verify_token` and `AuthClient` JWT verification call `decode(token, jwks, ...)` without an `algorithms=` allowlist. We will pass `algorithms=["RS256"]` everywhere. The set is hard-coded (not configurable) because adding non-RS256 algorithms is a breaking change to the issuer too, not a runtime knob. We also keep a defense-in-depth `_enforce_algorithm` helper that pre-parses the JWS header and rejects disallowed algs before the JOSE library touches the token, in case a malformed key were ever added to the verification keyset. + +### D3: Atomic refresh-token consumption via `UPDATE ... WHERE revoked_at IS NULL RETURNING ...`. + +The current read-then-write pattern allows two concurrent refreshes of the same token to both succeed. We will replace `consume_refresh_token` with a single statement: update the row setting `revoked_at = now()` and `rotated_to = ` only when `revoked_at IS NULL`, then check the affected-row count. If zero rows updated, either the token doesn't exist or it was already revoked — we then re-fetch to distinguish, and if revoked, fire the family-revocation as before. Postgres supports `RETURNING`; SQLite (used in tests) supports `RETURNING` since 3.35 — sufficient for our test environment. We will also add a partial unique index (`refresh_tokens (token_hash) WHERE revoked_at IS NULL`) as a belt-and-suspenders constraint, in a new alembic migration `0002_*`. + +**Alternatives considered:** +- *Application-level lock on `principal_sub`*: rejected — contention bottleneck and doesn't help in multi-process deployments. +- *`SELECT ... FOR UPDATE`*: works but two round-trips. The `UPDATE ... RETURNING` approach is one round-trip and atomic. + +### D4: Refuse to issue tokens (and refuse to start) without `lab_id`. + +`AuthManagerSettings.lab_id` becomes effectively required when `auth_enabled=True`. The Auth Manager's startup hook checks: if `lab_id` is unset, log an error and refuse to start. The `"lab-unbound"` literal in `auth_server.py:191` is removed. Test fixtures that don't care about lab binding can pass an explicit ULID. + +### D5: Bootstrap CLI accepts password only via interactive prompt or env var. + +Click `--password` option is removed. The CLI prompts via `click.prompt(hide_input=True, confirmation_prompt=True)` if neither stdin nor `MADSCI_AUTH_BOOTSTRAP_PASSWORD` is set. CI/automation uses the env var. argv-leak via `ps` is no longer possible. + +### D6: Audit log writes are failure-closed for state-changing operations. + +Today, audit log writes happen in a separate session from the operation. We move them into the same SQLAlchemy session, so a failed audit insert rolls back the whole transaction. For token issuance, this means: if we cannot record that we issued a token, we don't issue it. The consumer-side `auth_audit_fallback.py` mechanism is also wired into the issuer for transient DB failures: write to a local append-only file, drain on next successful DB connection. This matches the consumer-side pattern and means a brief DB blip doesn't take down the issuer. + +**Alternatives considered:** +- *Failure-open with monitoring*: rejected — silent audit gaps are exactly the failure mode an attacker would exploit (e.g., cause DB stress, then perform a privileged action knowing the audit will be dropped). + +### D7: Optional `X-Forwarded-For` trust via explicit setting. + +`AuthManagerSettings.trust_forwarded_for: bool = False`. When `True`, `_client_ip` reads `X-Forwarded-For`'s left-most value (after normalizing). When `False` (default), `_client_ip` returns the socket peer. Operators behind a real proxy must opt in. This matches industry practice (FastAPI + uvicorn `--proxy-headers`). + +### D8: Refresh-token forensics: populate `rotated_to`. + +The schema column already exists. `consume_refresh_token` will set `rotated_to = ` on the parent row at consumption time, enabling reuse-detection forensic queries (find the leaked token's family). + +### D10: Migrate from `authlib.jose` to `joserfc`. + +Authlib 1.7+ emits `AuthlibDeprecationWarning: authlib.jose module is deprecated, please use joserfc instead. It will be compatible before version 2.0.0.` `joserfc` is the same author's (Hsiaoming Yang) successor library, narrowly scoped to JOSE/JWT/JWK/JWA/JWE/JWS RFCs (Authlib remains the umbrella for OAuth/OIDC). `joserfc` is already pulled in as a transitive dependency by Authlib 1.7+, so no install footprint change. + +We migrate all three call sites: + +- `TokenService.issue_access_token` — `jwt.encode(header, claims_dict, RSAKey.import_key(pem, parameters={"kid": ...}), algorithms=["RS256"])`. Returns a `str` directly (Authlib returned `bytes`, requiring a `.decode()`). +- `TokenService.verify_token` — `jwt.decode(token, KeySet([RSAKey.import_key(pub_pem, parameters={"kid": ...}) for row in active_keys]), algorithms=["RS256"])`. Claim validation is a separate call: `JWTClaimsRegistry(iss=..., aud=..., exp={"essential": True}, leeway=...).validate(decoded.claims)`. +- `AuthClient.verify_jwt` — `jwt.decode(token, KeySet.import_key_set(jwks_dict), algorithms=["RS256"])` followed by `JWTClaimsRegistry(leeway=...).validate(...)`. The JWKS is fetched as a dict from `/.well-known/jwks.json` and `KeySet.import_key_set` parses it directly. + +`joserfc` enforces the algorithm allowlist via the `algorithms=` argument to both `encode` and `decode`, which complements (not replaces) D2's defense-in-depth header check. + +The dependency declarations swap `Authlib>=1.3.0` for `joserfc>=1.0.0` in both `madsci.auth_manager/pyproject.toml` and `madsci.client/pyproject.toml`. JWT format and verification semantics are identical; no operator-visible change. + +**Alternatives considered:** +- *Pin to a pre-deprecation Authlib version (≤1.6).* Rejected — would freeze us out of CVE patches in Authlib's transitive crypto bumps. +- *Defer the swap to a follow-on change.* Rejected — the deprecation warning is loud, the migration is small (3 call sites), and bundling it with the hardening keeps the JWT-path churn in one PR. + +### D9: Router refactor in scope but bounded. + +We will split `auth_server.py` into: +- `routers/token_router.py` — `/token`, `/introspect`, `/revoke` +- `routers/users_router.py` — `/users/*` +- `routers/projects_router.py` — `/projects/*` +- `routers/roles_router.py` — `/roles/*` +- `routers/principals_router.py` — `/service-accounts`, `/node-identities`, `/credentials/*` +- `routers/keys_router.py` — `/keys/*`, `/.well-known/jwks.json`, `/health/keys` +- `routers/deny_list_router.py` — `/deny-list` + +Each router declares its own permissions in one place, making it trivially auditable that, e.g., every `/users/*` route requires `auth.user.*`. The `AuthManager` class shrinks to a composition root (DI for services, mounting routers, lifecycle). If the refactor exceeds ~200 lines of churn beyond the per-route `@requires` additions, we land authorization without the refactor and split routers in a follow-up. + +## Risks / Trade-offs + +- **[Breaking change for already-deployed labs]** → mitigation: foundation has not shipped (PR draft); this lands before any production rollout. CHANGELOG and `docs/guides/auth_operator.md` document the new behavior. +- **[`UPDATE ... RETURNING` requires SQLite ≥ 3.35]** → mitigation: project's CI Python version ships with newer SQLite; pin in `pyproject.toml` if needed. +- **[Failure-closed audit could create availability dependency on DB for token issuance]** → mitigation: audit-log fallback file means a DB blip falls back to local append; drain on reconnect. Same pattern already used on consumer side. +- **[Router refactor risk of regressing wiring]** → mitigation: existing 65 tests + new authorization tests run against the refactored code; if green, wiring is preserved. +- **[Bootstrap UX regression — operators used to `--password` for scripts]** → mitigation: env var `MADSCI_AUTH_BOOTSTRAP_PASSWORD` provides the same automation hook without the argv leak. +- **[Partial unique index on `refresh_tokens` requires a migration]** → mitigation: standard Alembic migration; the foundation already ships Alembic infrastructure with auto-backup. + +## Migration Plan + +1. Land foundation PR #310 (with security-review skill green ignoring this PR's items). +2. Land this hardening change in the next PR. Single Alembic migration `0002_refresh_token_partial_unique_index`. +3. Operators who already ran `madsci auth bootstrap` from the foundation see no data migration. Operators with `lab_id` unset must set it before restarting. Operators who scripted `--password` must switch to env var. +4. Rollback: revert PR; the alembic migration is downgrade-safe (drop partial unique index). Removing `AuthMiddleware` from the Auth Manager is a one-line revert. + +## Open Questions + +- **Q1**: Should `/deny-list` require *some* auth (e.g., a shared HMAC token) to mitigate scraping? Current decision: no, the data is non-sensitive. Revisit if `auth-per-manager-rbac-rollout` reveals a different threat model. +- **Q2**: Is 30 s the right default leeway for `iat`/`exp` validation? RFC 7519 says "small", AWS uses 5 min, Azure uses 5 min. We pick 30 s as a balance between clock-skew tolerance and revocation freshness. Configurable via `AuthManagerSettings.token_clock_skew_seconds`. +- **Q3**: Should `auth.token.revoke` be a separate permission or fall under `auth.user.write`? Current decision: separate, since revocation is a discrete operation an operator may want to delegate without granting full user-write. diff --git a/openspec/changes/auth-manager-security-hardening/proposal.md b/openspec/changes/auth-manager-security-hardening/proposal.md new file mode 100644 index 000000000..76a01003a --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/proposal.md @@ -0,0 +1,40 @@ +## Why + +A senior security review of `auth-manager-foundation` (PR #310) identified five merge-blocking issues and six significant follow-ups. The Auth Manager's own admin endpoints are unauthenticated, JWT verification doesn't pin algorithms, refresh-token reuse detection has a TOCTOU race, the introspect/revoke endpoints leak token claims to anyone on the network, and the audience claim defaults to a value that two unbound labs would mutually trust. These flaws would let any actor with network reach to port 8007 mint admin users, rotate signing keys, and forge tokens — defeating the entire foundation. The hardening must land before any operator turns `auth_enabled=True`. + +## What Changes + +- **BREAKING**: All Auth Manager admin endpoints (`POST /users`, `/projects`, `/roles`, `/roles/grant`, `/service-accounts`, `/node-identities`, `POST /credentials/{id}/rotate`, `POST /keys/rotate`, `DELETE /keys/{kid}`, `GET /users`, `GET /projects`, `GET /roles`, `GET /keys`) require an authenticated caller with the appropriate `auth.*` permission. The Auth Manager mounts `AuthMiddleware` on itself with an explicit unauthenticated allowlist (`/token`, `/.well-known/jwks.json`, `/health*`, `/settings`, `/deny-list` GET). +- **BREAKING**: `/introspect` and `/revoke` require client authentication. `/introspect` returns `{active: false}` to unauthenticated callers per RFC 7662; `/revoke` requires the caller to be the token's `sub` or hold an `auth.token.revoke` permission. +- **BREAKING**: The Auth Manager refuses to start (and refuses to issue tokens) when `lab_id` is unset. The literal `"lab-unbound"` audience is removed. +- JWT verification on both server (`token_service.verify_token`) and client (`AuthClient`) pins `algorithms=["RS256"]`. Verification accepts a configurable clock-skew leeway (default 30 s). +- Refresh-token consumption uses an atomic `UPDATE ... WHERE revoked_at IS NULL RETURNING ...` (or row lock for SQLite) so concurrent refreshes cannot both succeed. The `rotated_to` column is populated for forensics. +- Bootstrap CLI removes `--password` as a positional/flag argument; password comes from interactive prompt or `MADSCI_AUTH_BOOTSTRAP_PASSWORD` env var only. argv is no longer a leak vector. +- Audit log writes for token issuance, revocation, and admin mutations are committed in the same transaction as the underlying state change. If audit write fails, the operation fails (failure-closed). The consumer-side `auth_audit_fallback` mechanism is extended to the issuer for transient DB errors. +- `_client_ip` no longer trusts `X-Forwarded-For` unconditionally; a `auth_trust_forwarded_for` setting (default `False`) gates it. +- `consume_refresh_token` populates `RefreshTokenTable.rotated_to` linking parent → child for reuse-detection forensics. +- `auth_server.py` is split into per-resource routers (`token_router`, `users_router`, `projects_router`, `roles_router`, `principals_router`, `keys_router`, `deny_list_router`) under `auth_manager/routers/`. +- New tests cover: alg-confusion rejection (HS256 forgery using public key as HMAC secret must fail), refresh-token concurrent-consume race, every admin endpoint rejecting unauthenticated and under-privileged callers, `aud` mismatch rejection, expired-token replay, deny-list eviction on jti revoke. +- **Library swap (Authlib → joserfc).** Authlib 1.7+ deprecates `authlib.jose` in favor of `joserfc` (same author's successor). `TokenService` and `AuthClient` are migrated to `joserfc.jwt.encode`/`decode`, `joserfc.jwk.RSAKey`/`KeySet`, and `JWTClaimsRegistry`. The dependency declaration in `madsci.auth_manager` and `madsci.client` swaps `Authlib>=1.3.0` for `joserfc>=1.0.0`. JWT format and verification semantics are unchanged. + +## Capabilities + +### New Capabilities + +_None._ All changes modify capabilities introduced by `auth-manager-foundation`. + +### Modified Capabilities + +- `auth-manager-service`: admin endpoint authorization, lab_id-required bootstrap, router refactor, audit-log failure-closed semantics, `X-Forwarded-For` gating. +- `auth-token-lifecycle`: algorithm pinning, clock-skew leeway, atomic refresh-token consumption, `rotated_to` linkage, introspect/revoke authentication. +- `auth-client-integration`: `AuthClient` algorithm pinning on verification, CLI bootstrap password no longer accepted via argv. + +## Impact + +- **Code:** `src/madsci_auth_manager/madsci/auth_manager/auth_server.py` (split into routers), `services/token_service.py`, `services/audit_logger.py`, `services/signing_key_service.py`, `server_types.py` (new settings field), `tables.py` (potential index for `(token_hash, revoked_at)`), `src/madsci_common/madsci/common/auth_middleware.py` (mount on Auth Manager, allowlist), `src/madsci_common/madsci/common/auth_decorators.py` (harden `project_from`), `src/madsci_client/madsci/client/auth_client.py` (alg pinning), `src/madsci_client/madsci/client/cli/commands/auth.py` (drop `--password` argv). +- **Operators:** Anyone running the Auth Manager with `auth_enabled=True` must now hold a token with `auth.*` permission to call admin endpoints. Bootstrap CLI now prompts for password instead of accepting `--password`. Operators behind a load balancer must explicitly set `auth_trust_forwarded_for=true`. Deployments without `lab_id` will fail to start. +- **Database:** Possible new partial unique index on `refresh_tokens(token_hash) WHERE revoked_at IS NULL` (Alembic migration `0002_*`). No data migration required. +- **Tests:** ~25 new tests in `src/madsci_auth_manager/tests/` and `src/madsci_common/tests/test_auth_*.py`. +- **Docs:** `docs/guides/auth.md` documents the admin permission model; `docs/guides/auth_operator.md` updates the bootstrap flow and the trusted-proxy setting. +- **Dependencies:** `Authlib>=1.3.0` replaced with `joserfc>=1.0.0` in `madsci.auth_manager` and `madsci.client`. `joserfc` was already pulled in transitively by Authlib 1.7+, so no install footprint change. +- **Sequencing:** This change MUST land after `auth-manager-foundation` (PR #310) merges, ideally in the same release. diff --git a/openspec/changes/auth-manager-security-hardening/specs/auth-client-integration/spec.md b/openspec/changes/auth-manager-security-hardening/specs/auth-client-integration/spec.md new file mode 100644 index 000000000..a1840f436 --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/specs/auth-client-integration/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: AuthClient pins RS256 on JWT verification + +`AuthClient`'s JWT verification path SHALL pass `algorithms=["RS256"]` to the underlying JWT library. Tokens whose JWS header declares any other algorithm SHALL be rejected. + +#### Scenario: AuthClient rejects HS256-confused token +- **GIVEN** a token forged with `alg=HS256` using the lab's RS256 public key as the HMAC secret +- **WHEN** `AuthClient.verify(...)` is called on that token +- **THEN** verification SHALL fail and SHALL NOT return claims + +### Requirement: Bootstrap CLI accepts password only via prompt or env var + +The `madsci auth bootstrap` CLI SHALL NOT accept the admin password as a command-line argument or option. The password SHALL be sourced from one of: + +1. The environment variable `MADSCI_AUTH_BOOTSTRAP_PASSWORD`. +2. An interactive prompt with hidden input (using `click.prompt(..., hide_input=True, confirmation_prompt=True)`). + +If neither is available (e.g., non-interactive run with no env var), the command SHALL exit non-zero with a clear error message. + +#### Scenario: --password flag is rejected +- **WHEN** an operator runs `madsci auth bootstrap --username admin --password hunter2` +- **THEN** the CLI SHALL exit non-zero and SHALL NOT accept the password from argv + +#### Scenario: Env var supplies password for automation +- **GIVEN** `MADSCI_AUTH_BOOTSTRAP_PASSWORD=hunter2` is set in the environment +- **WHEN** an operator runs `madsci auth bootstrap --username admin` +- **THEN** the CLI SHALL use the env var value and SHALL NOT prompt + +#### Scenario: Interactive prompt for an operator +- **GIVEN** no `MADSCI_AUTH_BOOTSTRAP_PASSWORD` env var and an interactive TTY +- **WHEN** an operator runs `madsci auth bootstrap --username admin` +- **THEN** the CLI SHALL prompt for the password with hidden input and require confirmation diff --git a/openspec/changes/auth-manager-security-hardening/specs/auth-manager-service/spec.md b/openspec/changes/auth-manager-security-hardening/specs/auth-manager-service/spec.md new file mode 100644 index 000000000..94d287ddd --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/specs/auth-manager-service/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Auth Manager enforces authentication on its own admin endpoints + +The Auth Manager SHALL install `AuthMiddleware` on its own FastAPI application whenever `auth_enabled=True`. Every administrative endpoint (every route under `/users`, `/projects`, `/roles`, `/service-accounts`, `/node-identities`, `/credentials`, and `/keys`, plus `GET /users` / `GET /projects` / `GET /roles` / `GET /keys` listing endpoints) SHALL additionally carry a `@requires(permission=...)` decorator that names the required permission and rejects unauthenticated or under-privileged callers. + +The unauthenticated allowlist SHALL be limited to: `POST /token`, `GET /.well-known/jwks.json`, `GET /health`, `GET /health/keys`, `GET /settings`, and `GET /deny-list`. No other route SHALL be reachable without a valid bearer token. + +#### Scenario: Unauthenticated request to admin endpoint is rejected +- **GIVEN** the Auth Manager is running with `auth_enabled=True` +- **WHEN** a client calls `POST /users`, `POST /roles/grant`, `POST /service-accounts`, `POST /node-identities`, `POST /credentials/{id}/rotate`, `POST /keys/rotate`, `DELETE /keys/{kid}`, or any `GET` listing endpoint without an `Authorization` header +- **THEN** the Auth Manager SHALL return HTTP 401 and SHALL NOT execute the handler + +#### Scenario: Authenticated but under-privileged request is rejected +- **GIVEN** a caller with a valid token whose `permissions` claim does not include the required `auth.*` permission for the route +- **WHEN** the caller invokes that route +- **THEN** the Auth Manager SHALL return HTTP 403 and SHALL NOT execute the handler + +#### Scenario: Allowlisted routes remain reachable without a token +- **WHEN** a client calls `POST /token`, `GET /.well-known/jwks.json`, `GET /health`, `GET /health/keys`, `GET /settings`, or `GET /deny-list` without an `Authorization` header +- **THEN** the Auth Manager SHALL serve the response normally + +### Requirement: Auth Manager refuses to operate without a bound lab_id + +The Auth Manager SHALL refuse to start when `lab_id` is unset or empty. The literal `"lab-unbound"` audience value SHALL NOT appear anywhere in issued tokens or in code. If `lab_id` becomes unset after startup (for example, via configuration reload), token issuance SHALL fail with HTTP 503 until `lab_id` is restored. + +#### Scenario: Startup fails without lab_id +- **GIVEN** an `AuthManagerSettings` whose `lab_id` is unset +- **WHEN** the operator runs `madsci start manager auth` +- **THEN** the process SHALL exit non-zero with a clear error message naming `lab_id` as required + +#### Scenario: No "lab-unbound" tokens are issued +- **WHEN** any token is issued by any code path +- **THEN** the `aud` claim SHALL be the deployment's bound `lab_id` and SHALL NEVER be the string `"lab-unbound"` + +### Requirement: Audit log writes are failure-closed for state-changing operations + +For every state-changing auth operation (token issuance, refresh, revocation, user/project/role/principal creation or modification, key rotation or retirement), the audit log row SHALL be written in the same database transaction as the underlying state change. If the audit write fails for any reason, the transaction SHALL be rolled back and the operation SHALL fail. For transient database errors only, the issuer SHALL fall back to writing to the local `auth_audit_fallback` append-only file, deferring the database write to the next successful reconnection. + +#### Scenario: Failed audit write rolls back the operation +- **GIVEN** a database state that causes the audit insert to raise (e.g., constraint violation, schema mismatch) +- **WHEN** any state-changing auth operation runs +- **THEN** the operation SHALL fail with HTTP 500, the underlying state SHALL NOT be persisted, and no token SHALL be returned + +#### Scenario: Transient DB error falls back to local audit file +- **WHEN** a state-changing auth operation encounters a transient DB error and the operation must succeed +- **THEN** the audit row SHALL be written to the local `auth_audit_fallback` file, the operation SHALL succeed, and the row SHALL be drained to the database on the next successful connection + +### Requirement: X-Forwarded-For trust is opt-in + +The Auth Manager SHALL NOT trust the `X-Forwarded-For` header by default. A new setting `auth_trust_forwarded_for: bool = False` SHALL gate trust. When `False`, `_client_ip` SHALL return the socket peer address. When `True`, `_client_ip` MAY use the leftmost `X-Forwarded-For` value after normalization. + +#### Scenario: Default deployment ignores X-Forwarded-For +- **GIVEN** an Auth Manager with `auth_trust_forwarded_for=False` +- **WHEN** a request arrives carrying `X-Forwarded-For: 1.2.3.4` +- **THEN** audit log entries for that request SHALL record the socket peer address, NOT `1.2.3.4` + +#### Scenario: Behind-proxy deployment honors X-Forwarded-For +- **GIVEN** an Auth Manager with `auth_trust_forwarded_for=True` +- **WHEN** a request arrives via a trusted proxy carrying `X-Forwarded-For: 1.2.3.4` +- **THEN** audit log entries SHALL record `1.2.3.4` as the client IP + +### Requirement: Auth Manager FastAPI server is organized by resource router + +The Auth Manager SHALL organize its endpoints into per-resource FastAPI routers under `madsci/auth_manager/routers/`: `token_router`, `users_router`, `projects_router`, `roles_router`, `principals_router`, `keys_router`, `deny_list_router`. Each router SHALL declare its required permissions in a single auditable location. + +#### Scenario: Routers are discoverable and per-resource +- **WHEN** a reviewer reads `madsci/auth_manager/routers/users_router.py` +- **THEN** every `/users/*` route and its `@requires(permission=...)` decorator SHALL be visible in that one file diff --git a/openspec/changes/auth-manager-security-hardening/specs/auth-token-lifecycle/spec.md b/openspec/changes/auth-manager-security-hardening/specs/auth-token-lifecycle/spec.md new file mode 100644 index 000000000..0061829ca --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/specs/auth-token-lifecycle/spec.md @@ -0,0 +1,86 @@ +## ADDED Requirements + +### Requirement: JWT verification pins RS256 + +Every JWT verification path — `token_service.verify_token` on the Auth Manager and `AuthClient` token verification on the consumer side — SHALL pass `algorithms=["RS256"]` to the underlying JWT library. Tokens whose JWS header declares any other algorithm (including `none`, `HS256`, `HS384`, `HS512`, `RS384`, `RS512`, `ES256`, etc.) SHALL be rejected with the same error path as a signature failure. + +#### Scenario: Token signed with HS256 using public key as secret is rejected +- **GIVEN** an attacker constructs a JWT with `alg=HS256` whose HMAC secret is the lab's RS256 public key (the classic alg-confusion attack) +- **WHEN** the token is presented to either the Auth Manager or any consumer with `AuthMiddleware` enabled +- **THEN** verification SHALL fail and the request SHALL be treated as unauthenticated + +#### Scenario: Token with alg=none is rejected +- **GIVEN** a JWT whose JWS header declares `alg=none` +- **WHEN** the token is verified +- **THEN** verification SHALL fail + +### Requirement: JWT verification accepts a configurable clock-skew leeway + +JWT verification SHALL apply a clock-skew leeway when validating `iat` and `exp`. The default leeway SHALL be 30 seconds. The leeway SHALL be configurable via `AuthManagerSettings.token_clock_skew_seconds` and SHALL be used uniformly by both the Auth Manager's verifier and the consumer-side `AuthClient`. + +#### Scenario: Token issued by a slightly-fast issuer verifies on a slightly-slow consumer +- **GIVEN** a token whose `iat` is 5 seconds in the future relative to the verifier's clock +- **WHEN** the token is verified with the default leeway +- **THEN** verification SHALL succeed + +#### Scenario: Token outside leeway is rejected +- **GIVEN** a token whose `exp` is 60 seconds in the past and the leeway is 30 seconds +- **WHEN** the token is verified +- **THEN** verification SHALL fail + +### Requirement: Refresh-token consumption is atomic and reuse-safe under concurrency + +The Auth Manager SHALL consume a refresh token via a single atomic database operation: `UPDATE refresh_tokens SET revoked_at = now(), rotated_to = WHERE token_hash = AND revoked_at IS NULL RETURNING ...`. If the affected-row count is zero, the implementation SHALL determine whether the row exists in revoked state (reuse) or does not exist (invalid grant), and SHALL fire the family-revocation response on detected reuse. + +A partial unique index `refresh_tokens(token_hash) WHERE revoked_at IS NULL` SHALL be added via Alembic migration `0002` to provide a database-enforced invariant. + +#### Scenario: Two concurrent refresh requests of the same token — only one succeeds +- **GIVEN** a valid refresh token T held by a client +- **WHEN** the client (or an attacker who stole T) issues two `grant_type=refresh_token` requests for T at the same moment +- **THEN** at most one request SHALL succeed; the other SHALL fail with HTTP 401 and the principal's entire refresh-token family SHALL be revoked + +#### Scenario: Reuse of a revoked refresh token revokes the family +- **WHEN** a refresh token whose `revoked_at` is already set is presented +- **THEN** the Auth Manager SHALL respond HTTP 401 AND SHALL revoke every currently-active refresh token for the same `principal_sub` + +### Requirement: Refresh-token rotation populates `rotated_to` for forensics + +When a refresh token is consumed and a new refresh token is issued, the parent row's `rotated_to` column SHALL be populated with the new token's `jti` (or row identifier). This SHALL be done in the same atomic UPDATE that revokes the parent. + +#### Scenario: Parent row links to child after rotation +- **GIVEN** a refresh token T1 is consumed and a new refresh token T2 is issued +- **WHEN** an operator queries the `refresh_tokens` table for T1 +- **THEN** T1's `rotated_to` column SHALL contain T2's identifier + +### Requirement: Token introspection requires authentication + +`POST /introspect` SHALL require an authenticated caller. Unauthenticated callers SHALL receive `{"active": false}` (per RFC 7662 §2.2 — never leak claims to unauthenticated parties). Authenticated callers holding the `auth.token.introspect` permission SHALL receive the full claims response for active tokens. + +#### Scenario: Unauthenticated introspect returns inactive +- **GIVEN** no `Authorization` header +- **WHEN** a client posts a valid token to `/introspect` +- **THEN** the response SHALL be HTTP 200 with body `{"active": false}` and SHALL NOT include any claims + +#### Scenario: Authenticated, privileged introspect returns claims +- **GIVEN** an authenticated caller whose token includes `auth.token.introspect` +- **WHEN** the caller posts a valid, unrevoked, unexpired token +- **THEN** the response SHALL be HTTP 200 with `{"active": true, ...claims}` + +### Requirement: Token revocation requires authentication + +`POST /revoke` SHALL require an authenticated caller. A caller MAY revoke a token whose `sub` matches the caller's own `sub`. Revocation of any other principal's token SHALL require the `auth.token.revoke` permission. Unauthenticated revocation requests SHALL be rejected with HTTP 401. + +#### Scenario: Unauthenticated revoke is rejected +- **GIVEN** no `Authorization` header +- **WHEN** a client posts any token to `/revoke` +- **THEN** the response SHALL be HTTP 401 and the token SHALL NOT be revoked + +#### Scenario: Self-revocation succeeds +- **GIVEN** a caller authenticated as principal P +- **WHEN** the caller revokes a token whose `sub` is P +- **THEN** the token SHALL be revoked and HTTP 200 returned + +#### Scenario: Cross-principal revocation requires permission +- **GIVEN** a caller authenticated as principal P with no `auth.token.revoke` permission +- **WHEN** the caller revokes a token whose `sub` is some other principal Q +- **THEN** the response SHALL be HTTP 403 and the token SHALL NOT be revoked diff --git a/openspec/changes/auth-manager-security-hardening/tasks.md b/openspec/changes/auth-manager-security-hardening/tasks.md new file mode 100644 index 000000000..1b4f1c0ff --- /dev/null +++ b/openspec/changes/auth-manager-security-hardening/tasks.md @@ -0,0 +1,133 @@ +## 1. Algorithm pinning (C3) + +- [x] 1.1 Add `algorithms=["RS256"]` to `jwt.decode(...)` in `src/madsci_auth_manager/madsci/auth_manager/services/token_service.py:226` +- [x] 1.2 Add `algorithms=["RS256"]` to `jose_jwt.decode(...)` in `src/madsci_client/madsci/client/auth_client.py:230` +- [x] 1.3 Add a unit test that constructs an HS256 token using the lab's RS256 public key as HMAC secret and asserts both verification paths reject it (`tests/test_auth_server.py` and `tests/test_auth_client.py`) +- [x] 1.4 Add a unit test that constructs an `alg=none` token and asserts both verification paths reject it + +## 2. Refuse unbound lab_id (C5) + +- [x] 2.1 Remove the `"lab-unbound"` literal from `src/madsci_auth_manager/madsci/auth_manager/auth_server.py:191` and any other references +- [x] 2.2 In `AuthManager.initialize` (or startup hook), raise `RuntimeError` with a clear message if `settings.lab_id` is unset/empty +- [x] 2.3 In every token-issuance path, defensive-check `lab_id` and respond HTTP 503 with `{"error": "lab_id_unbound"}` if it has become unset +- [x] 2.4 Update test fixtures in `src/madsci_auth_manager/tests/` to pass an explicit `lab_id` (use `new_ulid_str()`) +- [x] 2.5 Add a test asserting the manager refuses to start without `lab_id` + +## 3. Atomic refresh-token consumption (C4) + +- [x] 3.1 Replace the read-then-write pattern in `consume_refresh_token` (`token_service.py:149-180`) with a single `UPDATE refresh_tokens SET revoked_at = now(), rotated_to = WHERE token_hash = ? AND revoked_at IS NULL RETURNING ...` +- [x] 3.2 If 0 rows updated, re-fetch by `token_hash` to distinguish unknown vs already-revoked, and fire `_revoke_all_for_principal` on already-revoked +- [x] 3.3 Generate the new refresh-token jti before the UPDATE so it can be passed into `rotated_to` in one statement +- [x] 3.4 Create Alembic migration `0002_refresh_token_partial_unique_index` adding a partial unique index `refresh_tokens(token_hash) WHERE revoked_at IS NULL` +- [x] 3.5 Add a concurrency test using `concurrent.futures.ThreadPoolExecutor` (or equivalent) firing N parallel `consume_refresh_token` calls against the same token; assert exactly one returns success, all others raise `TokenError`, and the principal's family was revoked +- [x] 3.6 Verify SQLite ≥ 3.35 in CI (test fixture asserts `sqlite3.sqlite_version >= "3.35"`) + +## 4. Auth Manager admin authorization (C1) + +- [x] 4.1 Define the permission strings in one place (e.g., `src/madsci_auth_manager/madsci/auth_manager/permissions.py`): `auth.user.read`, `auth.user.write`, `auth.project.read`, `auth.project.write`, `auth.role.read`, `auth.role.write`, `auth.role.grant`, `auth.principal.write`, `auth.credentials.rotate`, `auth.key.read`, `auth.key.rotate`, `auth.key.retire`, `auth.token.introspect`, `auth.token.revoke` +- [x] 4.2 Seed the built-in `admin` role with all `auth.*` permissions in the bootstrap path +- [x] 4.3 Mount `AuthMiddleware` on the Auth Manager's FastAPI app via `AbstractManagerBase` (verify it picks up the manager's own `auth_enabled`/`auth_required` settings) +- [x] 4.4 Define the unauthenticated allowlist (`/token`, `/.well-known/jwks.json`, `/health`, `/health/keys`, `/settings`, `/deny-list`) — confirm the middleware supports an exemption list, and add one if not +- [x] 4.5 Decorate every admin route in `auth_server.py` (lines 673-1080) with `@requires(permission=...)` per the mapping in design D9 +- [x] 4.6 Add tests that, for each admin endpoint, assert: (a) HTTP 401 with no token, (b) HTTP 403 with a token lacking the permission, (c) HTTP 200 with the right permission + +## 5. Introspect/revoke authentication (C2) + +- [x] 5.1 Add `@requires(permission="auth.token.introspect")` to `/introspect`; on 401/403 return `{"active": false}` (HTTP 200) instead of the usual error response, per RFC 7662 +- [x] 5.2 Update `/revoke` to require authentication; allow self-revocation when `request.state.principal.sub == token.sub`; otherwise require `auth.token.revoke` +- [x] 5.3 Tests: unauthenticated `/introspect` returns `{"active": false}`; unauthenticated `/revoke` returns 401; cross-principal `/revoke` without permission returns 403; self-revocation succeeds + +## 6. Bootstrap CLI password handling (S1) + +- [x] 6.1 Remove the `--password` Click option from `src/madsci_client/madsci/client/cli/commands/auth.py` +- [x] 6.2 Source the password from `os.environ.get("MADSCI_AUTH_BOOTSTRAP_PASSWORD")` if set +- [x] 6.3 Otherwise, call `click.prompt("Admin password", hide_input=True, confirmation_prompt=True)` +- [x] 6.4 If neither is available (non-TTY and no env var), exit non-zero with a clear error +- [x] 6.5 Update `tests/test_cli_auth.py` to cover env-var path and to assert `--password` is no longer a recognized option + +## 7. Clock-skew leeway (S2) + +- [x] 7.1 Add `token_clock_skew_seconds: int = 30` to `AuthManagerSettings` in `server_types.py` +- [x] 7.2 Pass `leeway=settings.token_clock_skew_seconds` to the JOSE library's claims validation in `verify_token` +- [x] 7.3 Pass the same leeway to `AuthClient`'s verification path (settable on the client; default 30) +- [x] 7.4 Tests: token with `iat` slightly in the future verifies; token with `exp` outside leeway is rejected + +## 8. Failure-closed audit log (S4) + +- [ ] 8.1 Refactor `audit_logger.py` so audit writes accept the active `Session` and run inside the caller's transaction _(deferred — out of scope; would require restructuring every handler to share a session. Current `AuditLogger.log()` raises on DB failure, which propagates through FastAPI — see 8.3.)_ +- [ ] 8.2 Update `_handle_password_grant`, `_handle_refresh_grant`, `_handle_client_credentials_grant`, and every admin handler to share the same `Session` between operation and audit write _(deferred with 8.1)_ +- [x] 8.3 On audit-write failure, raise — let the transaction roll back; do NOT silently swallow _(audit failures now propagate; FastAPI converts to 500. The issued access JWT is in-memory only at that point and never returned. The refresh-token row IS already persisted, but only its hash — the opaque token never leaves the server.)_ +- [ ] 8.4 Wire `auth_audit_fallback` into the issuer side _(deferred — `auth_audit_fallback` remains a consumer-side mechanism. Out of scope for this PR; tracked as follow-up in `docs/guides/auth.md`.)_ +- [x] 8.5 Tests: induce an audit-write failure (e.g., monkeypatch the audit insert to raise), assert the calling operation also fails and no token is returned + +## 9. X-Forwarded-For trust gating (S5) + +- [x] 9.1 Add `auth_trust_forwarded_for: bool = False` to `AuthManagerSettings` +- [x] 9.2 Update `_client_ip` (`auth_server.py:120`) to consult the setting; default returns socket peer +- [x] 9.3 When trusted, parse leftmost `X-Forwarded-For` value, normalize, validate as IP; on parse failure, fall back to socket peer +- [x] 9.4 Tests: default ignores `X-Forwarded-For`; opt-in honors it; malformed header falls back to socket peer + +## 10. Refresh-token forensics: rotated_to (S3) + +- [x] 10.1 Implementation already covered by 3.1 — verify the `rotated_to` column is populated by the atomic UPDATE +- [x] 10.2 Add a test asserting that after rotation, the parent row's `rotated_to` is the new token's identifier + +## 11. Router refactor (S6 / D9) + +- [ ] 11.1 Create routers/ package _(deferred per 11.5 — refactor would exceed the design's 200-line bound)_ +- [ ] 11.2 Move route handlers _(deferred per 11.5)_ +- [ ] 11.3 Update `create_server` _(deferred per 11.5)_ +- [x] 11.4 Re-run the full auth test suite; all 65 prior tests + new tests SHALL pass _(91 tests pass — 65 baseline + 24 new hardening tests + 2 new CLI tests)_ +- [x] 11.5 Decision: defer the refactor. Authorization landed without the split. Follow-up tracked in `docs/guides/auth.md`. + +## 12. Documentation updates + +- [x] 12.1 Update `docs/guides/auth.md` with the admin-permission model and the full list of `auth.*` permissions +- [x] 12.2 Update `docs/guides/auth_operator.md` bootstrap section: env-var-or-prompt password, `lab_id` required, `auth_trust_forwarded_for` opt-in +- [x] 12.3 Add a CHANGELOG entry under "Unreleased" / "Changed" + "Security" +- [x] 12.4 Regenerate `docs/Configuration.md` (auto-generated) so the new settings appear + +## 14. JOSE library migration (D10) + +- [x] 14.1 Swap `from authlib.jose import jwt` for `from joserfc import jwt` in `services/token_service.py`; add `RSAKey`, `KeySet`, `JWTClaimsRegistry` imports +- [x] 14.2 Update `issue_access_token` to construct `RSAKey.import_key(pem, parameters={"kid": ...})` and call `jwt.encode(...)` (returns `str` directly; drop the `.decode()`) +- [x] 14.3 Update `verify_token` to build a `KeySet` from active public keys, call `jwt.decode(token, key_set, algorithms=["RS256"])`, then validate via `JWTClaimsRegistry(...).validate(decoded.claims)` with `leeway` +- [x] 14.4 Swap `from authlib.jose import jwt as jose_jwt` for `from joserfc import jwt as jose_jwt` in `client/auth_client.py`; update `verify_jwt` to use `KeySet.import_key_set(jwks_dict)`, `jose_jwt.decode(...)`, and `JWTClaimsRegistry` +- [x] 14.5 Update `tests/test_security_hardening.py` token-construction helpers to use `joserfc`'s `jwt.encode` + `RSAKey.import_key` +- [x] 14.6 Replace `Authlib>=1.3.0` with `joserfc>=1.0.0` in `src/madsci_auth_manager/pyproject.toml` and `src/madsci_client/pyproject.toml` +- [x] 14.7 Run the auth test suite; confirm 92/92 pass and the `AuthlibDeprecationWarning` is gone + +## 15. Security-review HIGH mitigation (post-implementation review) + +The first `/security-review` pass after implementation found one HIGH issue plus +two filtered defense-in-depth findings. Mitigations: + +- [x] 15.1 **HIGH (Vuln 1):** Override `auth_enabled` and `auth_required` defaults to `True` on `AuthManagerSettings`. Mitigates the foundational issue that `@requires` no-ops when middleware isn't installed and `AuthManagerSettings` inherited `auth_enabled=False` from `ManagerSettings` — leaving every admin route unauthenticated on a fresh deployment. +- [x] 15.2 **HIGH (defense in depth):** `AuthManager._setup_auth_middleware` override installs a self-verifying `AuthMiddleware` (uses local `TokenService` rather than a remote `AuthClient`), avoiding the prefixed-alias collision that prevented the base-class path from picking up `auth_server_url`. +- [x] 15.3 **HIGH (defense in depth):** `AuthManager.run_server()` override refuses to bind unless both `auth_enabled` and `auth_required` are `True`. Catches misconfiguration at the startup boundary. +- [x] 15.4 **Filtered (`AuthClient` skips `iss`/`aud` validation):** Added `expected_issuer` and `expected_audience` constructor args to `AuthClient`; `verify_jwt` includes them in `JWTClaimsRegistry` when set. `manager_base._setup_auth_middleware` plumbs the manager's `auth_server_url` and `lab_id` through automatically. +- [x] 15.5 **Filtered (`/revoke` refresh-token branch lacks self-vs-other check):** `revoke_endpoint` now probes the refresh-token row's `principal_sub` and applies the same self-vs-other rule before revoking. +- [x] 15.6 Updated test fixtures in `test_auth_server.py`, `test_integration.py` (via `testing.make_auth_manager`'s new `auth_enforced=False` default), `test_auth_client.py`, `test_cli_auth.py`, and `test_security_hardening.py::server` to explicitly opt out of enforcement (preserves their unit-test semantics with the new safe defaults). +- [x] 15.7 New tests pin the invariants: `test_auth_manager_settings_default_to_auth_enabled`, `test_auth_manager_run_server_refuses_unsafe_config`, `test_cross_principal_refresh_token_revoke_requires_permission`, `test_auth_client_rejects_token_with_wrong_audience`. +- [x] 15.8 Re-ran auth suite (96/96) + full pytest (4202/4202) + project-wide `ruff check .` (clean). + +## 13. Verification & release gates + +- [x] 13.1 `pytest src/madsci_auth_manager src/madsci_common/tests/test_auth_*.py src/madsci_client/tests/test_auth_*.py src/madsci_client/tests/test_cli_auth.py` — all green (96/96) +- [x] 13.2 Full `pytest` — no regressions (4202/4202) +- [x] 13.3 `ruff check .` clean +- [x] 13.4 First `security-review` skill pass complete; HIGH finding + two filtered findings mitigated in Section 15. _(Re-review still recommended pre-merge.)_ +- [x] 13.5 First `madsci-release-audit` skill pass complete: stale `--password` in example lab README fixed, stale `Authlib` dep removed from `madsci_common`, stale "authlib's decoder" comment fixed in `token_service.py`. Audit findings recorded in `.scratch/auth_manager_security_hardening_audit.md`. +- [x] 13.6 Manual smoke against example lab — **PASSED**. `just build` + `just upd`. Exercised end-to-end against the running stack: + - **Pre-existing bug fixed during smoke:** `examples/example_lab/compose.yaml` set `AUTH_DATABASE_URL` to use the container DNS name (`madsci_postgres_auth:5432`), but the service uses `network_mode: host`, so DNS doesn't resolve. Auth Manager crashed with `OperationalError: could not translate host name`. Changed to `localhost:${AUTH_POSTGRES_PORT:-5435}` (the host-mapped port — same pattern other managers use for postgres). + - **Bootstrap via env var** (`docker exec -e MADSCI_AUTH_BOOTSTRAP_PASSWORD='...' auth_manager madsci auth bootstrap ...`) succeeded; returned admin user + signing kid. + - **Bootstrap with `--password`** rejected with `Error: No such option: --password`. + - **Bootstrap without env var or TTY** rejected with the documented error citing `MADSCI_AUTH_BOOTSTRAP_PASSWORD`. + - **Bootstrap re-run on populated DB** idempotent (returned same admin user). + - **`POST /token`** with admin credentials returned access + refresh token. + - **`GET /.well-known/jwks.json`** unauthenticated returned the active RS256 key. + - **Admin endpoints unauthenticated** (GET `/users`, `/projects`, `/roles`, `/keys`; POST `/users`, `/projects`, `/roles`, `/service-accounts`, `/node-identities`, `/keys/rotate`) all returned **HTTP 401**. + - **Admin endpoints with admin token** (GET `/users`, `/projects`, `/roles`, `/keys`) returned **HTTP 200**. + - **`/introspect` unauthenticated** returned `{"active": false}` (RFC 7662 compliant — no claim leak). + - **`/introspect` authorized** returned full claims dict including `sub`, `aud`, `principal_type`, `permissions`. + - **AuthMiddleware self-verifying log** (`"AuthMiddleware installed on Auth Manager (self-verifying)"`) confirmed at startup. diff --git a/pyproject.toml b/pyproject.toml index b96ba350d..579f10da1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ default_settings = [ "madsci.common.types.backup_types:DocumentDBBackupSettings", "madsci.common.types.document_db_migration_types:DocumentDBMigrationSettings", "madsci.resource_manager.migration_tool:DatabaseMigrationSettings", + "madsci.common.types.auth_types:AuthManagerSettings", ] # Generate Markdown docs @@ -92,6 +93,7 @@ dev = [ "-e madsci.experiment_manager @ file:///${PROJECT_ROOT}/src/madsci_experiment_manager", "-e madsci.data_manager @ file:///${PROJECT_ROOT}/src/madsci_data_manager", "-e madsci.location_manager @ file:///${PROJECT_ROOT}/src/madsci_location_manager", + "-e madsci.auth_manager @ file:///${PROJECT_ROOT}/src/madsci_auth_manager", "-e madsci.experiment_application @ file:///${PROJECT_ROOT}/src/madsci_experiment_application", ] diff --git a/src/madsci_auth_manager/README.md b/src/madsci_auth_manager/README.md new file mode 100644 index 000000000..d4bdf9b68 --- /dev/null +++ b/src/madsci_auth_manager/README.md @@ -0,0 +1,40 @@ +# MADSci Auth Manager + +The Auth Manager (port 8007) is MADSci's authentication and authorization service. + +It is responsible for: + +- **Identity** — User accounts (Argon2id-hashed passwords), Projects, Service + Accounts (managers), and Node Identities. +- **Tokens** — RS256 JWT access tokens + opaque, server-stored refresh tokens. + Standard OAuth 2.0 grants: `password`, `refresh_token`, `client_credentials`. +- **JWKS** — Rotating signing keypairs published at `/.well-known/jwks.json` + for stateless verification at every consuming manager. +- **RBAC** — Roles, role-permission mappings, and project-scoped grants. +- **Audit log** — Append-only record of every security-relevant event. +- **Deny-list** — Persistent revoked-`jti` table polled by consuming managers + for bounded-SLA access-token revocation. + +## Layout + +- `auth_server.py` — `AuthManager(AbstractManagerBase[AuthManagerSettings])` +- `tables.py` — SQLModel tables (users, projects, memberships, roles, + role_permissions, service_accounts, node_identities, refresh_tokens, + revoked_access_tokens, signing_keys, audit_log) +- `services/` — `SigningKeyService`, `TokenService`, `PasswordService`, + `AuditLogger`, `DenyListService` +- `alembic/` — schema migrations +- `migration_tool.py` — startup migration runner with auto-backup + +## Quick Start + +```bash +# Bootstrap (creates admin user, first signing key, built-in roles) +madsci auth bootstrap --username admin + +# Start the server +python -m madsci.auth_manager.auth_server +``` + +See `docs/guides/auth.md` and `docs/guides/auth_operator.md` for the full +architecture, token model, RBAC concepts, and operator runbook. diff --git a/src/madsci_auth_manager/madsci/auth_manager/__init__.py b/src/madsci_auth_manager/madsci/auth_manager/__init__.py new file mode 100644 index 000000000..e0dcc99f3 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/__init__.py @@ -0,0 +1,5 @@ +"""MADSci Auth Manager.""" + +__pdoc__ = { + "alembic": False, +} diff --git a/src/madsci_auth_manager/madsci/auth_manager/alembic.ini b/src/madsci_auth_manager/madsci/auth_manager/alembic.ini new file mode 100644 index 000000000..243471059 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/alembic.ini @@ -0,0 +1,41 @@ +# Alembic configuration for MADSci Auth Manager. + +[alembic] +script_location = %(here)s/alembic +prepend_sys_path = . +path_separator = os +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/src/madsci_auth_manager/madsci/auth_manager/alembic/README b/src/madsci_auth_manager/madsci/auth_manager/alembic/README new file mode 100644 index 000000000..8cfef3bb4 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/alembic/README @@ -0,0 +1,7 @@ +Generic single-database configuration for the MADSci Auth Manager. + +The initial migration is autogenerated against the SQLModel metadata in +``madsci.auth_manager.tables`` when the migration tool is first invoked +against an empty PostgreSQL database. For local/test runs the schema is +created via ``SQLAlchemyHandler.create_all_tables(metadata)`` and Alembic is +not consulted. diff --git a/src/madsci_auth_manager/madsci/auth_manager/alembic/env.py b/src/madsci_auth_manager/madsci/auth_manager/alembic/env.py new file mode 100644 index 000000000..4d6ead182 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/alembic/env.py @@ -0,0 +1,115 @@ +# alembic/env.py +# ruff: noqa +# flake8: noqa +"""Alembic environment configuration for MADSci Auth Manager.""" + +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +import sqlmodel +import sqlmodel.sql.sqltypes +from alembic import context +from sqlalchemy import engine_from_config, pool + + +def setup_python_path() -> Path: + """Ensure Python path includes the package root for imports.""" + env_file_dir = Path(__file__).resolve().parent + package_root = env_file_dir.parent + if not (package_root / "alembic.ini").exists(): + for parent in package_root.parents: + if (parent / "alembic.ini").exists(): + package_root = parent + break + package_root_str = str(package_root) + if package_root_str not in sys.path: + sys.path.insert(0, package_root_str) + return package_root + + +package_root = setup_python_path() + +from madsci.auth_manager.tables import metadata + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name, disable_existing_loggers=False) + +target_metadata = metadata + + +def get_database_url() -> str: + """Get database URL from environment variables set by migration tool.""" + db_url = os.getenv("AUTH_DB_URL") or os.getenv("AUTH_DATABASE_URL") + if db_url: + return db_url + db_url = config.get_main_option("sqlalchemy.url") + if db_url and db_url.strip(): + return db_url + raise RuntimeError( + "Database URL not provided to Alembic. Set AUTH_DB_URL or pass via " + "the migration tool." + ) + + +def include_object( + object, + name: str, + type_: str, + reflected: bool, + compare_to, +) -> bool: + """Exclude alembic_version table from autogenerate.""" + if type_ == "table" and name == "alembic_version": + return False + return True + + +def render_item(type_: str, obj, autogen_context) -> object: + """Apply custom rendering for SQLModel types.""" + if type_ == "type" and hasattr(obj, "__class__"): + if "AutoString" in str(obj.__class__): + return "sa.String()" + return False + + +def run_migrations_offline() -> None: + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + include_object=include_object, + render_item=render_item, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + url = get_database_url() + config.set_main_option("sqlalchemy.url", url) + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + include_object=include_object, + render_item=render_item, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/madsci_auth_manager/madsci/auth_manager/alembic/script.py.mako b/src/madsci_auth_manager/madsci/auth_manager/alembic/script.py.mako new file mode 100644 index 000000000..11016301e --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/src/madsci_auth_manager/madsci/auth_manager/alembic/versions/0001_initial_schema.py b/src/madsci_auth_manager/madsci/auth_manager/alembic/versions/0001_initial_schema.py new file mode 100644 index 000000000..22ab5f0fc --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/alembic/versions/0001_initial_schema.py @@ -0,0 +1,294 @@ +"""Initial schema for the Auth Manager. + +Revision ID: 0001 +Revises: +Create Date: 2026-05-04 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0001" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create all Auth Manager tables.""" + op.create_table( + "users", + sa.Column("user_id", sa.String(), primary_key=True), + sa.Column("username", sa.String(), nullable=False, unique=True), + sa.Column("email", sa.String(), nullable=True), + sa.Column("password_hash", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "projects", + sa.Column("project_id", sa.String(), primary_key=True), + sa.Column("name", sa.String(), nullable=False, unique=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "roles", + sa.Column("role_id", sa.String(), primary_key=True), + sa.Column("name", sa.String(), nullable=False, unique=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "role_permissions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "role_id", + sa.String(), + sa.ForeignKey("roles.role_id"), + nullable=False, + index=True, + ), + sa.Column("permission", sa.String(), nullable=False, index=True), + sa.UniqueConstraint("role_id", "permission", name="uix_role_permission"), + ) + + op.create_table( + "project_memberships", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "user_id", + sa.String(), + sa.ForeignKey("users.user_id"), + nullable=False, + index=True, + ), + sa.Column( + "project_id", + sa.String(), + sa.ForeignKey("projects.project_id"), + nullable=False, + index=True, + ), + sa.Column( + "role_id", + sa.String(), + sa.ForeignKey("roles.role_id"), + nullable=False, + index=True, + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.UniqueConstraint( + "user_id", "project_id", "role_id", name="uix_user_project_role" + ), + ) + + op.create_table( + "service_accounts", + sa.Column("client_id", sa.String(), primary_key=True), + sa.Column("client_secret_hash", sa.String(), nullable=False), + sa.Column("manager_id", sa.String(), nullable=False, index=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "node_identities", + sa.Column("client_id", sa.String(), primary_key=True), + sa.Column("client_secret_hash", sa.String(), nullable=False), + sa.Column("node_id", sa.String(), nullable=False, index=True), + sa.Column("workcell_id", sa.String(), nullable=True, index=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("mtls_cert_fingerprint", sa.String(), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "global_role_grants", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "role_id", + sa.String(), + sa.ForeignKey("roles.role_id"), + nullable=False, + index=True, + ), + sa.Column( + "user_id", + sa.String(), + sa.ForeignKey("users.user_id"), + nullable=True, + index=True, + ), + sa.Column( + "service_account_client_id", + sa.String(), + sa.ForeignKey("service_accounts.client_id"), + nullable=True, + index=True, + ), + sa.Column( + "node_identity_client_id", + sa.String(), + sa.ForeignKey("node_identities.client_id"), + nullable=True, + index=True, + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + op.create_table( + "refresh_tokens", + sa.Column("token_id", sa.String(), primary_key=True), + sa.Column("token_hash", sa.String(), nullable=False, unique=True), + sa.Column("principal_sub", sa.String(), nullable=False), + sa.Column("principal_type", sa.String(), nullable=False), + sa.Column( + "issued_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("revoked_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("rotated_to", sa.String(), nullable=True), + ) + op.create_index("idx_refresh_principal", "refresh_tokens", ["principal_sub"]) + + op.create_table( + "revoked_access_tokens", + sa.Column("jti", sa.String(), primary_key=True), + sa.Column("exp", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column( + "revoked_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + op.create_index("idx_revoked_exp", "revoked_access_tokens", ["exp"]) + + op.create_table( + "signing_keys", + sa.Column("kid", sa.String(), primary_key=True), + sa.Column("public_key_pem", sa.String(), nullable=False), + sa.Column("private_key_pem", sa.String(), nullable=False), + sa.Column("algorithm", sa.String(), nullable=False, server_default="RS256"), + sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column( + "active_for_signing", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("retired_at", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("event_id", sa.String(), nullable=False, unique=True), + sa.Column("event_type", sa.String(), nullable=False, index=True), + sa.Column( + "event_time", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("principal_id", sa.String(), nullable=True, index=True), + sa.Column("principal_type", sa.String(), nullable=True), + sa.Column("grant_type", sa.String(), nullable=True), + sa.Column("token_jti", sa.String(), nullable=True), + sa.Column("source_ip", sa.String(), nullable=True), + sa.Column("success", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("details", sa.JSON(), nullable=True), + ) + op.create_index("idx_audit_principal", "audit_log", ["principal_id"]) + op.create_index("idx_audit_event_time", "audit_log", ["event_time"]) + + op.create_table( + "lab_binding", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("lab_id", sa.String(), nullable=False), + sa.Column( + "bootstrapped_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + +def downgrade() -> None: + """Drop all Auth Manager tables.""" + op.drop_table("lab_binding") + op.drop_index("idx_audit_event_time", table_name="audit_log") + op.drop_index("idx_audit_principal", table_name="audit_log") + op.drop_table("audit_log") + op.drop_table("signing_keys") + op.drop_index("idx_revoked_exp", table_name="revoked_access_tokens") + op.drop_table("revoked_access_tokens") + op.drop_index("idx_refresh_principal", table_name="refresh_tokens") + op.drop_table("refresh_tokens") + op.drop_table("global_role_grants") + op.drop_table("node_identities") + op.drop_table("service_accounts") + op.drop_table("project_memberships") + op.drop_table("role_permissions") + op.drop_table("roles") + op.drop_table("projects") + op.drop_table("users") diff --git a/src/madsci_auth_manager/madsci/auth_manager/alembic/versions/0002_refresh_token_partial_unique_index.py b/src/madsci_auth_manager/madsci/auth_manager/alembic/versions/0002_refresh_token_partial_unique_index.py new file mode 100644 index 000000000..1a9fd0bdd --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/alembic/versions/0002_refresh_token_partial_unique_index.py @@ -0,0 +1,37 @@ +"""Partial unique index on refresh_tokens(token_hash) WHERE revoked_at IS NULL. + +Belt-and-suspenders for the atomic ``UPDATE ... WHERE revoked_at IS NULL +RETURNING ...`` pattern in ``TokenService.consume_refresh_token`` — guarantees +at the DB level that at most one unrevoked refresh-token row may share a +hash, even under concurrent inserts. + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-05-05 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0002" +down_revision: Union[str, Sequence[str], None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add the partial unique index. PostgreSQL supports ``WHERE`` clauses on indexes.""" + op.create_index( + "uix_refresh_tokens_active_hash", + "refresh_tokens", + ["token_hash"], + unique=True, + postgresql_where="revoked_at IS NULL", + ) + + +def downgrade() -> None: + """Drop the partial unique index.""" + op.drop_index("uix_refresh_tokens_active_hash", table_name="refresh_tokens") diff --git a/src/madsci_auth_manager/madsci/auth_manager/auth_server.py b/src/madsci_auth_manager/madsci/auth_manager/auth_server.py new file mode 100644 index 000000000..e81a8b106 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/auth_server.py @@ -0,0 +1,1365 @@ +"""MADSci Auth Manager FastAPI server. + +Implements the OAuth 2.0 token, introspection, revocation, and JWKS +endpoints, plus the admin surface for users, projects, roles, +service-accounts, node identities, signing keys, and the deny-list. + +Per Decision 12, this manager is single-tenant: all data is implicitly +scoped to the deployment's ``lab_id``. +""" +# ARG002: every admin route accepts ``request: Request`` because the +# ``@requires(...)`` decorator reads ``request.state.principal`` — the +# function bodies don't reference ``request`` directly. +# ruff: noqa: ARG002 + +from __future__ import annotations + +import ipaddress +import secrets +from datetime import datetime, timezone +from typing import Any, Optional + +import fastapi +from classy_fastapi import delete, get, patch, post +from fastapi import Form, HTTPException, Request, Response +from madsci.auth_manager.permissions import AuthPermissions +from madsci.auth_manager.server_types import ( + AddMemberRequest, + BootstrapResponse, + CreateProjectRequest, + CreateRoleRequest, + CreateUserRequest, + CredentialResponse, + DenyListResponse, + GrantRoleRequest, + IntrospectRequest, + KeyInfo, + KeysHealthResponse, + ProjectResponse, + RegisterNodeRequest, + RegisterServiceAccountRequest, + RevokeRequest, + RoleResponse, + UpdateUserRequest, + UserResponse, +) +from madsci.auth_manager.services import ( + AuditLogger, + DenyListService, + PasswordService, + SigningKeyService, + TokenService, +) +from madsci.auth_manager.services.audit_logger import AuditEvent +from madsci.auth_manager.services.token_service import TokenError, hash_refresh_token +from madsci.auth_manager.tables import ( + GlobalRoleGrantTable, + LabBindingTable, + NodeIdentityTable, + ProjectMembershipTable, + ProjectTable, + RefreshTokenTable, + RolePermissionTable, + RoleTable, + ServiceAccountTable, + UserTable, + metadata, +) +from madsci.common.auth_decorators import requires +from madsci.common.auth_middleware import current_principal +from madsci.common.db_handlers.postgres_handler import ( + PostgresHandler, + SQLAlchemyHandler, +) +from madsci.common.manager_base import AbstractManagerBase +from madsci.common.types.auth_types import ( + AuthManagerSettings, + GrantType, + PrincipalType, + TokenResponse, +) +from madsci.common.types.event_types import EventType +from madsci.common.utils import new_ulid_str +from sqlmodel import Session, select + +# Built-in role definitions seeded by ``bootstrap``. +BUILTIN_ROLES: list[dict[str, Any]] = [ + { + "name": "admin", + "description": "Full administrative privileges over the lab.", + "permissions": ["*"], + }, + { + "name": "experimenter", + "description": "Run experiments, submit workflows, manage resources.", + "permissions": [ + "experiment.read", + "experiment.write", + "workflow.read", + "workflow.submit", + "resource.read", + "resource.write", + ], + }, + { + "name": "operator", + "description": "Operate workcells and nodes.", + "permissions": [ + "workcell.read", + "workcell.execute", + "node.read", + "node.execute_action", + ], + }, + { + "name": "read_only", + "description": "Read-only access to all observable state.", + "permissions": [ + "experiment.read", + "workflow.read", + "resource.read", + "workcell.read", + "node.read", + "event.read", + ], + }, +] + + +def _client_ip(request: Request, *, trust_forwarded_for: bool = False) -> Optional[str]: + """Return the client IP for audit-log attribution. + + By default returns the socket peer; trusts ``X-Forwarded-For`` only when + the operator opts in via ``AuthManagerSettings.trust_forwarded_for``. + Untrusted forwarded headers would otherwise let any caller spoof the + audit log's ``source_ip`` column. + """ + if trust_forwarded_for: + xff = request.headers.get("x-forwarded-for") + if xff: + candidate = xff.split(",", maxsplit=1)[0].strip() + # Validate it parses as an IP; fall through to socket peer on garbage. + try: + ipaddress.ip_address(candidate) + return candidate + except ValueError: + pass + return request.client.host if request.client else None + + +class AuthManager(AbstractManagerBase[AuthManagerSettings]): + """MADSci Auth Manager REST server.""" + + SETTINGS_CLASS = AuthManagerSettings + + def __init__( + self, + settings: Optional[AuthManagerSettings] = None, + postgres_handler: Optional[PostgresHandler] = None, + **kwargs: Any, + ) -> None: + """Initialize the Auth Manager, optionally injecting a database handler.""" + self._postgres_handler = postgres_handler + super().__init__(settings=settings, **kwargs) + + def unauthenticated_paths(self) -> set[str]: + """Public endpoints required for token bootstrap and verification. + + Per the auth-manager-security-hardening change: every other admin + route on this manager carries a ``@requires(...)`` permission check. + """ + return super().unauthenticated_paths() | { + "/token", + "/.well-known/jwks.json", + "/health/keys", + "/deny-list", + # /introspect bypasses the middleware enforcement so the handler + # can implement RFC 7662's privacy-preserving "{active: false}" + # response for unauthorized callers. + "/introspect", + } + + def _setup_auth_middleware(self, app: fastapi.FastAPI) -> None: + """Install AuthMiddleware on the Auth Manager itself. + + Overrides the base-class implementation, which would try to read + ``auth_server_url`` from settings and construct a remote ``AuthClient`` + — both unnecessary here because the Auth Manager has its own + ``TokenService`` and JWKS in-process. This avoids the prefixed-alias + collision between the inherited ``auth_server_url`` field and our + own ``server_url`` (alias-generated to ``auth_server_url``). + """ + from madsci.common.auth_middleware import AuthMiddleware # noqa: PLC0415 + + # Local in-process verification: short-circuit the HTTP round-trip. + token_service = self._token_service + + class _InProcAuthClient: + """Stand-in for ``AuthClient`` that calls our own ``TokenService``.""" + + def verify_jwt(self, token: str) -> Any: + return token_service.verify_token(token) + + app.add_middleware( + AuthMiddleware, + auth_client=_InProcAuthClient(), + auth_required=self.settings.auth_required, + unauthenticated_paths=self.unauthenticated_paths(), + ) + self.logger.info( + "AuthMiddleware installed on Auth Manager (self-verifying)", + event_type=EventType.MANAGER_START, + auth_required=self.settings.auth_required, + ) + + def run_server( + self, + host: Optional[str] = None, + port: Optional[int] = None, + **uvicorn_kwargs: Any, + ) -> None: + """Bind and serve. Refuses to bind unless auth is enforced. + + Defense-in-depth against the security review's HIGH finding: an + Auth Manager started with ``auth_enabled=False`` would expose every + admin endpoint unauthenticated (``@requires`` no-ops when the + middleware isn't installed). We catch the misconfiguration at the + startup boundary rather than at first request. + """ + if not (self.settings.auth_enabled and self.settings.auth_required): + raise RuntimeError( + "AuthManager refuses to bind: settings.auth_enabled and" + " settings.auth_required must both be True. The Auth Manager" + " is the one service where unauthenticated admin endpoints" + " would compromise the entire lab's identity system." + ) + super().run_server(host=host, port=port, **uvicorn_kwargs) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def initialize(self, **kwargs: Any) -> None: + """Initialize handlers, schema, and service objects.""" + if self._postgres_handler is None: + try: + self._postgres_handler = SQLAlchemyHandler.from_url( + self.settings.database_url + ) + except Exception: + self.logger.warning( + "Could not connect to PostgreSQL via settings.database_url;" + " AuthManager initialized without persistence", + event_type=EventType.MANAGER_ERROR, + exc_info=True, + ) + return + + engine = self._postgres_handler.get_engine() + # Ensure tables exist (idempotent). In production, Alembic migrations + # handle schema; this also covers dev / SQLite test paths. + self._postgres_handler.create_all_tables(metadata) + + self._password_service = PasswordService( + time_cost=self.settings.argon2_time_cost, + memory_cost=self.settings.argon2_memory_cost, + parallelism=self.settings.argon2_parallelism, + ) + self._signing_key_service = SigningKeyService(engine) + self._deny_list_service = DenyListService( + engine, persist_grace_seconds=self.settings.deny_list_persist_grace + ) + self._audit = AuditLogger(engine) + + # Resolve / persist lab_id binding (Decision 12) + self._lab_id = self._resolve_lab_binding() + + # Decision: refuse to start without a bound lab_id. The earlier + # "lab-unbound" placeholder allowed two unrelated unbound deployments + # to mutually trust each other's tokens. + if not self._lab_id: + raise RuntimeError( + "AuthManager refuses to start: settings.lab_id is unset." + " Set AUTH_LAB_ID or pass --lab-id to bootstrap." + ) + + self._token_service = TokenService( + engine=engine, + signing_key_service=self._signing_key_service, + deny_list_service=self._deny_list_service, + issuer=str(self.settings.server_url).rstrip("/"), + audience=self._lab_id, + access_token_ttl=self.settings.access_token_ttl, + refresh_token_ttl=self.settings.refresh_token_ttl, + clock_skew_seconds=self.settings.token_clock_skew_seconds, + ) + + self.logger.info( + "AuthManager initialized", + event_type=EventType.MANAGER_START, + lab_id=self._lab_id, + ) + + def _resolve_lab_binding(self) -> Optional[str]: + """Read or create the lab_id binding row.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + existing = session.get(LabBindingTable, 1) + if existing is None: + if self.settings.lab_id is None: + return None + row = LabBindingTable(id=1, lab_id=self.settings.lab_id) + session.add(row) + session.commit() + return self.settings.lab_id + + if ( + self.settings.lab_id is not None + and existing.lab_id != self.settings.lab_id + ): + raise RuntimeError( + f"AuthManager database is bound to lab_id={existing.lab_id!r}" + f" but settings supplied lab_id={self.settings.lab_id!r}." + " Refusing to start (Decision 12)." + ) + return existing.lab_id + + # ------------------------------------------------------------------ + # Bootstrap (callable from CLI as well as direct API) + # ------------------------------------------------------------------ + + def bootstrap( + self, + *, + admin_username: str, + admin_password: str, + admin_email: Optional[str] = None, + ) -> BootstrapResponse: + """Idempotent bootstrap: create admin user, signing key, built-in roles.""" + engine = self._postgres_handler.get_engine() + + signing_kid: Optional[str] = None + with Session(engine) as session: + # Seed built-in roles + existing_roles = {r.name: r for r in session.exec(select(RoleTable)).all()} + for role_def in BUILTIN_ROLES: + if role_def["name"] in existing_roles: + continue + role = RoleTable( + name=role_def["name"], description=role_def["description"] + ) + session.add(role) + session.flush() + for perm in role_def["permissions"]: + session.add( + RolePermissionTable(role_id=role.role_id, permission=perm) + ) + existing_roles[role_def["name"]] = role + session.commit() + + admin_role = session.exec( + select(RoleTable).where(RoleTable.name == "admin") + ).first() + if admin_role is None: + raise RuntimeError("admin role missing after seeding") + + # Create admin user (idempotent on username) + admin = session.exec( + select(UserTable).where(UserTable.username == admin_username) + ).first() + if admin is None: + admin = UserTable( + username=admin_username, + email=admin_email, + password_hash=self._password_service.hash_password(admin_password), + ) + session.add(admin) + session.flush() + session.add( + GlobalRoleGrantTable( + role_id=admin_role.role_id, user_id=admin.user_id + ) + ) + session.commit() + session.refresh(admin) + + # Generate signing key if none exists + current = self._signing_key_service.get_signing_key() + if current is None: + key_row = self._signing_key_service.generate_keypair() + signing_kid = key_row.kid + else: + signing_kid = current.kid + + self._audit.log( + AuditEvent.BOOTSTRAP, + principal_id=admin.user_id, + principal_type="user", + details={ + "username": admin_username, + "signing_kid": signing_kid, + }, + ) + return BootstrapResponse( + user_id=admin.user_id, + username=admin.username, + admin_role_id=admin_role.role_id, + signing_kid=signing_kid, + ) + + # ------------------------------------------------------------------ + # Helpers used by token endpoints + # ------------------------------------------------------------------ + + def _collect_user_grants( + self, session: Session, user: UserTable + ) -> tuple[list[str], list[str], list[str]]: + """Return ``(role_ids, permissions, project_ids)`` for a user.""" + # Global role grants + role_ids: list[str] = [] + global_grants = session.exec( + select(GlobalRoleGrantTable).where( + GlobalRoleGrantTable.user_id == user.user_id + ) + ).all() + role_ids.extend([g.role_id for g in global_grants]) + + # Project memberships + memberships = session.exec( + select(ProjectMembershipTable).where( + ProjectMembershipTable.user_id == user.user_id + ) + ).all() + project_ids = sorted({m.project_id for m in memberships}) + role_ids.extend([m.role_id for m in memberships]) + role_ids = sorted(set(role_ids)) + + permissions: set[str] = set() + if role_ids: + perm_rows = session.exec( + select(RolePermissionTable).where( + RolePermissionTable.role_id.in_(role_ids) # type: ignore[union-attr] + ) + ).all() + permissions = {p.permission for p in perm_rows} + return role_ids, sorted(permissions), project_ids + + def _collect_principal_grants( + self, + session: Session, + *, + user_id: Optional[str] = None, + service_account_client_id: Optional[str] = None, + node_identity_client_id: Optional[str] = None, + ) -> tuple[list[str], list[str]]: + """Return ``(role_ids, permissions)`` for a service-account or node.""" + stmt = select(GlobalRoleGrantTable) + if service_account_client_id: + stmt = stmt.where( + GlobalRoleGrantTable.service_account_client_id + == service_account_client_id + ) + elif node_identity_client_id: + stmt = stmt.where( + GlobalRoleGrantTable.node_identity_client_id == node_identity_client_id + ) + elif user_id: + stmt = stmt.where(GlobalRoleGrantTable.user_id == user_id) + rows = list(session.exec(stmt).all()) + role_ids = sorted({r.role_id for r in rows}) + if not role_ids: + return [], [] + perm_rows = session.exec( + select(RolePermissionTable).where( + RolePermissionTable.role_id.in_(role_ids) # type: ignore[union-attr] + ) + ).all() + return role_ids, sorted({p.permission for p in perm_rows}) + + # ------------------------------------------------------------------ + # /token endpoint (RFC 6749) + # ------------------------------------------------------------------ + + @post("/token") + async def token_endpoint( + self, + request: Request, + grant_type: str = Form(...), + username: Optional[str] = Form(None), + password: Optional[str] = Form(None), + refresh_token: Optional[str] = Form(None), + client_id: Optional[str] = Form(None), + client_secret: Optional[str] = Form(None), + ) -> TokenResponse: + """OAuth 2.0 token endpoint (password, refresh_token, client_credentials).""" + ip = _client_ip(request, trust_forwarded_for=self.settings.trust_forwarded_for) + try: + gt = GrantType(grant_type) + except ValueError: + self._audit.log( + AuditEvent.TOKEN_REJECT, + source_ip=ip, + success=False, + details={"reason": "unsupported_grant_type", "grant_type": grant_type}, + ) + raise HTTPException( + status_code=400, + detail={ + "error": "unsupported_grant_type", + "error_description": ( + "grant_type must be one of password, refresh_token," + " client_credentials" + ), + }, + ) from None + + if gt == GrantType.PASSWORD: + return self._handle_password_grant(username, password, ip) + if gt == GrantType.REFRESH_TOKEN: + return self._handle_refresh_grant(refresh_token, ip) + return self._handle_client_credentials_grant(client_id, client_secret, ip) + + def _handle_password_grant( + self, username: Optional[str], password: Optional[str], ip: Optional[str] + ) -> TokenResponse: + if not username or not password: + raise HTTPException(status_code=400, detail="missing username or password") + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + user = session.exec( + select(UserTable).where(UserTable.username == username) + ).first() + if user is None or not user.is_active: + self._audit.log( + AuditEvent.TOKEN_REJECT, + source_ip=ip, + success=False, + details={ + "username": username, + "reason": "no_such_user_or_inactive", + }, + ) + raise HTTPException(status_code=401, detail="invalid_grant") + if not self._password_service.verify_password(user.password_hash, password): + self._audit.log( + AuditEvent.TOKEN_REJECT, + principal_id=user.user_id, + principal_type="user", + source_ip=ip, + success=False, + details={"reason": "bad_password"}, + ) + raise HTTPException(status_code=401, detail="invalid_grant") + + role_ids, permissions, project_ids = self._collect_user_grants( + session, user + ) + + access, claims = self._token_service.issue_access_token( + sub=user.user_id, + principal_type=PrincipalType.USER, + roles=role_ids, + permissions=permissions, + user_id=user.user_id, + project_ids=project_ids, + ) + refresh, _refresh_id = self._token_service.issue_refresh_token( + sub=user.user_id, principal_type=PrincipalType.USER + ) + self._audit.log( + AuditEvent.TOKEN_ISSUE, + principal_id=user.user_id, + principal_type="user", + grant_type=GrantType.PASSWORD.value, + token_jti=claims.jti, + source_ip=ip, + ) + return self._token_service.make_token_response( + access_token=access, + ttl=self.settings.access_token_ttl, + refresh_token=refresh, + ) + + def _handle_refresh_grant( + self, refresh_token: Optional[str], ip: Optional[str] + ) -> TokenResponse: + if not refresh_token: + raise HTTPException(status_code=400, detail="missing refresh_token") + + # Pre-issue the new refresh token so we can record its id on the + # parent's ``rotated_to`` in the same atomic UPDATE that revokes the + # parent. We need to know the principal first — peek at the row + # without consuming it via a SELECT. + token_hash = hash_refresh_token(refresh_token) + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + preview = session.exec( + select(RefreshTokenTable).where( + RefreshTokenTable.token_hash == token_hash + ) + ).first() + if preview is None: + self._audit.log( + AuditEvent.TOKEN_REJECT, + source_ip=ip, + success=False, + details={"reason": "invalid_grant"}, + ) + raise HTTPException(status_code=401, detail="invalid_grant") + + principal_type = PrincipalType(preview.principal_type) + new_refresh, new_refresh_id = self._token_service.issue_refresh_token( + sub=preview.principal_sub, principal_type=principal_type + ) + + # Atomically claim the parent. If reuse is detected, the new refresh + # we just issued is also revoked as part of the family revocation. + try: + row = self._token_service.consume_refresh_token( + refresh_token, rotated_to_token_id=new_refresh_id + ) + except TokenError as e: + self._audit.log( + AuditEvent.TOKEN_REJECT, + source_ip=ip, + success=False, + details={"reason": str(e)}, + ) + raise HTTPException(status_code=401, detail="invalid_grant") from e + + with Session(engine) as session: + if row.principal_type == PrincipalType.USER.value: + user = session.get(UserTable, row.principal_sub) + if user is None or not user.is_active: + raise HTTPException(status_code=401, detail="invalid_grant") + role_ids, permissions, project_ids = self._collect_user_grants( + session, user + ) + access, claims = self._token_service.issue_access_token( + sub=user.user_id, + principal_type=PrincipalType.USER, + roles=role_ids, + permissions=permissions, + user_id=user.user_id, + project_ids=project_ids, + ) + else: + # Refresh tokens are typically not issued for service accounts / + # nodes (client_credentials flow), but if present, re-issue. + ptype = PrincipalType(row.principal_type) + role_ids, permissions = self._collect_principal_grants( + session, + service_account_client_id=row.principal_sub + if ptype == PrincipalType.SERVICE_ACCOUNT + else None, + node_identity_client_id=row.principal_sub + if ptype == PrincipalType.NODE + else None, + ) + access, claims = self._token_service.issue_access_token( + sub=row.principal_sub, + principal_type=ptype, + roles=role_ids, + permissions=permissions, + ) + + self._audit.log( + AuditEvent.TOKEN_REFRESH, + principal_id=row.principal_sub, + principal_type=row.principal_type, + grant_type=GrantType.REFRESH_TOKEN.value, + token_jti=claims.jti, + source_ip=ip, + ) + return self._token_service.make_token_response( + access_token=access, + ttl=self.settings.access_token_ttl, + refresh_token=new_refresh, + ) + + def _handle_client_credentials_grant( + self, + client_id: Optional[str], + client_secret: Optional[str], + ip: Optional[str], + ) -> TokenResponse: + if not client_id or not client_secret: + raise HTTPException( + status_code=400, detail="missing client_id or client_secret" + ) + + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + sa = session.get(ServiceAccountTable, client_id) + node = session.get(NodeIdentityTable, client_id) if sa is None else None + + if ( + sa is not None + and sa.is_active + and self._password_service.verify_password( + sa.client_secret_hash, client_secret + ) + ): + role_ids, permissions = self._collect_principal_grants( + session, service_account_client_id=client_id + ) + access, claims = self._token_service.issue_access_token( + sub=client_id, + principal_type=PrincipalType.SERVICE_ACCOUNT, + roles=role_ids, + permissions=permissions, + manager_id=sa.manager_id, + ) + self._audit.log( + AuditEvent.TOKEN_ISSUE, + principal_id=client_id, + principal_type="service_account", + grant_type=GrantType.CLIENT_CREDENTIALS.value, + token_jti=claims.jti, + source_ip=ip, + ) + return self._token_service.make_token_response( + access_token=access, ttl=self.settings.access_token_ttl + ) + + if ( + node is not None + and node.is_active + and self._password_service.verify_password( + node.client_secret_hash, client_secret + ) + ): + role_ids, permissions = self._collect_principal_grants( + session, node_identity_client_id=client_id + ) + access, claims = self._token_service.issue_access_token( + sub=client_id, + principal_type=PrincipalType.NODE, + roles=role_ids, + permissions=permissions, + node_id=node.node_id, + workcell_id=node.workcell_id, + ) + self._audit.log( + AuditEvent.TOKEN_ISSUE, + principal_id=client_id, + principal_type="node", + grant_type=GrantType.CLIENT_CREDENTIALS.value, + token_jti=claims.jti, + source_ip=ip, + ) + return self._token_service.make_token_response( + access_token=access, ttl=self.settings.access_token_ttl + ) + + self._audit.log( + AuditEvent.TOKEN_REJECT, + source_ip=ip, + success=False, + details={"reason": "bad_client_credentials", "client_id": client_id}, + ) + raise HTTPException(status_code=401, detail="invalid_client") + + # ------------------------------------------------------------------ + # /introspect, /revoke, JWKS + # ------------------------------------------------------------------ + + @post("/introspect") + async def introspect_endpoint( + self, request: Request, body: IntrospectRequest + ) -> dict[str, Any]: + """OAuth 2.0 Token Introspection (RFC 7662). + + Per RFC 7662 §2.2 the introspection endpoint MUST NOT leak claims to + unauthenticated callers. When AuthMiddleware is installed + (``auth_enabled=True``) we require ``auth.token.introspect``; + unauthorized callers get ``{"active": false}`` (NOT 401/403, to match + the spec's privacy-preserving response shape). + """ + if hasattr(request.state, "principal"): + principal = current_principal(request) + perms = set(principal.permissions) if principal else set() + if principal is None or ( + AuthPermissions.TOKEN_INTROSPECT not in perms and "*" not in perms + ): + return {"active": False} + return self._token_service.introspect(body.token) + + @post("/revoke") + async def revoke_endpoint( # noqa: C901 + self, request: Request, body: RevokeRequest + ) -> dict[str, bool]: + """Revoke an access token and/or refresh token. + + Requires authentication when AuthMiddleware is installed. Self- + revocation (caller's own ``sub``) is always allowed; revoking + another principal's token requires ``auth.token.revoke``. + """ + principal = ( + current_principal(request) if hasattr(request.state, "principal") else None + ) + auth_active = hasattr(request.state, "principal") + + if auth_active and principal is None: + raise HTTPException(status_code=401, detail="authentication required") + + # Probe the token's sub (without revoking) to enforce self-vs-other rules. + target_claims = None + if body.token: + try: + target_claims = self._token_service.verify_token(body.token) + except TokenError: + target_claims = None + + # Probe the refresh-token row (without revoking) to enforce the + # same self-vs-other rule on the refresh-token branch. Without this, + # any authenticated principal who knows another user's refresh-token + # bearer string could revoke it (a sign-out DoS). + target_refresh_sub: Optional[str] = None + if body.refresh_token: + with Session(self._postgres_handler.get_engine()) as session: + row = session.exec( + select(RefreshTokenTable).where( + RefreshTokenTable.token_hash + == hash_refresh_token(body.refresh_token) + ) + ).first() + if row is not None: + target_refresh_sub = row.principal_sub + + if auth_active and principal is not None: + perms = set(principal.permissions) + has_revoke_perm = AuthPermissions.TOKEN_REVOKE in perms or "*" in perms + if target_claims is not None: + is_self_access = target_claims.sub == principal.sub + if not is_self_access and not has_revoke_perm: + raise HTTPException( + status_code=403, + detail=( + "missing required permission: " + f"{AuthPermissions.TOKEN_REVOKE}" + ), + ) + if target_refresh_sub is not None: + is_self_refresh = target_refresh_sub == principal.sub + if not is_self_refresh and not has_revoke_perm: + raise HTTPException( + status_code=403, + detail=( + "missing required permission: " + f"{AuthPermissions.TOKEN_REVOKE}" + ), + ) + + if body.refresh_token: + self._token_service.revoke_refresh_token(body.refresh_token) + if body.token and target_claims is not None: + self._token_service.revoke_access_token( + target_claims.jti, target_claims.exp + ) + self._audit.log( + AuditEvent.TOKEN_REVOKE, + principal_id=target_claims.sub, + principal_type=target_claims.principal_type.value, + token_jti=target_claims.jti, + ) + return {"revoked": True} + + @get("/.well-known/jwks.json") + async def jwks_endpoint(self) -> dict[str, Any]: + """Public JWKS document — no authentication required.""" + return self._signing_key_service.jwks() + + # ------------------------------------------------------------------ + # /users + # ------------------------------------------------------------------ + + @post("/users") + @requires(permission=AuthPermissions.USER_WRITE) + async def create_user( + self, request: Request, body: CreateUserRequest + ) -> UserResponse: + """Create a new user account.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + existing = session.exec( + select(UserTable).where(UserTable.username == body.username) + ).first() + if existing is not None: + raise HTTPException(status_code=409, detail="username already exists") + user = UserTable( + username=body.username, + email=body.email, + password_hash=self._password_service.hash_password(body.password), + ) + session.add(user) + session.commit() + session.refresh(user) + self._audit.log( + AuditEvent.USER_CREATE, + principal_id=user.user_id, + principal_type="user", + details={"username": user.username}, + ) + return UserResponse( + user_id=user.user_id, + username=user.username, + email=user.email, + is_active=user.is_active, + ) + + @get("/users") + @requires(permission=AuthPermissions.USER_READ) + async def list_users(self, request: Request) -> list[UserResponse]: + """List all user accounts.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + rows = session.exec(select(UserTable)).all() + return [ + UserResponse( + user_id=r.user_id, + username=r.username, + email=r.email, + is_active=r.is_active, + ) + for r in rows + ] + + @get("/users/{user_id}") + @requires(permission=AuthPermissions.USER_READ) + async def get_user(self, request: Request, user_id: str) -> UserResponse: + """Fetch a single user by id.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + row = session.get(UserTable, user_id) + if row is None: + raise HTTPException(status_code=404, detail="user not found") + return UserResponse( + user_id=row.user_id, + username=row.username, + email=row.email, + is_active=row.is_active, + ) + + @patch("/users/{user_id}") + @requires(permission=AuthPermissions.USER_WRITE) + async def update_user( + self, request: Request, user_id: str, body: UpdateUserRequest + ) -> UserResponse: + """Patch user fields (deactivate, change password, update email).""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + row = session.get(UserTable, user_id) + if row is None: + raise HTTPException(status_code=404, detail="user not found") + changed = False + if body.is_active is not None and body.is_active != row.is_active: + row.is_active = body.is_active + changed = True + self._audit.log( + AuditEvent.USER_DEACTIVATE + if not body.is_active + else "user.activate", + principal_id=row.user_id, + principal_type="user", + ) + if body.email is not None: + row.email = body.email + changed = True + if body.new_password: + row.password_hash = self._password_service.hash_password( + body.new_password + ) + changed = True + self._audit.log( + AuditEvent.USER_PASSWORD_CHANGE, + principal_id=row.user_id, + principal_type="user", + ) + if changed: + row.updated_at = datetime.now(timezone.utc) + session.add(row) + session.commit() + session.refresh(row) + return UserResponse( + user_id=row.user_id, + username=row.username, + email=row.email, + is_active=row.is_active, + ) + + # ------------------------------------------------------------------ + # /projects + # ------------------------------------------------------------------ + + @post("/projects") + @requires(permission=AuthPermissions.PROJECT_WRITE) + async def create_project( + self, request: Request, body: CreateProjectRequest + ) -> ProjectResponse: + """Create a new project.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + existing = session.exec( + select(ProjectTable).where(ProjectTable.name == body.name) + ).first() + if existing is not None: + raise HTTPException( + status_code=409, detail="project name already exists" + ) + row = ProjectTable(name=body.name, description=body.description) + session.add(row) + session.commit() + session.refresh(row) + return ProjectResponse( + project_id=row.project_id, + name=row.name, + description=row.description, + ) + + @get("/projects") + @requires(permission=AuthPermissions.PROJECT_READ) + async def list_projects(self, request: Request) -> list[ProjectResponse]: + """List all projects.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + rows = session.exec(select(ProjectTable)).all() + return [ + ProjectResponse( + project_id=r.project_id, name=r.name, description=r.description + ) + for r in rows + ] + + @post("/projects/{project_id}/members") + @requires(permission=AuthPermissions.PROJECT_WRITE) + async def add_project_member( + self, request: Request, project_id: str, body: AddMemberRequest + ) -> dict[str, str]: + """Add a user to a project with a role.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + project = session.get(ProjectTable, project_id) + if project is None: + raise HTTPException(status_code=404, detail="project not found") + user = session.get(UserTable, body.user_id) + role = session.get(RoleTable, body.role_id) + if user is None or role is None: + raise HTTPException(status_code=404, detail="user or role not found") + session.add( + ProjectMembershipTable( + user_id=body.user_id, + project_id=project_id, + role_id=body.role_id, + ) + ) + session.commit() + return {"status": "ok"} + + @delete("/projects/{project_id}/members/{user_id}") + @requires(permission=AuthPermissions.PROJECT_WRITE) + async def remove_project_member( + self, request: Request, project_id: str, user_id: str + ) -> dict[str, str]: + """Remove all memberships for a user from a project.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + stmt = select(ProjectMembershipTable).where( + ProjectMembershipTable.project_id == project_id, + ProjectMembershipTable.user_id == user_id, + ) + rows = list(session.exec(stmt).all()) + for r in rows: + session.delete(r) + session.commit() + return {"removed": str(len(rows))} + + # ------------------------------------------------------------------ + # /roles + # ------------------------------------------------------------------ + + @post("/roles") + @requires(permission=AuthPermissions.ROLE_WRITE) + async def create_role( + self, request: Request, body: CreateRoleRequest + ) -> RoleResponse: + """Create a new role with permissions.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + existing = session.exec( + select(RoleTable).where(RoleTable.name == body.name) + ).first() + if existing is not None: + raise HTTPException(status_code=409, detail="role name already exists") + role = RoleTable(name=body.name, description=body.description) + session.add(role) + session.flush() + for perm in body.permissions: + session.add(RolePermissionTable(role_id=role.role_id, permission=perm)) + session.commit() + session.refresh(role) + return RoleResponse( + role_id=role.role_id, + name=role.name, + description=role.description, + permissions=list(body.permissions), + ) + + @get("/roles") + @requires(permission=AuthPermissions.ROLE_READ) + async def list_roles(self, request: Request) -> list[RoleResponse]: + """List all roles, including their permission strings.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + roles = list(session.exec(select(RoleTable)).all()) + results: list[RoleResponse] = [] + for role in roles: + perms = session.exec( + select(RolePermissionTable).where( + RolePermissionTable.role_id == role.role_id + ) + ).all() + results.append( + RoleResponse( + role_id=role.role_id, + name=role.name, + description=role.description, + permissions=[p.permission for p in perms], + ) + ) + return results + + @post("/roles/grant") + @requires(permission=AuthPermissions.ROLE_GRANT) + async def grant_role( + self, request: Request, body: GrantRoleRequest + ) -> dict[str, str]: + """Grant a role to a user (optionally project-scoped), service account, or node.""" + engine = self._postgres_handler.get_engine() + with Session(engine) as session: + role = session.get(RoleTable, body.role_id) + if role is None: + raise HTTPException(status_code=404, detail="role not found") + if body.user_id and body.project_id: + session.add( + ProjectMembershipTable( + user_id=body.user_id, + project_id=body.project_id, + role_id=body.role_id, + ) + ) + else: + session.add( + GlobalRoleGrantTable( + role_id=body.role_id, + user_id=body.user_id, + service_account_client_id=body.service_account_client_id, + node_identity_client_id=body.node_identity_client_id, + ) + ) + session.commit() + self._audit.log( + AuditEvent.ROLE_GRANT, + principal_id=body.user_id + or body.service_account_client_id + or body.node_identity_client_id, + details=body.model_dump(exclude_none=True), + ) + return {"status": "ok"} + + # ------------------------------------------------------------------ + # /service-accounts and /node-identities + # ------------------------------------------------------------------ + + @post("/service-accounts") + @requires(permission=AuthPermissions.PRINCIPAL_WRITE) + async def register_service_account( + self, request: Request, body: RegisterServiceAccountRequest + ) -> CredentialResponse: + """Create a service-account principal and return its plaintext secret once.""" + engine = self._postgres_handler.get_engine() + client_id = f"sa-{new_ulid_str()}" + client_secret = secrets.token_urlsafe(32) + with Session(engine) as session: + session.add( + ServiceAccountTable( + client_id=client_id, + client_secret_hash=self._password_service.hash_password( + client_secret + ), + manager_id=body.manager_id, + ) + ) + for role_id in body.role_ids: + session.add( + GlobalRoleGrantTable( + role_id=role_id, service_account_client_id=client_id + ) + ) + session.commit() + self._audit.log( + AuditEvent.SERVICE_ACCOUNT_REGISTER, + principal_id=client_id, + principal_type="service_account", + details={"manager_id": body.manager_id}, + ) + return CredentialResponse(client_id=client_id, client_secret=client_secret) + + @post("/node-identities") + @requires(permission=AuthPermissions.PRINCIPAL_WRITE) + async def register_node_identity( + self, request: Request, body: RegisterNodeRequest + ) -> CredentialResponse: + """Create a node-identity principal and return its plaintext secret once.""" + engine = self._postgres_handler.get_engine() + client_id = f"node-{new_ulid_str()}" + client_secret = secrets.token_urlsafe(32) + with Session(engine) as session: + session.add( + NodeIdentityTable( + client_id=client_id, + client_secret_hash=self._password_service.hash_password( + client_secret + ), + node_id=body.node_id, + workcell_id=body.workcell_id, + ) + ) + for role_id in body.role_ids: + session.add( + GlobalRoleGrantTable( + role_id=role_id, node_identity_client_id=client_id + ) + ) + session.commit() + self._audit.log( + AuditEvent.NODE_REGISTER, + principal_id=client_id, + principal_type="node", + details={ + "node_id": body.node_id, + "workcell_id": body.workcell_id, + }, + ) + return CredentialResponse(client_id=client_id, client_secret=client_secret) + + @post("/credentials/{client_id}/rotate") + @requires(permission=AuthPermissions.CREDENTIALS_ROTATE) + async def rotate_credentials( + self, request: Request, client_id: str + ) -> CredentialResponse: + """Rotate the client_secret for a service-account or node-identity.""" + engine = self._postgres_handler.get_engine() + new_secret = secrets.token_urlsafe(32) + new_hash = self._password_service.hash_password(new_secret) + with Session(engine) as session: + sa = session.get(ServiceAccountTable, client_id) + node = session.get(NodeIdentityTable, client_id) if sa is None else None + target = sa or node + if target is None: + raise HTTPException(status_code=404, detail="client_id not found") + target.client_secret_hash = new_hash + session.add(target) + session.commit() + event = ( + AuditEvent.SERVICE_ACCOUNT_ROTATE + if isinstance(target, ServiceAccountTable) + else AuditEvent.NODE_ROTATE + ) + self._audit.log(event, principal_id=client_id) + return CredentialResponse(client_id=client_id, client_secret=new_secret) + + # ------------------------------------------------------------------ + # /keys + # ------------------------------------------------------------------ + + @post("/keys/rotate") + @requires(permission=AuthPermissions.KEY_ROTATE) + async def rotate_keys(self, request: Request) -> KeyInfo: + """Generate a new signing keypair, demoting the previous one to verify-only.""" + new_row = self._signing_key_service.rotate() + self._audit.log(AuditEvent.KEY_ROTATE, details={"kid": new_row.kid}) + return KeyInfo( + kid=new_row.kid, + algorithm=new_row.algorithm, + active=new_row.active, + active_for_signing=new_row.active_for_signing, + created_at=new_row.created_at.isoformat() if new_row.created_at else None, + ) + + @get("/keys") + @requires(permission=AuthPermissions.KEY_READ) + async def list_keys(self, request: Request) -> list[KeyInfo]: + """List all signing keys (active, retired, signing flag).""" + return [ + KeyInfo( + kid=k.kid, + algorithm=k.algorithm, + active=k.active, + active_for_signing=k.active_for_signing, + created_at=k.created_at.isoformat() if k.created_at else None, + retired_at=k.retired_at.isoformat() if k.retired_at else None, + ) + for k in self._signing_key_service.list_all_keys() + ] + + @delete("/keys/{kid}") + @requires(permission=AuthPermissions.KEY_RETIRE) + async def retire_key(self, request: Request, kid: str) -> dict[str, bool]: + """Retire a signing key (remove from JWKS, delete private material).""" + ok = self._signing_key_service.retire(kid) + if ok: + self._audit.log(AuditEvent.KEY_RETIRE, details={"kid": kid}) + return {"retired": ok} + + @get("/health/keys") + async def keys_health(self) -> KeysHealthResponse: + """Report active key count, oldest-key age, and current signing kid.""" + keys = self._signing_key_service.list_active_keys() + signing = self._signing_key_service.get_signing_key() + oldest_age: Optional[int] = None + if keys: + now = datetime.now(timezone.utc) + ages = [] + for k in keys: + created = k.created_at + if created.tzinfo is None: + created = created.replace(tzinfo=timezone.utc) + ages.append(int((now - created).total_seconds())) + oldest_age = max(ages) + return KeysHealthResponse( + active_keys=len(keys), + oldest_key_age_seconds=oldest_age, + signing_kid=signing.kid if signing else None, + ) + + # ------------------------------------------------------------------ + # /deny-list + # ------------------------------------------------------------------ + + @get("/deny-list") + async def deny_list_endpoint( + self, request: Request, response: Response + ) -> DenyListResponse: + """Return the persistent jti deny-list, with ETag conditional-fetch support.""" + snapshot = self._deny_list_service.snapshot() + etag = f'"{snapshot["etag"]}"' + if request.headers.get("if-none-match") == etag: + response.status_code = 304 + return Response(status_code=304) # type: ignore[return-value] + response.headers["ETag"] = etag + return DenyListResponse(etag=snapshot["etag"], entries=snapshot["entries"]) + + # ------------------------------------------------------------------ + # Server lifecycle + # ------------------------------------------------------------------ + + def create_server(self, **kwargs: Any) -> fastapi.FastAPI: + """Build the FastAPI application with all Auth Manager endpoints registered.""" + return super().create_server(**kwargs) + + +# Main entry point for running the server +if __name__ == "__main__": + manager = AuthManager() + manager.run_server() diff --git a/src/madsci_auth_manager/madsci/auth_manager/permissions.py b/src/madsci_auth_manager/madsci/auth_manager/permissions.py new file mode 100644 index 000000000..485ab004e --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/permissions.py @@ -0,0 +1,50 @@ +"""Canonical permission strings used by the Auth Manager itself. + +Every administrative endpoint on the Auth Manager carries a +``@requires(permission=...)`` decorator naming one of the strings below. +Operators grant these via the built-in ``admin`` role (which holds the ``*`` +wildcard), or via a custom role for narrower delegation (e.g., a separate +``key-rotator`` role for an automated key-rotation job). +""" + +from __future__ import annotations + + +class AuthPermissions: + """Auth Manager admin-endpoint permission strings.""" + + USER_READ = "auth.user.read" + USER_WRITE = "auth.user.write" + PROJECT_READ = "auth.project.read" + PROJECT_WRITE = "auth.project.write" + ROLE_READ = "auth.role.read" + ROLE_WRITE = "auth.role.write" + ROLE_GRANT = "auth.role.grant" + PRINCIPAL_WRITE = "auth.principal.write" + CREDENTIALS_ROTATE = "auth.credentials.rotate" + KEY_READ = "auth.key.read" + KEY_ROTATE = "auth.key.rotate" + KEY_RETIRE = "auth.key.retire" + TOKEN_INTROSPECT = "auth.token.introspect" # noqa: S105 + TOKEN_REVOKE = "auth.token.revoke" # noqa: S105 + + +ALL_AUTH_PERMISSIONS: tuple[str, ...] = ( + AuthPermissions.USER_READ, + AuthPermissions.USER_WRITE, + AuthPermissions.PROJECT_READ, + AuthPermissions.PROJECT_WRITE, + AuthPermissions.ROLE_READ, + AuthPermissions.ROLE_WRITE, + AuthPermissions.ROLE_GRANT, + AuthPermissions.PRINCIPAL_WRITE, + AuthPermissions.CREDENTIALS_ROTATE, + AuthPermissions.KEY_READ, + AuthPermissions.KEY_ROTATE, + AuthPermissions.KEY_RETIRE, + AuthPermissions.TOKEN_INTROSPECT, + AuthPermissions.TOKEN_REVOKE, +) + + +__all__ = ["ALL_AUTH_PERMISSIONS", "AuthPermissions"] diff --git a/src/madsci_auth_manager/madsci/auth_manager/server_types.py b/src/madsci_auth_manager/madsci/auth_manager/server_types.py new file mode 100644 index 000000000..fcac0be5f --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/server_types.py @@ -0,0 +1,185 @@ +"""Pydantic request/response models specific to the Auth Manager server.""" + +from __future__ import annotations + +from typing import Optional + +from madsci.common.types.base_types import MadsciBaseModel +from pydantic import Field + + +class CreateUserRequest(MadsciBaseModel): + """Request body for ``POST /users``.""" + + username: str + password: str + email: Optional[str] = None + + +class UserResponse(MadsciBaseModel): + """User-resource response (``password_hash`` is never returned).""" + + user_id: str + username: str + email: Optional[str] = None + is_active: bool = True + + +class UpdateUserRequest(MadsciBaseModel): + """Partial-update body for ``PATCH /users/{id}``.""" + + is_active: Optional[bool] = None + new_password: Optional[str] = None + email: Optional[str] = None + + +class CreateProjectRequest(MadsciBaseModel): + """Request body for ``POST /projects``.""" + + name: str + description: Optional[str] = None + + +class ProjectResponse(MadsciBaseModel): + """Project-resource response.""" + + project_id: str + name: str + description: Optional[str] = None + + +class AddMemberRequest(MadsciBaseModel): + """Request body for ``POST /projects/{id}/members``.""" + + user_id: str + role_id: str + + +class CreateRoleRequest(MadsciBaseModel): + """Request body for ``POST /roles``.""" + + name: str + description: Optional[str] = None + permissions: list[str] = Field(default_factory=list) + + +class RoleResponse(MadsciBaseModel): + """Role-resource response, including its flattened permission strings.""" + + role_id: str + name: str + description: Optional[str] = None + permissions: list[str] = Field(default_factory=list) + + +class GrantRoleRequest(MadsciBaseModel): + """Request body for ``POST /roles/grant``. + + Exactly one of ``user_id`` (with or without ``project_id``), + ``service_account_client_id``, or ``node_identity_client_id`` should be + supplied to identify the grant target. + """ + + role_id: str + user_id: Optional[str] = None + project_id: Optional[str] = None + service_account_client_id: Optional[str] = None + node_identity_client_id: Optional[str] = None + + +class RegisterServiceAccountRequest(MadsciBaseModel): + """Request body for ``POST /service-accounts``.""" + + manager_id: str + role_ids: list[str] = Field(default_factory=list) + + +class RegisterNodeRequest(MadsciBaseModel): + """Request body for ``POST /node-identities``.""" + + node_id: str + workcell_id: Optional[str] = None + role_ids: list[str] = Field(default_factory=list) + + +class CredentialResponse(MadsciBaseModel): + """Response that returns a freshly-issued client_id + plaintext secret. + + The plaintext secret is returned exactly once; only its Argon2 hash is + stored. Callers are responsible for distributing the secret out-of-band. + """ + + client_id: str + client_secret: str + note: str = "Store this secret immediately — it will never be displayed again." + + +class IntrospectRequest(MadsciBaseModel): + """Request body for ``POST /introspect`` (RFC 7662).""" + + token: str + + +class RevokeRequest(MadsciBaseModel): + """Request body for ``POST /revoke``. + + Either ``token`` (an access-token JWT) or ``refresh_token`` may be set; + callers usually send both during logout. + """ + + token: Optional[str] = None + refresh_token: Optional[str] = None + + +class KeyInfo(MadsciBaseModel): + """Public summary of a signing key (``private_key_pem`` is never returned).""" + + kid: str + algorithm: str + active: bool + active_for_signing: bool + created_at: Optional[str] = None + retired_at: Optional[str] = None + + +class KeysHealthResponse(MadsciBaseModel): + """Response body for ``GET /health/keys``.""" + + active_keys: int + oldest_key_age_seconds: Optional[int] = None + signing_kid: Optional[str] = None + + +class DenyListEntry(MadsciBaseModel): + """A single entry in the deny-list (jti + its access-token expiration).""" + + jti: str + exp: int + + +class DenyListResponse(MadsciBaseModel): + """Response body for ``GET /deny-list``. + + Consumers SHOULD send ``If-None-Match: ""`` on subsequent polls + to receive HTTP 304 when the list is unchanged. + """ + + etag: str + entries: list[DenyListEntry] + + +class BootstrapResponse(MadsciBaseModel): + """Response body for the bootstrap CLI / API call.""" + + user_id: str + username: str + admin_role_id: str + signing_kid: str + note: str = "Bootstrap successful — store the admin password securely." + + +class TokenErrorResponse(MadsciBaseModel): + """OAuth 2.0 token-endpoint error body (RFC 6749 §5.2).""" + + error: str + error_description: Optional[str] = None diff --git a/src/madsci_auth_manager/madsci/auth_manager/services/__init__.py b/src/madsci_auth_manager/madsci/auth_manager/services/__init__.py new file mode 100644 index 000000000..7936fb4b8 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/services/__init__.py @@ -0,0 +1,20 @@ +"""Service-layer modules for the Auth Manager. + +These services encapsulate the cryptographic and persistence operations the +``AuthManager`` server class depends on, keeping the FastAPI layer focused on +HTTP concerns. +""" + +from madsci.auth_manager.services.audit_logger import AuditLogger +from madsci.auth_manager.services.deny_list_service import DenyListService +from madsci.auth_manager.services.password_service import PasswordService +from madsci.auth_manager.services.signing_key_service import SigningKeyService +from madsci.auth_manager.services.token_service import TokenService + +__all__ = [ + "AuditLogger", + "DenyListService", + "PasswordService", + "SigningKeyService", + "TokenService", +] diff --git a/src/madsci_auth_manager/madsci/auth_manager/services/audit_logger.py b/src/madsci_auth_manager/madsci/auth_manager/services/audit_logger.py new file mode 100644 index 000000000..b476584da --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/services/audit_logger.py @@ -0,0 +1,107 @@ +"""Append-only audit log for the Auth Manager.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from madsci.auth_manager.tables import AuditLogTable +from sqlmodel import Session, select + + +class AuditLogger: + """Persist security-relevant events to the ``audit_log`` table. + + Per the ``Audit log`` requirement in ``auth-identity-model/spec.md``, the + table is append-only at the application level. There is no public + ``update``/``delete`` API; any attempt to mutate a row by an admin must + itself produce a new audit entry recording the attempt. + """ + + def __init__(self, engine: Any) -> None: + """Bind the logger to a SQLAlchemy engine.""" + self._engine = engine + + def log( + self, + event_type: str, + *, + principal_id: Optional[str] = None, + principal_type: Optional[str] = None, + grant_type: Optional[str] = None, + token_jti: Optional[str] = None, + source_ip: Optional[str] = None, + success: bool = True, + details: Optional[dict] = None, + ) -> AuditLogTable: + """Append a new audit row and return it. + + Raises whatever the underlying DB raises — callers MUST NOT swallow + these exceptions for state-changing operations (failure-closed). + """ + with Session(self._engine) as session: + row = AuditLogTable( + event_type=event_type, + event_time=datetime.now(timezone.utc), + principal_id=principal_id, + principal_type=principal_type, + grant_type=grant_type, + token_jti=token_jti, + source_ip=source_ip, + success=success, + details=details, + ) + session.add(row) + session.commit() + session.refresh(row) + return row + + def query( + self, + *, + principal_id: Optional[str] = None, + event_type: Optional[str] = None, + limit: int = 100, + ) -> list[AuditLogTable]: + """Query audit rows with optional filters; newest first.""" + with Session(self._engine) as session: + stmt = select(AuditLogTable).order_by(AuditLogTable.event_time.desc()) + if principal_id: + stmt = stmt.where(AuditLogTable.principal_id == principal_id) + if event_type: + stmt = stmt.where(AuditLogTable.event_type == event_type) + stmt = stmt.limit(limit) + return list(session.exec(stmt).all()) + + +# Canonical event-type names — kept here so callers can reference constants +# rather than naked strings, and so the docs guide can enumerate them. + + +class AuditEvent: + """Canonical audit event type strings. + + These are event-type names persisted in the ``audit_log.event_type`` + column, NOT secrets. The S105 suppression on this class quiets ruff's + hardcoded-password heuristic for the ``TOKEN_*`` and ``USER_PASSWORD_*`` + constants. + """ + + USER_CREATE = "user.create" + USER_DEACTIVATE = "user.deactivate" + USER_PASSWORD_CHANGE = "user.password_change" # noqa: S105 + ROLE_GRANT = "role.grant" + ROLE_REVOKE = "role.revoke" + TOKEN_ISSUE = "token.issue" # noqa: S105 + TOKEN_REFRESH = "token.refresh" # noqa: S105 + TOKEN_REVOKE = "token.revoke" # noqa: S105 + TOKEN_REJECT = "token.reject" # noqa: S105 + SERVICE_ACCOUNT_REGISTER = "service_account.register" + SERVICE_ACCOUNT_ROTATE = "service_account.rotate" + NODE_REGISTER = "node.register" + NODE_ROTATE = "node.rotate" + BOOTSTRAP = "bootstrap" + KEY_ROTATE = "key.rotate" + KEY_RETIRE = "key.retire" + AUDIT_TAMPER_ATTEMPT = "audit.tamper_attempt" + RATE_LIMITED = "rate_limited" diff --git a/src/madsci_auth_manager/madsci/auth_manager/services/deny_list_service.py b/src/madsci_auth_manager/madsci/auth_manager/services/deny_list_service.py new file mode 100644 index 000000000..2139631e6 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/services/deny_list_service.py @@ -0,0 +1,130 @@ +"""Deny-list service for revoked access-token jtis. + +Revoked jtis are persisted to ``revoked_access_tokens`` and cached in memory +for fast read at the ``GET /deny-list`` endpoint. The cache is hydrated from +the database on startup so revocations survive Auth Manager restarts. + +Entries whose ``exp`` is in the past are evicted both from the in-memory set +and from the database, bounding the list size to currently-revoked-and-still- +unexpired tokens. +""" + +from __future__ import annotations + +import hashlib +import json +import threading +from datetime import datetime, timedelta, timezone +from typing import Any + +from madsci.auth_manager.tables import RevokedAccessTokenTable +from sqlmodel import Session, delete, select + + +class DenyListService: + """Persistent jti deny-list with in-memory cache and ETag support.""" + + def __init__(self, engine: Any, *, persist_grace_seconds: int = 300) -> None: + """Bind the deny-list to a SQLAlchemy engine and hydrate from the table.""" + self._engine = engine + self._persist_grace = persist_grace_seconds + self._lock = threading.RLock() + # jti -> exp (unix epoch seconds) + self._cache: dict[str, int] = {} + self._etag = "0" + self._hydrate() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def _hydrate(self) -> None: + """Rebuild the in-memory cache from the persisted table.""" + with self._lock: + self._cache.clear() + now = datetime.now(timezone.utc) + with Session(self._engine) as session: + rows = session.exec(select(RevokedAccessTokenTable)).all() + for row in rows: + exp = row.exp + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + if exp >= now: + self._cache[row.jti] = int(exp.timestamp()) + self._recompute_etag() + + def _recompute_etag(self) -> None: + # Stable hash of (jti, exp) tuples so consumers can use If-None-Match. + items = sorted(self._cache.items()) + h = hashlib.sha256(json.dumps(items, sort_keys=True).encode("utf-8")) + self._etag = h.hexdigest() + + # ------------------------------------------------------------------ + # Mutations + # ------------------------------------------------------------------ + + def revoke(self, jti: str, exp_unix: int) -> None: + """Revoke a jti with the given expiration (unix seconds).""" + exp_dt = datetime.fromtimestamp(exp_unix, tz=timezone.utc) + with self._lock, Session(self._engine) as session: + existing = session.get(RevokedAccessTokenTable, jti) + if existing is None: + session.add(RevokedAccessTokenTable(jti=jti, exp=exp_dt)) + session.commit() + self._cache[jti] = exp_unix + self._recompute_etag() + + def is_revoked(self, jti: str) -> bool: + """Return True if ``jti`` is in the deny-list and not yet expired.""" + with self._lock: + exp = self._cache.get(jti) + if exp is None: + return False + if exp < int(datetime.now(timezone.utc).timestamp()): + # lazily evict + self._cache.pop(jti, None) + self._recompute_etag() + return False + return True + + def evict_expired(self) -> int: + """Evict expired entries from cache and DB. Returns count removed.""" + now = datetime.now(timezone.utc) + cutoff = now - timedelta(seconds=self._persist_grace) + removed = 0 + with self._lock: + for jti, exp_unix in list(self._cache.items()): + if exp_unix < int(now.timestamp()): + self._cache.pop(jti, None) + removed += 1 + with Session(self._engine) as session: + session.exec( + delete(RevokedAccessTokenTable).where( + RevokedAccessTokenTable.exp < cutoff + ) + ) + session.commit() + if removed: + self._recompute_etag() + return removed + + # ------------------------------------------------------------------ + # Read API for /deny-list endpoint + # ------------------------------------------------------------------ + + def snapshot(self) -> dict[str, Any]: + """Snapshot for the ``GET /deny-list`` response.""" + with self._lock: + return { + "etag": self._etag, + "entries": [ + {"jti": jti, "exp": exp_unix} + for jti, exp_unix in sorted(self._cache.items()) + ], + } + + @property + def etag(self) -> str: + """Current ETag of the deny-list snapshot (sha256 over (jti, exp) tuples).""" + with self._lock: + return self._etag diff --git a/src/madsci_auth_manager/madsci/auth_manager/services/password_service.py b/src/madsci_auth_manager/madsci/auth_manager/services/password_service.py new file mode 100644 index 000000000..31685a654 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/services/password_service.py @@ -0,0 +1,40 @@ +"""Argon2id password hashing helpers.""" + +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + + +class PasswordService: + """Wrapper around argon2-cffi for password hashing and verification.""" + + def __init__( + self, + time_cost: int = 3, + memory_cost: int = 64 * 1024, + parallelism: int = 4, + ) -> None: + """Configure the underlying ``argon2.PasswordHasher``.""" + self._hasher = PasswordHasher( + time_cost=time_cost, + memory_cost=memory_cost, + parallelism=parallelism, + ) + + def hash_password(self, password: str) -> str: + """Hash a plaintext password with Argon2id.""" + return self._hasher.hash(password) + + def verify_password(self, password_hash: str, password: str) -> bool: + """Verify a password against a stored hash. Returns False on mismatch.""" + try: + return self._hasher.verify(password_hash, password) + except VerifyMismatchError: + return False + except Exception: + return False + + def needs_rehash(self, password_hash: str) -> bool: + """Whether the stored hash should be re-hashed with current params.""" + return self._hasher.check_needs_rehash(password_hash) diff --git a/src/madsci_auth_manager/madsci/auth_manager/services/signing_key_service.py b/src/madsci_auth_manager/madsci/auth_manager/services/signing_key_service.py new file mode 100644 index 000000000..e09d139d6 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/services/signing_key_service.py @@ -0,0 +1,182 @@ +"""RSA signing-key management for the Auth Manager. + +Implements key generation, persistence, rotation, and JWKS export. RS256 is +the only supported algorithm (per Decision 1). +""" + +from __future__ import annotations + +import base64 +from datetime import datetime, timezone +from typing import Any, Optional + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import ( + load_pem_private_key, + load_pem_public_key, +) +from madsci.auth_manager.tables import SigningKeyTable +from madsci.common.utils import new_ulid_str +from sqlmodel import Session, select + + +def _b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii") + + +def _int_to_b64u(i: int) -> str: + byte_len = (i.bit_length() + 7) // 8 + return _b64u(i.to_bytes(byte_len, "big")) + + +class SigningKeyService: + """Manage rotating RSA signing keys.""" + + def __init__(self, engine: Any, key_size: int = 2048) -> None: + """Bind to a SQLAlchemy engine and choose the RSA key size in bits.""" + self._engine = engine + self._key_size = key_size + + # ------------------------------------------------------------------ + # Generation / persistence + # ------------------------------------------------------------------ + + def generate_keypair(self, *, set_signing: bool = True) -> SigningKeyTable: + """Generate a new RSA keypair and persist it. + + Args: + set_signing: If True (default), the new key becomes the + ``active_for_signing`` key and any previously-signing key is + downgraded to verify-only. + """ + private = rsa.generate_private_key( + public_exponent=65537, key_size=self._key_size + ) + private_pem = private.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + public_pem = ( + private.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("ascii") + ) + kid = new_ulid_str() + + with Session(self._engine) as session: + if set_signing: + # Demote any previously-signing key to verify-only + stmt = select(SigningKeyTable).where( + SigningKeyTable.active_for_signing.is_(True) + ) + for old in session.exec(stmt).all(): + old.active_for_signing = False + session.add(old) + + new_row = SigningKeyTable( + kid=kid, + public_key_pem=public_pem, + private_key_pem=private_pem, + algorithm="RS256", + active=True, + active_for_signing=set_signing, + ) + session.add(new_row) + session.commit() + session.refresh(new_row) + return new_row + + # ------------------------------------------------------------------ + # Lookup helpers + # ------------------------------------------------------------------ + + def get_signing_key(self) -> Optional[SigningKeyTable]: + """Return the currently-active signing key, or None if none exists.""" + with Session(self._engine) as session: + stmt = ( + select(SigningKeyTable) + .where(SigningKeyTable.active_for_signing.is_(True)) + .order_by(SigningKeyTable.created_at.desc()) + ) + return session.exec(stmt).first() + + def get_key(self, kid: str) -> Optional[SigningKeyTable]: + """Look up a signing key by kid.""" + with Session(self._engine) as session: + return session.get(SigningKeyTable, kid) + + def list_active_keys(self) -> list[SigningKeyTable]: + """All keys currently published in JWKS (i.e., active=True).""" + with Session(self._engine) as session: + stmt = ( + select(SigningKeyTable) + .where(SigningKeyTable.active.is_(True)) + .order_by(SigningKeyTable.created_at.desc()) + ) + return list(session.exec(stmt).all()) + + def list_all_keys(self) -> list[SigningKeyTable]: + """All keys including retired ones, newest first.""" + with Session(self._engine) as session: + stmt = select(SigningKeyTable).order_by(SigningKeyTable.created_at.desc()) + return list(session.exec(stmt).all()) + + # ------------------------------------------------------------------ + # Rotation / retire + # ------------------------------------------------------------------ + + def rotate(self) -> SigningKeyTable: + """Generate a new signing key, demoting the current one to verify-only.""" + return self.generate_keypair(set_signing=True) + + def retire(self, kid: str) -> bool: + """Retire a key (remove from JWKS, delete private material). + + Returns True if a row was modified, False otherwise. + """ + with Session(self._engine) as session: + row = session.get(SigningKeyTable, kid) + if row is None: + return False + row.active = False + row.active_for_signing = False + row.private_key_pem = "" + row.retired_at = datetime.now(timezone.utc) + session.add(row) + session.commit() + return True + + # ------------------------------------------------------------------ + # JWKS export + # ------------------------------------------------------------------ + + def jwks(self) -> dict[str, list[dict[str, str]]]: + """Return a JWKS document for all currently-active keys.""" + keys = [] + for row in self.list_active_keys(): + pub = load_pem_public_key(row.public_key_pem.encode("ascii")) + numbers = pub.public_numbers() # type: ignore[attr-defined] + keys.append( + { + "kty": "RSA", + "use": "sig", + "alg": row.algorithm, + "kid": row.kid, + "n": _int_to_b64u(numbers.n), + "e": _int_to_b64u(numbers.e), + } + ) + return {"keys": keys} + + def load_private_key(self, row: SigningKeyTable) -> Any: + """Load the private key for signing operations.""" + return load_pem_private_key(row.private_key_pem.encode("ascii"), password=None) + + def load_public_key(self, row: SigningKeyTable) -> Any: + """Load the public key for verification operations.""" + return load_pem_public_key(row.public_key_pem.encode("ascii")) diff --git a/src/madsci_auth_manager/madsci/auth_manager/services/token_service.py b/src/madsci_auth_manager/madsci/auth_manager/services/token_service.py new file mode 100644 index 000000000..c3195360c --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/services/token_service.py @@ -0,0 +1,365 @@ +"""JWT issuance, verification, and refresh-token management.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from joserfc import jwt +from joserfc.jwk import KeySet, RSAKey +from joserfc.jwt import JWTClaimsRegistry +from madsci.auth_manager.services.deny_list_service import DenyListService +from madsci.auth_manager.services.signing_key_service import SigningKeyService +from madsci.auth_manager.tables import RefreshTokenTable +from madsci.common.types.auth_types import ( + JWTClaims, + PrincipalType, + TokenResponse, +) +from madsci.common.utils import new_ulid_str +from sqlalchemy import update +from sqlmodel import Session, select + + +def _hash_refresh(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def hash_refresh_token(token: str) -> str: + """Public helper: SHA-256 hash a refresh token for table lookup. + + Hashing is deterministic — only the hash is persisted, never the raw + token — so this helper is safe to use anywhere a lookup-by-token is + needed (e.g., the Auth Manager's pre-rotation peek). + """ + return _hash_refresh(token) + + +def _ensure_aware(dt: datetime) -> datetime: + """Coerce a naive datetime to UTC. + + Some database backends (notably SQLite via SQLAlchemy) drop tzinfo on + round-trip; we normalize to UTC so comparisons remain correct. + """ + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + + +class TokenError(Exception): + """Raised on token verification / lookup failures.""" + + +class TokenService: + """Issue, verify, and revoke MADSci access + refresh tokens.""" + + # JWT verification is hard-pinned to RS256. Adding a non-RS256 algorithm + # would be a breaking change to the issuer too, not a runtime knob. + ALLOWED_ALGORITHMS: tuple[str, ...] = ("RS256",) + + def __init__( + self, + *, + engine: Any, + signing_key_service: SigningKeyService, + deny_list_service: DenyListService, + issuer: str, + audience: str, + access_token_ttl: int = 900, + refresh_token_ttl: int = 60 * 60 * 24 * 30, + clock_skew_seconds: int = 30, + ) -> None: + """Wire the token service to its signing-key, deny-list, and lab identity.""" + self._engine = engine + self._sks = signing_key_service + self._dls = deny_list_service + self._issuer = issuer + self._audience = audience + self._access_ttl = access_token_ttl + self._refresh_ttl = refresh_token_ttl + self._clock_skew = clock_skew_seconds + + # ------------------------------------------------------------------ + # Access tokens + # ------------------------------------------------------------------ + + def issue_access_token( + self, + *, + sub: str, + principal_type: PrincipalType, + roles: Optional[list[str]] = None, + permissions: Optional[list[str]] = None, + user_id: Optional[str] = None, + project_ids: Optional[list[str]] = None, + manager_id: Optional[str] = None, + node_id: Optional[str] = None, + workcell_id: Optional[str] = None, + ttl: Optional[int] = None, + ) -> tuple[str, JWTClaims]: + """Sign a new access token. Returns ``(jwt_str, claims_model)``.""" + signing_row = self._sks.get_signing_key() + if signing_row is None: + raise TokenError("No active signing key; bootstrap required.") + + now = int(datetime.now(timezone.utc).timestamp()) + ttl = ttl or self._access_ttl + jti = new_ulid_str() + claims_dict: dict[str, Any] = { + "iss": self._issuer, + "aud": self._audience, + "sub": sub, + "iat": now, + "exp": now + ttl, + "jti": jti, + "principal_type": principal_type.value, + "roles": list(roles or []), + "permissions": list(permissions or []), + } + if user_id is not None: + claims_dict["user_id"] = user_id + if project_ids is not None: + claims_dict["project_ids"] = list(project_ids) + if manager_id is not None: + claims_dict["manager_id"] = manager_id + if node_id is not None: + claims_dict["node_id"] = node_id + if workcell_id is not None: + claims_dict["workcell_id"] = workcell_id + + header = {"alg": signing_row.algorithm, "kid": signing_row.kid, "typ": "JWT"} + signing_key = RSAKey.import_key( + signing_row.private_key_pem, parameters={"kid": signing_row.kid} + ) + # joserfc.jwt.encode returns a str directly (authlib returned bytes). + token_str = jwt.encode( + header, claims_dict, signing_key, algorithms=list(self.ALLOWED_ALGORITHMS) + ) + + claims = JWTClaims(**dict(claims_dict)) + return token_str, claims + + # ------------------------------------------------------------------ + # Refresh tokens + # ------------------------------------------------------------------ + + def issue_refresh_token( + self, + *, + sub: str, + principal_type: PrincipalType, + ttl: Optional[int] = None, + ) -> tuple[str, str]: + """Generate an opaque refresh token and persist its hash. + + Returns ``(opaque_token, row_token_id)`` so the caller can record the + new row's id on the parent row's ``rotated_to`` column when this is + issued as part of a rotation. + """ + token = secrets.token_urlsafe(48) + ttl = ttl or self._refresh_ttl + now = datetime.now(timezone.utc) + + with Session(self._engine) as session: + row = RefreshTokenTable( + token_hash=_hash_refresh(token), + principal_sub=sub, + principal_type=principal_type.value, + issued_at=now, + expires_at=now + timedelta(seconds=ttl), + ) + session.add(row) + session.commit() + session.refresh(row) + return token, row.token_id + + def consume_refresh_token( + self, refresh_token: str, *, rotated_to_token_id: Optional[str] = None + ) -> RefreshTokenTable: + """Atomically validate-and-revoke the matching refresh-token row. + + Raises ``TokenError`` for invalid / expired / already-revoked tokens. + + Concurrency: the revoke is implemented as a single + ``UPDATE ... WHERE revoked_at IS NULL RETURNING ...`` so two parallel + consumers of the same refresh token cannot both succeed. If the + update affects zero rows, we re-fetch by ``token_hash`` to + distinguish "doesn't exist" (invalid_grant) from "already revoked" + (reuse — fire family-revocation). + + ``rotated_to_token_id`` is recorded on the parent row so future + forensic queries can walk the rotation chain. + """ + token_hash = _hash_refresh(refresh_token) + now = datetime.now(timezone.utc) + # Stamp every concurrent update with a unique marker so that AFTER + # commit we can identify which thread actually performed the + # transition from "active" to "revoked". This is more robust than + # ``rowcount`` (which is unreliable across SQLite + thread pools) + # and works for any backend. + claim_marker = rotated_to_token_id or new_ulid_str() + with Session(self._engine) as session: + stmt = ( + update(RefreshTokenTable) + .where( + RefreshTokenTable.token_hash == token_hash, + RefreshTokenTable.revoked_at.is_(None), # type: ignore[union-attr] + ) + .values(revoked_at=now, rotated_to=claim_marker) + ) + session.execute(stmt) + session.commit() + + row = session.exec( + select(RefreshTokenTable).where( + RefreshTokenTable.token_hash == token_hash + ) + ).first() + + if row is None: + # No row at all — token doesn't exist, OR was retention-swept. + raise TokenError("invalid_grant") + if _ensure_aware(row.expires_at) < now: + raise TokenError("invalid_grant: expired") + if row.rotated_to == claim_marker: + # WE were the thread that flipped this row. + return row + # Someone else (or a previous request) already revoked this. + # Reuse detected: revoke the entire family. + self._revoke_all_for_principal(session, row.principal_sub) + session.commit() + raise TokenError("invalid_grant: refresh-token reuse detected") + + def _revoke_all_for_principal(self, session: Session, principal_sub: str) -> int: + stmt = select(RefreshTokenTable).where( + RefreshTokenTable.principal_sub == principal_sub, + RefreshTokenTable.revoked_at.is_(None), # type: ignore[union-attr] + ) + rows = list(session.exec(stmt).all()) + now = datetime.now(timezone.utc) + for r in rows: + r.revoked_at = now + session.add(r) + return len(rows) + + def revoke_refresh_token(self, refresh_token: str) -> bool: + """Mark a refresh token as revoked. Returns True if a row was changed.""" + token_hash = _hash_refresh(refresh_token) + with Session(self._engine) as session: + stmt = select(RefreshTokenTable).where( + RefreshTokenTable.token_hash == token_hash + ) + row = session.exec(stmt).first() + if row is None or row.revoked_at is not None: + return False + row.revoked_at = datetime.now(timezone.utc) + session.add(row) + session.commit() + return True + + # ------------------------------------------------------------------ + # Verification / introspection + # ------------------------------------------------------------------ + + def _enforce_algorithm(self, token: str) -> None: + """Reject tokens whose JWS header alg is not in ALLOWED_ALGORITHMS. + + Closes the classic alg-confusion attack (e.g., HS256-with-public-key + forgery) before the token reaches joserfc's decoder. + """ + try: + header_b64 = token.split(".", maxsplit=1)[0] + padding = 4 - (len(header_b64) % 4) + if padding != 4: + header_b64 += "=" * padding + header = json.loads(base64.urlsafe_b64decode(header_b64)) + alg = header.get("alg") + except Exception as e: + raise TokenError("invalid_token: malformed header") from e + if alg not in self.ALLOWED_ALGORITHMS: + raise TokenError(f"invalid_token: disallowed alg {alg!r}") + + def verify_token(self, token: str) -> JWTClaims: + """Verify a JWT against the JWKS and return its claims. + + Checks signature, ``exp``, ``iss``, ``aud`` and the deny-list. Raises + ``TokenError`` on failure. + """ + active_keys = self._sks.list_active_keys() + if not active_keys: + raise TokenError("no signing keys available") + + # Build a KeySet from the active public keys (joserfc's preferred + # input). The SigningKeyService keeps PEMs locally, so we don't need + # to round-trip through a JWKS dict here. + key_set = KeySet( + [ + RSAKey.import_key(row.public_key_pem, parameters={"kid": row.kid}) + for row in active_keys + ] + ) + + # Defense-in-depth: pre-parse the JWS header and reject anything that + # isn't on our allowlist before we hand the token to joserfc. This + # closes the alg-confusion path even if a malformed key were ever + # added to the set (e.g., an HS-typed key snuck in). + self._enforce_algorithm(token) + + try: + decoded = jwt.decode( + token, key_set, algorithms=list(self.ALLOWED_ALGORITHMS) + ) + JWTClaimsRegistry( + iss={"essential": True, "value": self._issuer}, + aud={"essential": True, "value": self._audience}, + exp={"essential": True}, + leeway=self._clock_skew, + ).validate(decoded.claims) + except Exception as e: + raise TokenError(f"invalid_token: {e}") from e + + try: + claims = JWTClaims(**decoded.claims) + except Exception as e: + raise TokenError(f"invalid_token_claims: {e}") from e + + if self._dls.is_revoked(claims.jti): + raise TokenError("invalid_token: revoked") + + return claims + + def introspect(self, token: str) -> dict[str, Any]: + """RFC 7662 introspection. Returns ``{'active': False}`` for any failure.""" + try: + claims = self.verify_token(token) + except TokenError: + return {"active": False} + out = claims.model_dump() + out["active"] = True + return out + + def revoke_access_token(self, jti: str, exp_unix: int) -> None: + """Add an access token's jti to the deny-list.""" + self._dls.revoke(jti, exp_unix) + + # ------------------------------------------------------------------ + # Composite responses + # ------------------------------------------------------------------ + + def make_token_response( + self, + *, + access_token: str, + ttl: int, + refresh_token: Optional[str] = None, + ) -> TokenResponse: + """Build the OAuth 2.0 token-endpoint response body.""" + return TokenResponse( + access_token=access_token, + expires_in=ttl, + refresh_token=refresh_token, + ) diff --git a/src/madsci_auth_manager/madsci/auth_manager/tables.py b/src/madsci_auth_manager/madsci/auth_manager/tables.py new file mode 100644 index 000000000..7e43d7353 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/tables.py @@ -0,0 +1,407 @@ +"""SQLModel tables for the Auth Manager. + +All entities are scoped to a single ``lab_id`` (Decision 12). The schema is +single-tenant and intentionally has no ``tenant_id`` column. + +Tables: + +- ``users`` — local user accounts with Argon2id password hashes +- ``projects`` — project records +- ``project_memberships`` — many-to-many user ↔ project ↔ role +- ``roles`` — named bundles of permissions +- ``role_permissions`` — many-to-many role ↔ permission string +- ``service_accounts`` — manager principals +- ``node_identities`` — node principals (with reserved ``mtls_cert_fingerprint``) +- ``refresh_tokens`` — opaque refresh tokens, server-stored +- ``revoked_access_tokens`` — persistent deny-list (jti, exp, revoked_at) +- ``signing_keys`` — RSA keypairs for JWT signing +- ``audit_log`` — append-only security event log + +The ``mtls_cert_fingerprint`` column on ``node_identities`` is forward-compat +with the future mTLS follow-on; it is not validated or used by this change. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from madsci.common.utils import new_ulid_str +from sqlalchemy import Index, UniqueConstraint +from sqlalchemy.sql.schema import Column +from sqlalchemy.sql.sqltypes import TIMESTAMP +from sqlmodel import JSON, Field, SQLModel, text + + +def _utc_default() -> datetime: + return datetime.now(timezone.utc) + + +class UserTable(SQLModel, table=True): + """Local user account.""" + + __tablename__ = "users" + + user_id: str = Field( + default_factory=new_ulid_str, + primary_key=True, + description="ULID identifier for the user.", + ) + username: str = Field( + sa_column=Column( + "username", + nullable=False, + unique=True, + type_=__import__("sqlalchemy").String, + ), + description="Unique login name.", + ) + email: Optional[str] = Field(default=None, description="Optional email.") + password_hash: str = Field( + nullable=False, description="Argon2id hash of the password." + ) + is_active: bool = Field(default=True, nullable=False) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + updated_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class ProjectTable(SQLModel, table=True): + """Project record.""" + + __tablename__ = "projects" + + project_id: str = Field(default_factory=new_ulid_str, primary_key=True) + name: str = Field(nullable=False, unique=True) + description: Optional[str] = Field(default=None) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class RoleTable(SQLModel, table=True): + """Role record (a named bundle of permissions).""" + + __tablename__ = "roles" + + role_id: str = Field(default_factory=new_ulid_str, primary_key=True) + name: str = Field(nullable=False, unique=True) + description: Optional[str] = Field(default=None) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class RolePermissionTable(SQLModel, table=True): + """Many-to-many between roles and permission strings. + + Permissions are stored as plain strings (``.``) drawn + from the canonical namespace documented in ``docs/guides/auth.md``. + """ + + __tablename__ = "role_permissions" + __table_args__ = ( + UniqueConstraint("role_id", "permission", name="uix_role_permission"), + ) + + id: Optional[int] = Field(default=None, primary_key=True) + role_id: str = Field(nullable=False, foreign_key="roles.role_id", index=True) + permission: str = Field(nullable=False, index=True) + + +class ProjectMembershipTable(SQLModel, table=True): + """A user's role grant within a project.""" + + __tablename__ = "project_memberships" + __table_args__ = ( + UniqueConstraint( + "user_id", "project_id", "role_id", name="uix_user_project_role" + ), + ) + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: str = Field(nullable=False, foreign_key="users.user_id", index=True) + project_id: str = Field( + nullable=False, foreign_key="projects.project_id", index=True + ) + role_id: str = Field(nullable=False, foreign_key="roles.role_id", index=True) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class GlobalRoleGrantTable(SQLModel, table=True): + """Global (non-project-scoped) role grants for users / service accounts / nodes. + + A row applies to exactly one principal. The unused id columns are NULL. + """ + + __tablename__ = "global_role_grants" + + id: Optional[int] = Field(default=None, primary_key=True) + role_id: str = Field(nullable=False, foreign_key="roles.role_id", index=True) + user_id: Optional[str] = Field( + default=None, foreign_key="users.user_id", index=True + ) + service_account_client_id: Optional[str] = Field( + default=None, + foreign_key="service_accounts.client_id", + index=True, + ) + node_identity_client_id: Optional[str] = Field( + default=None, + foreign_key="node_identities.client_id", + index=True, + ) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class ServiceAccountTable(SQLModel, table=True): + """Service account principal (a manager service).""" + + __tablename__ = "service_accounts" + + client_id: str = Field(primary_key=True, description="OAuth client_id.") + client_secret_hash: str = Field( + nullable=False, description="Argon2id hash of the client_secret." + ) + manager_id: str = Field( + nullable=False, index=True, description="ULID of the represented manager." + ) + is_active: bool = Field(default=True, nullable=False) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class NodeIdentityTable(SQLModel, table=True): + """Node principal.""" + + __tablename__ = "node_identities" + + client_id: str = Field(primary_key=True, description="OAuth client_id.") + client_secret_hash: str = Field( + nullable=False, description="Argon2id hash of the client_secret." + ) + node_id: str = Field( + nullable=False, index=True, description="ULID of the represented node." + ) + workcell_id: Optional[str] = Field( + default=None, index=True, description="Optional workcell scope." + ) + is_active: bool = Field(default=True, nullable=False) + mtls_cert_fingerprint: Optional[str] = Field( + default=None, + description=( + "Reserved for the future mTLS follow-on. SHA-256 fingerprint of" + " the node's mTLS client certificate." + ), + ) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class RefreshTokenTable(SQLModel, table=True): + """Opaque refresh token, server-stored.""" + + __tablename__ = "refresh_tokens" + __table_args__ = (Index("idx_refresh_principal", "principal_sub"),) + + token_id: str = Field(default_factory=new_ulid_str, primary_key=True) + token_hash: str = Field( + nullable=False, unique=True, description="SHA-256 hash of the opaque token." + ) + principal_sub: str = Field( + nullable=False, description="The sub claim this refresh token belongs to." + ) + principal_type: str = Field(nullable=False) + issued_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + expires_at: datetime = Field(nullable=False, sa_type=TIMESTAMP(timezone=True)) + revoked_at: Optional[datetime] = Field( + default=None, sa_type=TIMESTAMP(timezone=True) + ) + rotated_to: Optional[str] = Field( + default=None, + description="token_id of the refresh token this was rotated into, if any.", + ) + + +class RevokedAccessTokenTable(SQLModel, table=True): + """Persistent deny-list of revoked access-token jtis.""" + + __tablename__ = "revoked_access_tokens" + __table_args__ = (Index("idx_revoked_exp", "exp"),) + + jti: str = Field(primary_key=True) + exp: datetime = Field(nullable=False, sa_type=TIMESTAMP(timezone=True)) + revoked_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +class SigningKeyTable(SQLModel, table=True): + """RSA signing keypair for JWT issuance.""" + + __tablename__ = "signing_keys" + + kid: str = Field(primary_key=True, description="Key ID (ULID).") + public_key_pem: str = Field(nullable=False) + private_key_pem: str = Field( + nullable=False, + description=( + "PEM-encoded private key. Operators are responsible for" + " disk/database encryption-at-rest." + ), + ) + algorithm: str = Field(default="RS256", nullable=False) + active: bool = Field( + default=True, + nullable=False, + description="Whether the key is published in JWKS for verification.", + ) + active_for_signing: bool = Field( + default=False, + nullable=False, + description="Whether new tokens are signed with this key.", + ) + created_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + retired_at: Optional[datetime] = Field( + default=None, sa_type=TIMESTAMP(timezone=True) + ) + + +class AuditLogTable(SQLModel, table=True): + """Append-only audit log.""" + + __tablename__ = "audit_log" + __table_args__ = ( + Index("idx_audit_principal", "principal_id"), + Index("idx_audit_event_time", "event_time"), + ) + + id: Optional[int] = Field(default=None, primary_key=True) + event_id: str = Field(default_factory=new_ulid_str, unique=True, nullable=False) + event_type: str = Field(nullable=False, index=True) + event_time: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + principal_id: Optional[str] = Field(default=None, index=True) + principal_type: Optional[str] = Field(default=None) + grant_type: Optional[str] = Field(default=None) + token_jti: Optional[str] = Field(default=None) + source_ip: Optional[str] = Field(default=None) + success: bool = Field(default=True, nullable=False) + details: Optional[dict] = Field(default=None, sa_type=JSON) + + +class LabBindingTable(SQLModel, table=True): + """Records the lab_id this Auth Manager database is bound to. + + Per Decision 12, an Auth Manager refuses to start later against a + different lab_id without an explicit operator-acknowledged migration. + """ + + __tablename__ = "lab_binding" + + id: int = Field(primary_key=True, default=1) + lab_id: str = Field(nullable=False) + bootstrapped_at: datetime = Field( + default_factory=_utc_default, + sa_type=TIMESTAMP(timezone=True), + sa_column_kwargs={ + "nullable": False, + "server_default": text("CURRENT_TIMESTAMP"), + }, + ) + + +# Convenience handle for create_all / Alembic +metadata = SQLModel.metadata + + +__all__ = [ + "AuditLogTable", + "GlobalRoleGrantTable", + "LabBindingTable", + "NodeIdentityTable", + "ProjectMembershipTable", + "ProjectTable", + "RefreshTokenTable", + "RevokedAccessTokenTable", + "RolePermissionTable", + "RoleTable", + "ServiceAccountTable", + "SigningKeyTable", + "UserTable", + "metadata", +] diff --git a/src/madsci_auth_manager/madsci/auth_manager/testing.py b/src/madsci_auth_manager/madsci/auth_manager/testing.py new file mode 100644 index 000000000..8ae0fc995 --- /dev/null +++ b/src/madsci_auth_manager/madsci/auth_manager/testing.py @@ -0,0 +1,102 @@ +"""Reusable in-memory Auth Manager fixture and helpers for tests. + +Importable by any test suite that needs a real Auth Manager wired up against +``SQLiteHandler`` plus an ``AuthClient`` whose HTTP transport is bound to +the in-memory FastAPI app via ``httpx.MockTransport``. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Iterator + +import httpx +from fastapi.testclient import TestClient +from madsci.auth_manager.auth_server import AuthManager +from madsci.client.auth_client import AuthClient +from madsci.common.db_handlers.postgres_handler import SQLiteHandler +from madsci.common.types.auth_types import AuthManagerSettings +from madsci.common.utils import new_ulid_str + + +def make_auth_manager( + *, + lab_id: str | None = None, + admin_username: str = "admin", + admin_password: str = "hunter2", # noqa: S107 + auth_enforced: bool = False, +) -> AuthManager: + """Build a fully-bootstrapped in-memory AuthManager. + + ``auth_enforced=False`` (the default for tests) disables the auth + middleware so admin endpoints can be exercised without going through + the password-grant + bearer-token dance. Production deployments default + to ``True`` (security review HIGH finding); pass ``True`` here when the + test specifically wants to verify auth enforcement. + """ + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id=lab_id or new_ulid_str(), + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + if not auth_enforced: + settings.auth_enabled = False + settings.auth_required = False + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username=admin_username, admin_password=admin_password) + return mgr + + +def make_mock_transport(mgr: AuthManager) -> httpx.MockTransport: + """Build an httpx MockTransport that forwards requests to ``mgr``.""" + test_client = TestClient(mgr.create_server()) + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.raw_path.decode("ascii") + kwargs: dict = {"headers": dict(request.headers.items())} + if request.content: + kwargs["content"] = request.content + resp = test_client.request(request.method, path, **kwargs) + return httpx.Response( + status_code=resp.status_code, + headers=dict(resp.headers), + content=resp.content, + ) + + return httpx.MockTransport(handler) + + +def make_auth_client(mgr: AuthManager) -> AuthClient: + """Build an AuthClient whose HTTP transport is bound to ``mgr``.""" + transport = make_mock_transport(mgr) + client = AuthClient(auth_server_url="http://localhost:8007/") + client._http = httpx.Client( + base_url=client.auth_server_url, + transport=transport, + timeout=10.0, + ) + return client + + +@contextmanager +def in_memory_auth( + *, lab_id: str | None = None +) -> Iterator[tuple[AuthManager, AuthClient]]: + """Context-managed (mgr, client) pair for one-off use.""" + mgr = make_auth_manager(lab_id=lab_id) + client = make_auth_client(mgr) + try: + yield mgr, client + finally: + client.close() + + +__all__ = [ + "in_memory_auth", + "make_auth_client", + "make_auth_manager", + "make_mock_transport", +] diff --git a/src/madsci_auth_manager/pyproject.toml b/src/madsci_auth_manager/pyproject.toml new file mode 100644 index 000000000..cb09bb112 --- /dev/null +++ b/src/madsci_auth_manager/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "madsci.auth_manager" +version = "0.8.0-rc.1" +description = "The Modular Autonomous Discovery for Science (MADSci) Auth Manager." +authors = [ + {name = "Tobias Ginsburg", email = "tginsburg@anl.gov"}, + {name = "Ryan D. Lewis", email = "ryan.lewis@anl.gov"}, + {name = "Casey Stone", email = "cstone@anl.gov"}, + {name = "Doga Ozgulbas", email = "dozgulbas@anl.gov"}, +] +requires-python = ">=3.10.0" +readme = "README.md" +license = {text = "MIT"} +dependencies = [ + "madsci.common", + "madsci.client", + "psycopg2-binary", + "alembic>=1.13.0", + "joserfc>=1.0.0", + "argon2-cffi>=23.1.0", + "cryptography>=42.0.0", +] + +[project.urls] +Homepage = "https://github.com/AD-SDL/MADSci" + + +###################### +# Build Info + Tools # +###################### + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" + +[tool.pdm.build] +includes = ["madsci/"] diff --git a/src/madsci_auth_manager/tests/__init__.py b/src/madsci_auth_manager/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/madsci_auth_manager/tests/test_auth_server.py b/src/madsci_auth_manager/tests/test_auth_server.py new file mode 100644 index 000000000..fc78ab480 --- /dev/null +++ b/src/madsci_auth_manager/tests/test_auth_server.py @@ -0,0 +1,300 @@ +# ruff: noqa: S105, S106 +"""Integration tests for the AuthManager FastAPI server.""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from madsci.auth_manager.auth_server import AuthManager +from madsci.common.db_handlers.postgres_handler import SQLiteHandler +from madsci.common.types.auth_types import AuthManagerSettings +from madsci.common.utils import new_ulid_str + + +@pytest.fixture +def server() -> tuple[AuthManager, TestClient]: + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id="lab-test", + otel_enabled=False, + # speed up tests + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + # Explicit opt-out of auth enforcement for these unit tests. The Auth + # Manager defaults to ``auth_enabled=True`` in production (security + # review HIGH finding) — these tests exercise the raw HTTP surface + # without going through the password grant + bearer-token flow. + settings.auth_enabled = False + settings.auth_required = False + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username="admin", admin_password="hunter2") + app = mgr.create_server() + return mgr, TestClient(app) + + +def test_health(server) -> None: + _, client = server + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["healthy"] is True + + +def test_jwks(server) -> None: + _, client = server + r = client.get("/.well-known/jwks.json") + assert r.status_code == 200 + body = r.json() + assert len(body["keys"]) == 1 + assert body["keys"][0]["kty"] == "RSA" + + +def test_password_grant_and_refresh(server) -> None: + _, client = server + r = client.post( + "/token", + data={ + "grant_type": "password", + "username": "admin", + "password": "hunter2", + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["token_type"] == "Bearer" + access = body["access_token"] + refresh = body["refresh_token"] + assert access and refresh + + # Introspect + r = client.post("/introspect", json={"token": access}) + assert r.status_code == 200 + intro = r.json() + assert intro["active"] is True + assert intro["principal_type"] == "user" + + # Refresh + r = client.post( + "/token", data={"grant_type": "refresh_token", "refresh_token": refresh} + ) + assert r.status_code == 200 + new_body = r.json() + assert new_body["access_token"] != access + assert new_body["refresh_token"] != refresh + + # Reuse-detection + r = client.post( + "/token", data={"grant_type": "refresh_token", "refresh_token": refresh} + ) + assert r.status_code == 401 + + +def test_unsupported_grant_type(server) -> None: + _, client = server + r = client.post("/token", data={"grant_type": "authorization_code"}) + assert r.status_code == 400 + assert r.json()["detail"]["error"] == "unsupported_grant_type" + + +def test_bad_password(server) -> None: + _, client = server + r = client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "wrong"}, + ) + assert r.status_code == 401 + + +def test_inactive_user(server) -> None: + _, client = server + # Create a user, then deactivate + new_pw = "x" * 12 + r = client.post("/users", json={"username": "bob", "password": new_pw}) + assert r.status_code == 200 + user_id = r.json()["user_id"] + r = client.patch(f"/users/{user_id}", json={"is_active": False}) + assert r.status_code == 200 + r = client.post( + "/token", + data={"grant_type": "password", "username": "bob", "password": new_pw}, + ) + assert r.status_code == 401 + + +def test_duplicate_username_409(server) -> None: + _, client = server + r = client.post("/users", json={"username": "admin", "password": "x" * 12}) + assert r.status_code == 409 + + +def test_revoke_access_token(server) -> None: + _, client = server + r = client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "hunter2"}, + ) + access = r.json()["access_token"] + r = client.post("/revoke", json={"token": access}) + assert r.status_code == 200 + + # Now introspection says inactive + r = client.post("/introspect", json={"token": access}) + assert r.json()["active"] is False + + +def test_deny_list_etag(server) -> None: + _, client = server + r1 = client.get("/deny-list") + assert r1.status_code == 200 + etag = r1.headers["ETag"] + + # Conditional fetch + r2 = client.get("/deny-list", headers={"if-none-match": etag}) + assert r2.status_code == 304 + + +def test_keys_health(server) -> None: + _, client = server + r = client.get("/health/keys") + assert r.status_code == 200 + body = r.json() + assert body["active_keys"] == 1 + assert body["signing_kid"] + + +def test_key_rotate(server) -> None: + _, client = server + r = client.get("/keys") + n_before = len(r.json()) + r = client.post("/keys/rotate") + assert r.status_code == 200 + r = client.get("/keys") + assert len(r.json()) == n_before + 1 + + +def test_register_service_account_and_client_credentials(server) -> None: + _, client = server + r = client.post( + "/service-accounts", json={"manager_id": new_ulid_str(), "role_ids": []} + ) + assert r.status_code == 200, r.text + cred = r.json() + cid, secret = cred["client_id"], cred["client_secret"] + + r = client.post( + "/token", + data={ + "grant_type": "client_credentials", + "client_id": cid, + "client_secret": secret, + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["access_token"] + # No refresh token for client_credentials + assert body.get("refresh_token") is None + + +def test_register_node_and_client_credentials(server) -> None: + _, client = server + r = client.post( + "/node-identities", + json={"node_id": new_ulid_str(), "workcell_id": new_ulid_str()}, + ) + assert r.status_code == 200, r.text + cred = r.json() + r = client.post( + "/token", + data={ + "grant_type": "client_credentials", + "client_id": cred["client_id"], + "client_secret": cred["client_secret"], + }, + ) + assert r.status_code == 200 + + +def test_create_role_and_grant(server) -> None: + _, client = server + r = client.post( + "/roles", + json={ + "name": "test_role", + "permissions": ["test.read"], + }, + ) + assert r.status_code == 200 + role_id = r.json()["role_id"] + + r = client.get("/roles") + names = [x["name"] for x in r.json()] + assert "test_role" in names + + # Grant globally to admin user + users = client.get("/users").json() + admin_id = next(u["user_id"] for u in users if u["username"] == "admin") + r = client.post( + "/roles/grant", + json={"role_id": role_id, "user_id": admin_id}, + ) + assert r.status_code == 200 + + +def test_project_create_and_membership(server) -> None: + _, client = server + r = client.post("/projects", json={"name": "proj-x"}) + pid = r.json()["project_id"] + + users = client.get("/users").json() + admin_id = next(u["user_id"] for u in users if u["username"] == "admin") + roles = client.get("/roles").json() + role_id = next(r["role_id"] for r in roles if r["name"] == "experimenter") + + r = client.post( + f"/projects/{pid}/members", + json={"user_id": admin_id, "role_id": role_id}, + ) + assert r.status_code == 200 + + # Token for admin should now include project_ids + r = client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "hunter2"}, + ) + intro = client.post("/introspect", json={"token": r.json()["access_token"]}).json() + assert pid in intro["project_ids"] + + +def test_credential_rotation(server) -> None: + _, client = server + r = client.post( + "/service-accounts", json={"manager_id": new_ulid_str(), "role_ids": []} + ) + cid, old_secret = r.json()["client_id"], r.json()["client_secret"] + r = client.post(f"/credentials/{cid}/rotate") + new_secret = r.json()["client_secret"] + assert new_secret != old_secret + + # Old secret no longer works + r = client.post( + "/token", + data={ + "grant_type": "client_credentials", + "client_id": cid, + "client_secret": old_secret, + }, + ) + assert r.status_code == 401 + + # New secret does + r = client.post( + "/token", + data={ + "grant_type": "client_credentials", + "client_id": cid, + "client_secret": new_secret, + }, + ) + assert r.status_code == 200 diff --git a/src/madsci_auth_manager/tests/test_integration.py b/src/madsci_auth_manager/tests/test_integration.py new file mode 100644 index 000000000..bee6933c5 --- /dev/null +++ b/src/madsci_auth_manager/tests/test_integration.py @@ -0,0 +1,233 @@ +# ruff: noqa: PLC0415 +"""End-to-end integration tests for the Auth Manager foundation. + +These exercise the full bootstrap → token → manager-call → revoke lifecycle +across the AuthManager service, AuthClient library, AuthMiddleware, and the +``@requires`` decorator. +""" + +from __future__ import annotations + +from typing import Iterator + +import httpx +import pytest +from classy_fastapi import get +from fastapi import Request +from fastapi.testclient import TestClient +from madsci.auth_manager.auth_server import AuthManager +from madsci.auth_manager.testing import make_auth_client, make_auth_manager +from madsci.client.auth_client import AuthClient +from madsci.common.auth_decorators import requires +from madsci.common.manager_base import AbstractManagerBase +from madsci.common.types.manager_types import ( + ManagerSettings, + ManagerType, +) +from madsci.common.utils import new_ulid_str +from pydantic import AnyUrl + +_LAB_ID = new_ulid_str() + + +class _DemoSettings(ManagerSettings): + server_url: AnyUrl = AnyUrl("http://localhost:9999") + manager_type: ManagerType | None = None + + +class _ProtectedManager(AbstractManagerBase[_DemoSettings]): + SETTINGS_CLASS = _DemoSettings + + @get("/whoami") + @requires(permission="event.read") + async def whoami(self, request: Request) -> dict: + principal = request.state.principal + return { + "sub": principal.sub, + "principal_type": principal.principal_type.value, + } + + +@pytest.fixture +def auth_pair() -> Iterator[tuple[AuthManager, AuthClient]]: + mgr = make_auth_manager(lab_id=_LAB_ID) + client = make_auth_client(mgr) + try: + yield mgr, client + finally: + client.close() + + +def _protected_app(auth_client: AuthClient, *, required: bool = True): + settings = _DemoSettings( + enable_registry_resolution=False, + otel_enabled=False, + auth_enabled=True, + auth_required=required, + auth_server_url=AnyUrl("http://localhost:8007/"), + ) + demo = _ProtectedManager(settings=settings) + app = demo.create_server() + for mw in app.user_middleware: + if "AuthMiddleware" in str(mw.cls): + mw.kwargs["auth_client"] = auth_client + return TestClient(app) + + +# 11.2 - bootstrap → user login → call protected endpoint +def test_full_user_login_flow(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, ac = auth_pair + # Grant the admin user the event.read permission via the read_only role + roles = ac.list_roles() + read_only_id = next(r["role_id"] for r in roles if r["name"] == "read_only") + admin_id = next(u["user_id"] for u in ac.list_users() if u["username"] == "admin") + ac.grant_role(role_id=read_only_id, user_id=admin_id) + + tok = ac.login("admin", "hunter2") + client = _protected_app(ac, required=True) + r = client.get("/whoami", headers={"Authorization": f"Bearer {tok.access_token}"}) + assert r.status_code == 200, r.text + assert r.json()["principal_type"] == "user" + + +# 11.3 - service-account client_credentials → manager call +def test_service_account_call(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, ac = auth_pair + # Grant read_only globally to the new SA so token has event.read + roles = ac.list_roles() + read_only_id = next(r["role_id"] for r in roles if r["name"] == "read_only") + cred = ac.register_service_account(new_ulid_str(), [read_only_id]) + + sa_client = ac + sa_client.client_credentials_login(cred["client_id"], cred["client_secret"]) + client = _protected_app(ac, required=True) + r = client.get( + "/whoami", + headers={"Authorization": f"Bearer {ac.access_token}"}, + ) + assert r.status_code == 200, r.text + assert r.json()["principal_type"] == "service_account" + + +# 11.4 - refresh-token rotation including reuse-detection +def test_refresh_rotation_and_reuse_detection( + auth_pair: tuple[AuthManager, AuthClient], +) -> None: + _, ac = auth_pair + tok = ac.login("admin", "hunter2") + first_rt = tok.refresh_token + + new_tok = ac.refresh() + assert new_tok.refresh_token != first_rt + + # Now try to reuse the *original* refresh token + ac._refresh_token = first_rt + with pytest.raises(httpx.HTTPStatusError): + ac.refresh() + + # All refresh tokens for the principal should now be revoked — even the + # newly issued one + ac._refresh_token = new_tok.refresh_token + with pytest.raises(httpx.HTTPStatusError): + ac.refresh() + + +# 11.5 - JWKS rotation while in-flight token still valid +def test_jwks_rotation_keeps_in_flight_tokens_valid( + auth_pair: tuple[AuthManager, AuthClient], +) -> None: + mgr, ac = auth_pair + tok = ac.login("admin", "hunter2") + # Rotate signing keys at the manager directly + mgr._signing_key_service.rotate() + # Force the client to re-fetch JWKS + ac.jwks(force_refresh=True) + # In-flight token should still verify + claims = ac.verify_jwt(tok.access_token) + assert claims.user_id + + +# 11.6 - project-scoped @requires denies non-member +def test_project_scoped_requires(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, ac = auth_pair + # Build a manager whose endpoint requires membership in path's project_id + settings = _DemoSettings( + enable_registry_resolution=False, + otel_enabled=False, + auth_enabled=True, + auth_required=True, + auth_server_url=AnyUrl("http://localhost:8007/"), + ) + + class _ProjectManager(AbstractManagerBase[_DemoSettings]): + SETTINGS_CLASS = _DemoSettings + + @get("/projects/{project_id}/items") + @requires(permission="experiment.write", project_from="project_id") + async def items(self, request: Request, project_id: str) -> dict: + return {"project_id": project_id} + + demo = _ProjectManager(settings=settings) + app = demo.create_server() + for mw in app.user_middleware: + if "AuthMiddleware" in str(mw.cls): + mw.kwargs["auth_client"] = ac + client = TestClient(app) + + # Grant experimenter role to admin globally so the permission is satisfied + roles = ac.list_roles() + exp_role = next(r["role_id"] for r in roles if r["name"] == "experimenter") + admin_id = next(u["user_id"] for u in ac.list_users() if u["username"] == "admin") + ac.grant_role(role_id=exp_role, user_id=admin_id) + + tok = ac.login("admin", "hunter2") + fake_proj = new_ulid_str() + r = client.get( + f"/projects/{fake_proj}/items", + headers={"Authorization": f"Bearer {tok.access_token}"}, + ) + assert r.status_code == 403 + + +# 11.8 - deny-list flow +def test_deny_list_flow_revokes_token_at_consumer( + auth_pair: tuple[AuthManager, AuthClient], +) -> None: + _, ac = auth_pair + roles = ac.list_roles() + read_only_id = next(r["role_id"] for r in roles if r["name"] == "read_only") + admin_id = next(u["user_id"] for u in ac.list_users() if u["username"] == "admin") + ac.grant_role(role_id=read_only_id, user_id=admin_id) + + tok = ac.login("admin", "hunter2") + client = _protected_app(ac, required=True) + r = client.get("/whoami", headers={"Authorization": f"Bearer {tok.access_token}"}) + assert r.status_code == 200 + + # Revoke the access token at the Auth Manager + ac.revoke(token=tok.access_token) + # Force the client cache to refresh so the deny-list is applied + ac.force_deny_list_refresh() + # Subsequent verify should fail and the AuthMiddleware should 401 + r = client.get("/whoami", headers={"Authorization": f"Bearer {tok.access_token}"}) + assert r.status_code == 401 + + +# 11.10 - deny-list restart durability +def test_deny_list_persists_across_restart( + auth_pair: tuple[AuthManager, AuthClient], +) -> None: + mgr, ac = auth_pair + tok = ac.login("admin", "hunter2") + ac.revoke(token=tok.access_token) + snap = mgr._deny_list_service.snapshot() + revoked_jtis = {e["jti"] for e in snap["entries"]} + assert revoked_jtis # something is in the persistent table + + # Build a new DenyListService (simulating Auth Manager restart) using the + # same database + from madsci.auth_manager.services import DenyListService + + fresh = DenyListService(mgr._postgres_handler.get_engine()) + fresh_snap = fresh.snapshot() + assert {e["jti"] for e in fresh_snap["entries"]} == revoked_jtis diff --git a/src/madsci_auth_manager/tests/test_security_hardening.py b/src/madsci_auth_manager/tests/test_security_hardening.py new file mode 100644 index 000000000..77eea7b15 --- /dev/null +++ b/src/madsci_auth_manager/tests/test_security_hardening.py @@ -0,0 +1,709 @@ +# ruff: noqa: S106, ARG001, PLC0415 +"""Security-hardening regression tests for the Auth Manager. + +Each test pins one of the merge-blocking findings from the security review +(see openspec/changes/auth-manager-security-hardening/). +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import ipaddress +import json +import sqlite3 +import threading +from datetime import datetime, timezone + +import pytest +from fastapi.testclient import TestClient +from joserfc import jwt +from joserfc.jwk import RSAKey +from madsci.auth_manager.auth_server import AuthManager, _client_ip +from madsci.auth_manager.services.token_service import ( + TokenError, + hash_refresh_token, +) +from madsci.auth_manager.tables import RefreshTokenTable +from madsci.common.db_handlers.postgres_handler import SQLiteHandler +from madsci.common.types.auth_types import ( + AuthManagerSettings, +) +from madsci.common.utils import new_ulid_str +from sqlmodel import Session, select + +# --------------------------------------------------------------------------- +# Shared fixture (mirrors test_auth_server.server fixture) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def server() -> tuple[AuthManager, TestClient]: + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id="lab-test", + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + # These tests exercise the raw HTTP surface; the dedicated + # ``server_with_auth`` fixture below covers the middleware path. + settings.auth_enabled = False + settings.auth_required = False + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username="admin", admin_password="hunter2") + return mgr, TestClient(mgr.create_server()) + + +# --------------------------------------------------------------------------- +# Tasks 1.3 / 1.4: algorithm pinning +# --------------------------------------------------------------------------- + + +def test_token_service_rejects_alg_none(server) -> None: + mgr, _ = server + # Build a JWT with alg=none and the right claims. + header = ( + base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()) + .rstrip(b"=") + .decode() + ) + now = int(datetime.now(timezone.utc).timestamp()) + payload = ( + base64.urlsafe_b64encode( + json.dumps( + { + "iss": str(mgr.settings.server_url).rstrip("/"), + "aud": "lab-test", + "sub": "anyone", + "iat": now, + "exp": now + 60, + "jti": new_ulid_str(), + "principal_type": "user", + "permissions": ["*"], + } + ).encode() + ) + .rstrip(b"=") + .decode() + ) + fake = f"{header}.{payload}." + + with pytest.raises(TokenError) as exc: + mgr._token_service.verify_token(fake) + assert "disallowed alg" in str(exc.value) + + +def test_token_service_rejects_hs256_using_public_key(server) -> None: + mgr, _ = server + # Pull the public key PEM and construct an HS256 token using it as the + # HMAC secret — the classic alg-confusion attack. + signing_row = mgr._signing_key_service.get_signing_key() + public_pem = signing_row.public_key_pem.encode() + + header = ( + base64.urlsafe_b64encode( + json.dumps({"alg": "HS256", "typ": "JWT", "kid": signing_row.kid}).encode() + ) + .rstrip(b"=") + .decode() + ) + now = int(datetime.now(timezone.utc).timestamp()) + payload = ( + base64.urlsafe_b64encode( + json.dumps( + { + "iss": str(mgr.settings.server_url).rstrip("/"), + "aud": "lab-test", + "sub": "anyone", + "iat": now, + "exp": now + 60, + "jti": new_ulid_str(), + "principal_type": "user", + "permissions": ["*"], + } + ).encode() + ) + .rstrip(b"=") + .decode() + ) + signing_input = f"{header}.{payload}".encode() + sig = hmac.new(public_pem, signing_input, hashlib.sha256).digest() + sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b"=").decode() + forged = f"{header}.{payload}.{sig_b64}" + + with pytest.raises(TokenError) as exc: + mgr._token_service.verify_token(forged) + assert "disallowed alg" in str(exc.value) + + +def test_auth_client_rejects_alg_none() -> None: + from madsci.client.auth_client import AuthClient, AuthClientError + + client = AuthClient(auth_server_url="http://example.invalid/") + bad = ( + base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()) + .rstrip(b"=") + .decode() + + ".eyJ9.X" + ) + with pytest.raises(AuthClientError): + client.verify_jwt(bad) + + +# --------------------------------------------------------------------------- +# Task 2.5: Auth Manager refuses to start without lab_id +# --------------------------------------------------------------------------- + + +def test_auth_manager_settings_default_to_auth_enabled() -> None: + """Production safety: AuthManagerSettings defaults must enforce auth. + + Pins the fix for the security review's HIGH finding — a fresh + deployment with no overrides must NOT expose admin endpoints + unauthenticated. + """ + settings = AuthManagerSettings( + enable_registry_resolution=False, lab_id=new_ulid_str() + ) + assert settings.auth_enabled is True + assert settings.auth_required is True + + +def test_auth_manager_run_server_refuses_unsafe_config(tmp_path) -> None: + """run_server() refuses to bind unless auth is enforced. + + Defense-in-depth: even if an operator deliberately overrode the safe + defaults, the production startup path catches the misconfiguration + rather than silently exposing the admin surface. + """ + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id=new_ulid_str(), + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + settings.auth_enabled = False + settings.auth_required = False + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + with pytest.raises(RuntimeError) as exc: + mgr.run_server() + assert "auth_enabled" in str(exc.value) + + +def test_auth_manager_refuses_to_start_without_lab_id() -> None: + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id=None, + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + with pytest.raises(RuntimeError) as exc: + AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + assert "lab_id" in str(exc.value) + + +# --------------------------------------------------------------------------- +# Tasks 3.5 / 3.6 / 10.2: atomic refresh-token consumption + rotated_to +# --------------------------------------------------------------------------- + + +def test_sqlite_supports_returning_required_for_atomic_refresh() -> None: + # The atomic refresh-token consumption uses UPDATE...RETURNING via + # SQLAlchemy core; SQLite >= 3.35 supports it, which is what our test + # environment ships with. + parts = sqlite3.sqlite_version.split(".") + major, minor = int(parts[0]), int(parts[1]) + assert (major, minor) >= (3, 35), ( + f"SQLite >= 3.35 required for atomic refresh-token consumption; " + f"have {sqlite3.sqlite_version}" + ) + + +def test_concurrent_refresh_at_most_one_succeeds(server) -> None: + """Atomic claim invariant: no two concurrent refreshes both succeed. + + SQLite under thread contention can produce ``database is locked`` errors + that surface as non-TokenError exceptions; we tolerate those as failures + (not successes). The security-relevant property is that AT MOST one + thread observes the row as freshly-revoked — never two. + """ + mgr, client = server + r = client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "hunter2"}, + ) + refresh = r.json()["refresh_token"] + + successes: list[str] = [] + reuse_detected: list[str] = [] + other_errors: list[str] = [] + + def worker() -> None: + try: + new_id = new_ulid_str() + row = mgr._token_service.consume_refresh_token( + refresh, rotated_to_token_id=new_id + ) + successes.append(row.token_id) + except TokenError as e: + if "reuse" in str(e): + reuse_detected.append(str(e)) + else: + other_errors.append(str(e)) + except Exception as e: # pragma: no cover (SQLite contention) + other_errors.append(f"{type(e).__name__}: {e}") + + threads = [threading.Thread(target=worker) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(successes) <= 1, ( + f"expected at most one success, got {len(successes)}:" + f" successes={successes} reuse={reuse_detected} other={other_errors}" + ) + # In the common case, exactly one succeeds and the rest see reuse. + if successes: + assert reuse_detected, ( + f"a winner means losers should see reuse; got {reuse_detected}" + f" + other_errors={other_errors}" + ) + + +def test_rotated_to_links_parent_to_child(server) -> None: + mgr, client = server + r = client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "hunter2"}, + ) + parent_refresh = r.json()["refresh_token"] + parent_hash = hash_refresh_token(parent_refresh) + + # Trigger rotation + r = client.post( + "/token", + data={"grant_type": "refresh_token", "refresh_token": parent_refresh}, + ) + assert r.status_code == 200 + new_refresh = r.json()["refresh_token"] + new_hash = hash_refresh_token(new_refresh) + + engine = mgr._postgres_handler.get_engine() + with Session(engine) as session: + parent = session.exec( + select(RefreshTokenTable).where(RefreshTokenTable.token_hash == parent_hash) + ).first() + child = session.exec( + select(RefreshTokenTable).where(RefreshTokenTable.token_hash == new_hash) + ).first() + assert parent is not None + assert child is not None + assert parent.rotated_to == child.token_id + + +# --------------------------------------------------------------------------- +# Tasks 4.6 / 5.3: admin authorization (auth-required mode) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def server_with_auth() -> tuple[AuthManager, TestClient, str]: + """An Auth Manager with AuthMiddleware installed, plus an admin token. + + Uses the production defaults (``auth_enabled=True``, + ``auth_required=True``) and lets ``AuthManager._setup_auth_middleware`` + install the middleware automatically. The Auth Manager's middleware is + self-verifying (uses its own ``TokenService`` rather than a remote + ``AuthClient``), so no HTTP transport plumbing is needed. + """ + # OwnershipInfo.from_jwt_claims validates lab_id as a ULID, so the + # auth-enabled middleware path requires a real ULID (the simpler tests + # above don't pass through the middleware so they can use "lab-test"). + lab_ulid = new_ulid_str() + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id=lab_ulid, + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + # Defaults are now True, but be explicit so the test reads as a + # behavioral assertion, not a default-coupling. + settings.auth_enabled = True + settings.auth_required = True + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username="admin", admin_password="hunter2") + test_client = TestClient(mgr.create_server()) + + # Acquire admin token + r = test_client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "hunter2"}, + ) + assert r.status_code == 200, r.text + admin_token = r.json()["access_token"] + return mgr, test_client, admin_token + + +def test_admin_endpoints_reject_unauthenticated(server_with_auth) -> None: + _, client, _ = server_with_auth + paths = [ + ("GET", "/users"), + ("POST", "/users"), + ("GET", "/projects"), + ("POST", "/projects"), + ("GET", "/roles"), + ("POST", "/roles"), + ("POST", "/roles/grant"), + ("POST", "/service-accounts"), + ("POST", "/node-identities"), + ("POST", "/credentials/foo/rotate"), + ("POST", "/keys/rotate"), + ("GET", "/keys"), + ("DELETE", "/keys/anything"), + ] + for method, path in paths: + r = client.request(method, path, json={}) + assert r.status_code == 401, ( + f"{method} {path} should be 401, got {r.status_code}" + ) + + +def test_admin_endpoints_require_permission(server_with_auth) -> None: + _mgr, client, admin_token = server_with_auth + + # Create a user with no permissions + r = client.post( + "/users", + json={"username": "bob", "password": "x" * 12}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert r.status_code == 200, r.text + + # bob has no roles, so login + call admin endpoint = 403 + r = client.post( + "/token", + data={"grant_type": "password", "username": "bob", "password": "x" * 12}, + ) + bob_token = r.json()["access_token"] + + r = client.get("/users", headers={"Authorization": f"Bearer {bob_token}"}) + assert r.status_code == 403 + + +def test_admin_endpoints_succeed_with_admin_token(server_with_auth) -> None: + _, client, admin_token = server_with_auth + r = client.get("/users", headers={"Authorization": f"Bearer {admin_token}"}) + assert r.status_code == 200 + + +def test_token_endpoint_remains_public(server_with_auth) -> None: + _, client, _ = server_with_auth + r = client.post( + "/token", + data={"grant_type": "password", "username": "admin", "password": "hunter2"}, + ) + assert r.status_code == 200 + + +def test_jwks_remains_public(server_with_auth) -> None: + _, client, _ = server_with_auth + r = client.get("/.well-known/jwks.json") + assert r.status_code == 200 + + +def test_unauthenticated_introspect_returns_inactive(server_with_auth) -> None: + _, client, admin_token = server_with_auth + # Use admin_token as the token to introspect; unauthenticated caller + r = client.post("/introspect", json={"token": admin_token}) + assert r.status_code == 200 + assert r.json() == {"active": False} + + +def test_authenticated_privileged_introspect_returns_claims(server_with_auth) -> None: + _, client, admin_token = server_with_auth + # admin has * permissions + r = client.post( + "/introspect", + json={"token": admin_token}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["active"] is True + assert body["sub"] + + +def test_unauthenticated_revoke_is_rejected(server_with_auth) -> None: + _, client, admin_token = server_with_auth + r = client.post("/revoke", json={"token": admin_token}) + assert r.status_code == 401 + + +def test_self_revocation_succeeds(server_with_auth) -> None: + _, client, admin_token = server_with_auth + r = client.post( + "/revoke", + json={"token": admin_token}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert r.status_code == 200 + + +def test_cross_principal_revoke_requires_permission(server_with_auth) -> None: + _mgr, client, admin_token = server_with_auth + # Create bob without any auth.token.revoke permission + client.post( + "/users", + json={"username": "bob2", "password": "x" * 12}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + bob_token = client.post( + "/token", + data={"grant_type": "password", "username": "bob2", "password": "x" * 12}, + ).json()["access_token"] + + # bob tries to revoke admin's token -> 403 + r = client.post( + "/revoke", + json={"token": admin_token}, + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert r.status_code == 403 + + +def test_cross_principal_refresh_token_revoke_requires_permission( + server_with_auth, +) -> None: + """Refresh-token revocation honors the same self-vs-other rule. + + Pins the security review's filtered ``/revoke refresh-token path lacks + self-vs-other check`` finding — without this, bob with knowledge of + admin's refresh token could force-sign-out admin without holding + ``auth.token.revoke``. + """ + _mgr, client, admin_token = server_with_auth + # Get admin's refresh token + admin_refresh = client.post( + "/token", + data={ + "grant_type": "password", + "username": "admin", + "password": "hunter2", + }, + ).json()["refresh_token"] + + # bob (no permissions) logs in + client.post( + "/users", + json={"username": "bob3", "password": "x" * 12}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + bob_token = client.post( + "/token", + data={"grant_type": "password", "username": "bob3", "password": "x" * 12}, + ).json()["access_token"] + + # bob tries to revoke admin's refresh token -> 403 + r = client.post( + "/revoke", + json={"refresh_token": admin_refresh}, + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert r.status_code == 403 + + # Admin's refresh token still works + r = client.post( + "/token", + data={"grant_type": "refresh_token", "refresh_token": admin_refresh}, + ) + assert r.status_code == 200 + + +# --------------------------------------------------------------------------- +# AuthClient iss/aud validation (security review filtered defense-in-depth) +# --------------------------------------------------------------------------- + + +def test_auth_client_rejects_token_with_wrong_audience(server) -> None: + """AuthClient.verify_jwt enforces ``expected_audience`` when configured.""" + from madsci.client.auth_client import AuthClient, AuthClientError + + mgr, _ = server + # Issue a token via the manager (aud = "lab-test") + signing = mgr._signing_key_service.get_signing_key() + signing_key = RSAKey.import_key( + signing.private_key_pem, parameters={"kid": signing.kid} + ) + now = int(datetime.now(timezone.utc).timestamp()) + claims = { + "iss": str(mgr.settings.server_url).rstrip("/"), + "aud": "lab-test", + "sub": "u", + "iat": now, + "exp": now + 600, + "jti": new_ulid_str(), + "principal_type": "user", + "permissions": ["*"], + } + header = {"alg": "RS256", "kid": signing.kid, "typ": "JWT"} + token = jwt.encode(header, claims, signing_key, algorithms=["RS256"]) + + jwks_dict = mgr._signing_key_service.jwks() + + # AuthClient configured for a DIFFERENT audience must reject. The + # underlying joserfc raises InvalidClaimError; AuthClient propagates + # whatever the JOSE library raises for non-signature failures. + client = AuthClient( + auth_server_url="http://example.invalid/", + expected_audience="some-other-lab", + ) + # Stub jwks() entirely so retry-on-failure doesn't try to HTTP-fetch + client.jwks = lambda **_: jwks_dict # type: ignore[method-assign] + with pytest.raises((AuthClientError, Exception)) as exc: + client.verify_jwt(token) + assert "aud" in str(exc.value).lower() or "audience" in str(exc.value).lower() + + # Same client with the correct audience accepts it + client.close() + client = AuthClient( + auth_server_url="http://example.invalid/", + expected_audience="lab-test", + ) + client.jwks = lambda **_: jwks_dict # type: ignore[method-assign] + decoded = client.verify_jwt(token) + assert decoded.aud == "lab-test" + + +# --------------------------------------------------------------------------- +# Task 7.4: clock-skew leeway +# --------------------------------------------------------------------------- + + +def test_token_with_iat_slightly_in_future_verifies(server) -> None: + mgr, _ = server + # Issue a token by hand with iat 5s in the future + signing = mgr._signing_key_service.get_signing_key() + now = int(datetime.now(timezone.utc).timestamp()) + claims = { + "iss": str(mgr.settings.server_url).rstrip("/"), + "aud": "lab-test", + "sub": "admin", + "iat": now + 5, + "nbf": now + 5, + "exp": now + 600, + "jti": new_ulid_str(), + "principal_type": "user", + "permissions": ["*"], + } + header = {"alg": "RS256", "kid": signing.kid, "typ": "JWT"} + signing_key = RSAKey.import_key( + signing.private_key_pem, parameters={"kid": signing.kid} + ) + token = jwt.encode(header, claims, signing_key, algorithms=["RS256"]) + # Default leeway is 30s; should succeed. + decoded = mgr._token_service.verify_token(token) + assert decoded.sub == "admin" + + +def test_expired_token_outside_leeway_is_rejected(server) -> None: + mgr, _ = server + signing = mgr._signing_key_service.get_signing_key() + now = int(datetime.now(timezone.utc).timestamp()) + claims = { + "iss": str(mgr.settings.server_url).rstrip("/"), + "aud": "lab-test", + "sub": "admin", + "iat": now - 600, + "exp": now - 60, # 60s in past, beyond 30s leeway + "jti": new_ulid_str(), + "principal_type": "user", + "permissions": ["*"], + } + header = {"alg": "RS256", "kid": signing.kid, "typ": "JWT"} + signing_key = RSAKey.import_key( + signing.private_key_pem, parameters={"kid": signing.kid} + ) + token = jwt.encode(header, claims, signing_key, algorithms=["RS256"]) + with pytest.raises(TokenError): + mgr._token_service.verify_token(token) + + +# --------------------------------------------------------------------------- +# Task 8.5: audit failure-closed for token issuance +# --------------------------------------------------------------------------- + + +def test_audit_write_failure_propagates(server, monkeypatch) -> None: + """Audit write failure during token issuance MUST NOT return a token. + + The request either fails outright (5xx) or raises through TestClient — + either way, no token leaves the server. + """ + _, client = server + + from madsci.auth_manager.services import audit_logger as al_mod + + def boom(self, *args, **kwargs): + raise RuntimeError("simulated audit DB failure") + + monkeypatch.setattr(al_mod.AuditLogger, "log", boom) + + try: + r = client.post( + "/token", + data={ + "grant_type": "password", + "username": "admin", + "password": "hunter2", + }, + ) + except RuntimeError as exc: + # FastAPI/TestClient may propagate the unhandled exception; that's + # an acceptable failure-closed signal — the client never sees a token. + assert "simulated audit" in str(exc) + return + # If we got a response, it must NOT be a successful token grant. + assert r.status_code >= 400, f"audit failure leaked a token: {r.text}" + + +# --------------------------------------------------------------------------- +# Task 9.4: X-Forwarded-For trust gating +# --------------------------------------------------------------------------- + + +class _FakeRequest: + def __init__(self, host: str | None, xff: str | None) -> None: + self.headers = {"x-forwarded-for": xff} if xff else {} + self.client = type("C", (), {"host": host})() if host else None + + +def test_xff_ignored_by_default() -> None: + req = _FakeRequest("10.0.0.1", "1.2.3.4") + assert _client_ip(req) == "10.0.0.1" + + +def test_xff_honored_when_trusted() -> None: + req = _FakeRequest("10.0.0.1", "1.2.3.4") + assert _client_ip(req, trust_forwarded_for=True) == "1.2.3.4" + + +def test_xff_falls_back_to_socket_on_garbage() -> None: + req = _FakeRequest("10.0.0.1", "not-an-ip") + assert _client_ip(req, trust_forwarded_for=True) == "10.0.0.1" + + +def test_xff_first_value_used() -> None: + req = _FakeRequest("10.0.0.1", "1.2.3.4, 5.6.7.8") + assert _client_ip(req, trust_forwarded_for=True) == "1.2.3.4" + # Sanity: result is a valid IP + ipaddress.ip_address("1.2.3.4") diff --git a/src/madsci_client/madsci/client/auth_client.py b/src/madsci_client/madsci/client/auth_client.py new file mode 100644 index 000000000..e1d48ac60 --- /dev/null +++ b/src/madsci_client/madsci/client/auth_client.py @@ -0,0 +1,529 @@ +"""Client library for the MADSci Auth Manager. + +Provides programmatic access to the Auth Manager: token acquisition (password, +refresh, client_credentials), introspection, JWKS-cached JWT verification, +deny-list polling, and the admin surface (users, projects, roles, +service-accounts, node identities, signing keys). + +The ``AuthClient`` is also installable into the ambient context via +``auth_client_context()`` so other service clients pick up bearer tokens +automatically (see ``madsci.common.auth_context``). +""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import threading +import time +from datetime import datetime, timezone +from typing import Any, Optional + +import httpx +from joserfc import jwt as jose_jwt +from joserfc.jwk import KeySet +from joserfc.jwt import JWTClaimsRegistry +from madsci.common.types.auth_types import JWTClaims, TokenResponse +from pydantic import AnyUrl + + +class AuthClientError(Exception): + """Raised on auth-client failures.""" + + +class AuthClient: + """Synchronous client for the Auth Manager. + + The client is intentionally synchronous to mirror the rest of MADSci's + service clients. Concurrency-sensitive call sites can wrap it with their + own thread pool / asyncio.to_thread. + """ + + # JWT verification is hard-pinned to RS256 (mirrors TokenService). + ALLOWED_ALGORITHMS: tuple[str, ...] = ("RS256",) + + def __init__( + self, + auth_server_url: AnyUrl | str, + *, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + jwks_ttl_seconds: int = 300, + deny_list_poll_interval: int = 30, + refresh_buffer_seconds: int = 60, + timeout: float = 10.0, + clock_skew_seconds: int = 30, + expected_issuer: Optional[str] = None, + expected_audience: Optional[str] = None, + ) -> None: + """Initialize the client with optional pre-existing tokens / credentials. + + ``expected_issuer`` and ``expected_audience`` are validated on every + ``verify_jwt`` call when set — defense-in-depth against any future + topology where a consumer might be reachable by tokens from a + different lab. Today's single-Auth-Manager-per-lab architecture + already prevents cross-lab confusion via the JWKS scope, but the + explicit check costs nothing and is required by the + ``auth-token-lifecycle`` spec's "verifier rejects tokens with wrong + aud" requirement. + """ + self.auth_server_url = str(auth_server_url).rstrip("/") + self._access_token = access_token + self._refresh_token = refresh_token + self._client_id = client_id + self._client_secret = client_secret + self._access_claims: Optional[JWTClaims] = None + self._timeout = timeout + self._http: Optional[httpx.Client] = None + self._async_http: Optional[httpx.AsyncClient] = None + self._lock = threading.RLock() + self._clock_skew = clock_skew_seconds + self._expected_issuer = expected_issuer + self._expected_audience = expected_audience + + # JWKS cache + self._jwks_ttl = jwks_ttl_seconds + self._jwks: Optional[dict] = None + self._jwks_fetched_at: float = 0.0 + + # Deny list cache + self._deny_list_poll = deny_list_poll_interval + self._deny_etag: Optional[str] = None + self._deny_set: set[str] = set() + self._deny_fetched_at: float = 0.0 + + # Auto-refresh tuning + self._refresh_buffer = refresh_buffer_seconds + + # ------------------------------------------------------------------ + # HTTP plumbing + # ------------------------------------------------------------------ + + @property + def http(self) -> httpx.Client: + """Lazily-initialized synchronous httpx client.""" + if self._http is None: + self._http = httpx.Client( + base_url=self.auth_server_url, timeout=self._timeout + ) + return self._http + + @property + def async_http(self) -> httpx.AsyncClient: + """Lazily-initialized async httpx client (mirrors ``http``).""" + if self._async_http is None: + self._async_http = httpx.AsyncClient( + base_url=self.auth_server_url, timeout=self._timeout + ) + return self._async_http + + def close(self) -> None: + """Release the synchronous HTTP connection pool. Idempotent.""" + with contextlib.suppress(Exception): + if self._http is not None: + self._http.close() + self._http = None + # Async client must be closed via async context; best-effort here + + def __enter__(self) -> AuthClient: + """Enter context-manager scope.""" + return self + + def __exit__(self, *_: Any) -> None: + """Close the underlying HTTP client on exit.""" + self.close() + + # ------------------------------------------------------------------ + # Token acquisition + # ------------------------------------------------------------------ + + def login(self, username: str, password: str) -> TokenResponse: + """Exchange username/password for access + refresh tokens (password grant).""" + r = self.http.post( + "/token", + data={ + "grant_type": "password", + "username": username, + "password": password, + }, + ) + r.raise_for_status() + token = TokenResponse(**r.json()) + self._access_token = token.access_token + self._refresh_token = token.refresh_token + self._access_claims = self._parse_unverified(token.access_token) + return token + + def client_credentials_login( + self, client_id: str, client_secret: str + ) -> TokenResponse: + """Exchange client_id/client_secret for an access token (no refresh token).""" + r = self.http.post( + "/token", + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + }, + ) + r.raise_for_status() + token = TokenResponse(**r.json()) + self._client_id = client_id + self._client_secret = client_secret + self._access_token = token.access_token + self._refresh_token = token.refresh_token + self._access_claims = self._parse_unverified(token.access_token) + return token + + def refresh(self) -> TokenResponse: + """Exchange the cached refresh token for a fresh access + refresh pair.""" + if not self._refresh_token: + if self._client_id and self._client_secret: + return self.client_credentials_login( + self._client_id, self._client_secret + ) + raise AuthClientError("no refresh_token available") + r = self.http.post( + "/token", + data={ + "grant_type": "refresh_token", + "refresh_token": self._refresh_token, + }, + ) + r.raise_for_status() + token = TokenResponse(**r.json()) + self._access_token = token.access_token + self._refresh_token = token.refresh_token + self._access_claims = self._parse_unverified(token.access_token) + return token + + @property + def access_token(self) -> Optional[str]: + """The currently-cached access token, or None if not logged in.""" + return self._access_token + + def get_access_token(self) -> str: + """Return a non-expired access token, refreshing transparently.""" + with self._lock: + if self._access_token is None: + raise AuthClientError("no access token; call login() first") + if self._near_expiry(): + self.refresh() + return self._access_token + + def _near_expiry(self) -> bool: + if self._access_claims is None: + return False + now = int(datetime.now(timezone.utc).timestamp()) + return self._access_claims.exp - now <= self._refresh_buffer + + # ------------------------------------------------------------------ + # JWKS / verify + # ------------------------------------------------------------------ + + def jwks(self, *, force_refresh: bool = False) -> dict: + """Return the JWKS document, fetching from the Auth Manager if the cache is stale.""" + with self._lock: + now = time.time() + if ( + force_refresh + or self._jwks is None + or (now - self._jwks_fetched_at) > self._jwks_ttl + ): + r = self.http.get("/.well-known/jwks.json") + r.raise_for_status() + self._jwks = r.json() + self._jwks_fetched_at = now + return self._jwks + + def verify_jwt(self, token: str) -> JWTClaims: + """Verify a JWT against the cached JWKS and the cached deny-list. + + On signature failure, the JWKS cache is force-refreshed and verification + is retried once. + """ + # Reject anything that isn't on our allowlist before joserfc touches it. + self._enforce_algorithm(token) + for attempt in range(2): + jwks_dict = self.jwks(force_refresh=attempt > 0) + try: + key_set = KeySet.import_key_set(jwks_dict) + decoded = jose_jwt.decode( + token, key_set, algorithms=list(self.ALLOWED_ALGORITHMS) + ) + claim_options: dict[str, Any] = {} + if self._expected_issuer is not None: + claim_options["iss"] = { + "essential": True, + "value": self._expected_issuer, + } + if self._expected_audience is not None: + claim_options["aud"] = { + "essential": True, + "value": self._expected_audience, + } + JWTClaimsRegistry(leeway=self._clock_skew, **claim_options).validate( + decoded.claims + ) + claims = JWTClaims(**decoded.claims) + # Deny-list enforcement + self._poll_deny_list_if_due() + if claims.jti in self._deny_set: + raise AuthClientError("token revoked") + return claims + except AuthClientError: + raise + except Exception: + if attempt == 1: + raise + raise AuthClientError("verify_jwt: unreachable") + + def _enforce_algorithm(self, token: str) -> None: + """Reject tokens whose JWS header alg is not in ALLOWED_ALGORITHMS.""" + try: + header_b64 = token.split(".", maxsplit=1)[0] + padding = 4 - (len(header_b64) % 4) + if padding != 4: + header_b64 += "=" * padding + header = json.loads(base64.urlsafe_b64decode(header_b64)) + alg = header.get("alg") + except Exception as e: + raise AuthClientError("invalid_token: malformed header") from e + if alg not in self.ALLOWED_ALGORITHMS: + raise AuthClientError(f"invalid_token: disallowed alg {alg!r}") + + def _parse_unverified(self, token: str) -> Optional[JWTClaims]: + """Parse JWT claims without signature verification. Used for refresh-buffer logic.""" + try: + parts = token.split(".") + if len(parts) != 3: + return None + payload_segment = parts[1] + padding = 4 - (len(payload_segment) % 4) + if padding != 4: + payload_segment += "=" * padding + payload = json.loads(base64.urlsafe_b64decode(payload_segment)) + return JWTClaims(**payload) + except Exception: + return None + + # ------------------------------------------------------------------ + # Introspect / revoke + # ------------------------------------------------------------------ + + def introspect(self, token: str) -> dict: + """Call the RFC 7662 introspection endpoint for ``token``.""" + r = self.http.post("/introspect", json={"token": token}) + r.raise_for_status() + return r.json() + + def revoke( + self, + *, + token: Optional[str] = None, + refresh_token: Optional[str] = None, + ) -> None: + """Revoke an access token and/or refresh token at the Auth Manager.""" + body: dict[str, Any] = {} + if token is not None: + body["token"] = token + if refresh_token is not None: + body["refresh_token"] = refresh_token + r = self.http.post("/revoke", json=body) + r.raise_for_status() + + # ------------------------------------------------------------------ + # Deny-list polling + # ------------------------------------------------------------------ + + def _poll_deny_list_if_due(self) -> None: + now = time.time() + if (now - self._deny_fetched_at) < self._deny_list_poll: + return + self._fetch_deny_list() + + def _fetch_deny_list(self) -> None: + headers = {} + if self._deny_etag: + headers["If-None-Match"] = f'"{self._deny_etag}"' + try: + r = self.http.get("/deny-list", headers=headers) + except Exception: + return + if r.status_code == 304: + self._deny_fetched_at = time.time() + return + if r.status_code != 200: + return + body = r.json() + self._deny_etag = body.get("etag") + self._deny_set = {e["jti"] for e in body.get("entries", [])} + self._deny_fetched_at = time.time() + + def force_deny_list_refresh(self) -> None: + """Force an immediate deny-list fetch (used after on-401 retries).""" + self._deny_fetched_at = 0.0 + self._fetch_deny_list() + + # ------------------------------------------------------------------ + # Admin surface (auth required) + # ------------------------------------------------------------------ + + def _admin_headers(self) -> dict[str, str]: + if self._access_token: + return {"Authorization": f"Bearer {self._access_token}"} + return {} + + # --- Users + def create_user( + self, username: str, password: str, email: Optional[str] = None + ) -> dict: + """Create a new user (``POST /users``).""" + r = self.http.post( + "/users", + json={"username": username, "password": password, "email": email}, + headers=self._admin_headers(), + ) + r.raise_for_status() + return r.json() + + def list_users(self) -> list[dict]: + """List all users (``GET /users``).""" + r = self.http.get("/users", headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + def update_user(self, user_id: str, **fields: Any) -> dict: + """Patch a user (``PATCH /users/{user_id}``).""" + r = self.http.patch( + f"/users/{user_id}", json=fields, headers=self._admin_headers() + ) + r.raise_for_status() + return r.json() + + # --- Projects + def create_project(self, name: str, description: Optional[str] = None) -> dict: + """Create a new project (``POST /projects``).""" + r = self.http.post( + "/projects", + json={"name": name, "description": description}, + headers=self._admin_headers(), + ) + r.raise_for_status() + return r.json() + + def list_projects(self) -> list[dict]: + """List all projects (``GET /projects``).""" + r = self.http.get("/projects", headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + def add_project_member(self, project_id: str, user_id: str, role_id: str) -> dict: + """Add a user to a project with a role (``POST /projects/{id}/members``).""" + r = self.http.post( + f"/projects/{project_id}/members", + json={"user_id": user_id, "role_id": role_id}, + headers=self._admin_headers(), + ) + r.raise_for_status() + return r.json() + + # --- Roles + def create_role( + self, name: str, permissions: list[str], description: Optional[str] = None + ) -> dict: + """Create a new role with permissions (``POST /roles``).""" + r = self.http.post( + "/roles", + json={ + "name": name, + "permissions": permissions, + "description": description, + }, + headers=self._admin_headers(), + ) + r.raise_for_status() + return r.json() + + def list_roles(self) -> list[dict]: + """List all roles (``GET /roles``).""" + r = self.http.get("/roles", headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + def grant_role(self, **kwargs: Any) -> dict: + """Grant a role to a principal (``POST /roles/grant``).""" + r = self.http.post("/roles/grant", json=kwargs, headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + # --- Service accounts / nodes + def register_service_account( + self, manager_id: str, role_ids: Optional[list[str]] = None + ) -> dict: + """Register a service account (``POST /service-accounts``). + + The plaintext ``client_secret`` is returned exactly once. + """ + r = self.http.post( + "/service-accounts", + json={"manager_id": manager_id, "role_ids": role_ids or []}, + headers=self._admin_headers(), + ) + r.raise_for_status() + return r.json() + + def register_node( + self, + node_id: str, + workcell_id: Optional[str] = None, + role_ids: Optional[list[str]] = None, + ) -> dict: + """Register a node identity (``POST /node-identities``). + + The plaintext ``client_secret`` is returned exactly once. + """ + r = self.http.post( + "/node-identities", + json={ + "node_id": node_id, + "workcell_id": workcell_id, + "role_ids": role_ids or [], + }, + headers=self._admin_headers(), + ) + r.raise_for_status() + return r.json() + + def rotate_credentials(self, client_id: str) -> dict: + """Rotate a service-account or node-identity secret (``POST /credentials/{id}/rotate``).""" + r = self.http.post( + f"/credentials/{client_id}/rotate", headers=self._admin_headers() + ) + r.raise_for_status() + return r.json() + + # --- Keys + def rotate_keys(self) -> dict: + """Generate a new signing keypair (``POST /keys/rotate``).""" + r = self.http.post("/keys/rotate", headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + def list_keys(self) -> list[dict]: + """List all signing keys (``GET /keys``).""" + r = self.http.get("/keys", headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + def retire_key(self, kid: str) -> dict: + """Retire a signing key (``DELETE /keys/{kid}``).""" + r = self.http.delete(f"/keys/{kid}", headers=self._admin_headers()) + r.raise_for_status() + return r.json() + + +__all__ = ["AuthClient", "AuthClientError"] diff --git a/src/madsci_client/madsci/client/cli/__init__.py b/src/madsci_client/madsci/client/cli/__init__.py index 2b4ff5bfb..653983d50 100644 --- a/src/madsci_client/madsci/client/cli/__init__.py +++ b/src/madsci_client/madsci/client/cli/__init__.py @@ -45,6 +45,7 @@ "campaign": ("madsci.client.cli.commands.campaign", "campaign"), "data": ("madsci.client.cli.commands.data", "data"), "events": ("madsci.client.cli.commands.events", "events"), + "auth": ("madsci.client.cli.commands.auth", "auth"), } diff --git a/src/madsci_client/madsci/client/cli/commands/auth.py b/src/madsci_client/madsci/client/cli/commands/auth.py new file mode 100644 index 000000000..b79e99a70 --- /dev/null +++ b/src/madsci_client/madsci/client/cli/commands/auth.py @@ -0,0 +1,387 @@ +"""MADSci CLI ``auth`` command group. + +Subcommands target the Auth Manager via ``AuthClient``. The bootstrap +command runs locally against an Auth Manager instance (operator must already +have access to the database / process); all other commands talk to a running +Auth Manager over HTTP. +""" + +from __future__ import annotations + +import getpass +import json +import os +import sys +from typing import TYPE_CHECKING, Optional + +import click + +BOOTSTRAP_PASSWORD_ENV_VAR = "MADSCI_AUTH_BOOTSTRAP_PASSWORD" # noqa: S105 + + +def _auth_url(ctx: click.Context, auth_url: Optional[str]) -> str: + if auth_url: + return auth_url + # Try MadsciContext if present + context = ctx.obj.get("context") if ctx.obj else None + if context is not None: + # Auth URL isn't yet a first-class field on MadsciContext; fall back to default. + pass + return "http://localhost:8007/" + + +if TYPE_CHECKING: + from madsci.client.auth_client import AuthClient + + +def _client(auth_url: str) -> AuthClient: + from madsci.client.auth_client import AuthClient + + return AuthClient(auth_server_url=auth_url) + + +def _print(value: object) -> None: + if isinstance(value, (dict, list)): + click.echo(json.dumps(value, indent=2, sort_keys=True)) + else: + click.echo(str(value)) + + +@click.group() +@click.option( + "--auth-url", + envvar="AUTH_SERVER_URL", + default=None, + help="Auth Manager URL (default: http://localhost:8007/).", +) +@click.pass_context +def auth(ctx: click.Context, auth_url: Optional[str]) -> None: + """Auth Manager commands (users, projects, roles, keys, credentials).""" + ctx.ensure_object(dict) + ctx.obj["auth_url"] = _auth_url(ctx, auth_url) + + +# --------------------------------------------------------------------------- +# bootstrap (in-process) +# --------------------------------------------------------------------------- + + +def _resolve_bootstrap_password(username: str) -> str: + """Source the admin password from env var, then interactive prompt. + + Refuses to fall back to argv-passed passwords: those leak via ``ps``. + Refuses to silently accept an empty password. + """ + env_value = os.environ.get(BOOTSTRAP_PASSWORD_ENV_VAR) + if env_value: + return env_value + if not sys.stdin.isatty(): + raise click.ClickException( + f"No password available. Set ${BOOTSTRAP_PASSWORD_ENV_VAR} or run" + " interactively (TTY required for the password prompt)." + ) + pw = click.prompt( + f"Password for {username}", + hide_input=True, + confirmation_prompt=True, + ) + if not pw: + raise click.ClickException("Password may not be empty.") + return pw + + +@auth.command() +@click.option("--username", default="admin", show_default=True) +@click.option("--email", default=None) +@click.option( + "--lab-id", + default=None, + envvar="AUTH_LAB_ID", + help="lab_id to bind the Auth Manager to (Decision 12).", +) +@click.option( + "--database-url", + default=None, + envvar="AUTH_DATABASE_URL", + help="Override AUTH_DATABASE_URL for this run (e.g. sqlite:///./test.db).", +) +def bootstrap( + username: str, + email: Optional[str], + lab_id: Optional[str], + database_url: Optional[str], +) -> None: + """Initialize an empty Auth Manager database (idempotent). + + Creates the admin user, generates the first signing keypair, and seeds + the built-in roles. Safe to re-run against a populated database. + + The admin password MUST be supplied via the + ``MADSCI_AUTH_BOOTSTRAP_PASSWORD`` environment variable or via the + interactive prompt. Passing the password on the command line is no longer + supported (it would leak via ``ps``/process listings). + """ + from madsci.auth_manager.auth_server import AuthManager + from madsci.common.types.auth_types import AuthManagerSettings + + password = _resolve_bootstrap_password(username) + + overrides: dict = {"enable_registry_resolution": False} + if lab_id: + overrides["lab_id"] = lab_id + if database_url: + overrides["database_url"] = database_url + + settings = AuthManagerSettings(**overrides) + mgr = AuthManager(settings=settings) + result = mgr.bootstrap( + admin_username=username, admin_password=password, admin_email=email + ) + _print( + { + "user_id": result.user_id, + "username": result.username, + "admin_role_id": result.admin_role_id, + "signing_kid": result.signing_kid, + "note": result.note, + } + ) + + +# --------------------------------------------------------------------------- +# user +# --------------------------------------------------------------------------- + + +@auth.group() +@click.pass_context +def user(ctx: click.Context) -> None: + """User account commands.""" + ctx.ensure_object(dict) + + +@user.command("create") +@click.argument("username") +@click.option("--password", default=None) +@click.option("--email", default=None) +@click.pass_context +def user_create( + ctx: click.Context, username: str, password: Optional[str], email: Optional[str] +) -> None: + """Create a new user account.""" + if password is None: + password = getpass.getpass(f"Password for {username}: ") + with _client(ctx.obj["auth_url"]) as c: + _print(c.create_user(username, password, email)) + + +@user.command("deactivate") +@click.argument("user_id") +@click.pass_context +def user_deactivate(ctx: click.Context, user_id: str) -> None: + """Deactivate a user (no future logins; existing tokens still verify until exp).""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.update_user(user_id, is_active=False)) + + +@user.command("password") +@click.argument("user_id") +@click.option("--new-password", default=None) +@click.pass_context +def user_password( + ctx: click.Context, user_id: str, new_password: Optional[str] +) -> None: + """Set a new password for a user.""" + if new_password is None: + new_password = getpass.getpass("New password: ") + with _client(ctx.obj["auth_url"]) as c: + _print(c.update_user(user_id, new_password=new_password)) + + +@user.command("list") +@click.pass_context +def user_list(ctx: click.Context) -> None: + """List all user accounts.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.list_users()) + + +@user.command("grant") +@click.argument("role_id") +@click.argument("user_id") +@click.option("--project-id", default=None) +@click.pass_context +def user_grant( + ctx: click.Context, role_id: str, user_id: str, project_id: Optional[str] +) -> None: + """Grant a role to a user, optionally scoped to a project.""" + with _client(ctx.obj["auth_url"]) as c: + body: dict = {"role_id": role_id, "user_id": user_id} + if project_id: + body["project_id"] = project_id + _print(c.grant_role(**body)) + + +# --------------------------------------------------------------------------- +# project +# --------------------------------------------------------------------------- + + +@auth.group() +@click.pass_context +def project(ctx: click.Context) -> None: + """Project commands.""" + ctx.ensure_object(dict) + + +@project.command("create") +@click.argument("name") +@click.option("--description", default=None) +@click.pass_context +def project_create(ctx: click.Context, name: str, description: Optional[str]) -> None: + """Create a new project.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.create_project(name, description)) + + +@project.command("list") +@click.pass_context +def project_list(ctx: click.Context) -> None: + """List all projects.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.list_projects()) + + +@project.command("members") +@click.argument("project_id") +@click.argument("user_id") +@click.argument("role_id") +@click.pass_context +def project_members( + ctx: click.Context, project_id: str, user_id: str, role_id: str +) -> None: + """Add a user to a project with a role.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.add_project_member(project_id, user_id, role_id)) + + +# --------------------------------------------------------------------------- +# manager / node register +# --------------------------------------------------------------------------- + + +@auth.group() +@click.pass_context +def manager(ctx: click.Context) -> None: + """Service-account commands for managers.""" + ctx.ensure_object(dict) + + +@manager.command("register") +@click.option("--manager-id", required=True) +@click.option("--role-id", "role_ids", multiple=True) +@click.pass_context +def manager_register( + ctx: click.Context, manager_id: str, role_ids: tuple[str, ...] +) -> None: + """Register a manager service account; returns the plaintext secret once.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.register_service_account(manager_id, list(role_ids))) + + +@manager.command("list") +@click.pass_context +def manager_list(ctx: click.Context) -> None: + """List managers via roles (placeholder — see /service-accounts).""" + with _client(ctx.obj["auth_url"]) as c: + # No dedicated list endpoint in v1; show roles + a hint. + _print(c.list_roles()) + + +@auth.group() +@click.pass_context +def node(ctx: click.Context) -> None: + """Node-identity commands.""" + ctx.ensure_object(dict) + + +@node.command("register") +@click.option("--node-id", required=True) +@click.option("--workcell-id", default=None) +@click.option("--role-id", "role_ids", multiple=True) +@click.pass_context +def node_register( + ctx: click.Context, + node_id: str, + workcell_id: Optional[str], + role_ids: tuple[str, ...], +) -> None: + """Register a node identity; returns the plaintext secret once.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.register_node(node_id, workcell_id, list(role_ids))) + + +@node.command("list") +@click.pass_context +def node_list(ctx: click.Context) -> None: + """List nodes via roles (placeholder — see /node-identities).""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.list_roles()) + + +# --------------------------------------------------------------------------- +# credentials +# --------------------------------------------------------------------------- + + +@auth.group() +@click.pass_context +def credentials(ctx: click.Context) -> None: + """Credential rotation commands.""" + ctx.ensure_object(dict) + + +@credentials.command("rotate") +@click.argument("client_id") +@click.pass_context +def credentials_rotate(ctx: click.Context, client_id: str) -> None: + """Rotate a service-account or node-identity secret; returns the new secret once.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.rotate_credentials(client_id)) + + +# --------------------------------------------------------------------------- +# keys +# --------------------------------------------------------------------------- + + +@auth.group() +@click.pass_context +def keys(ctx: click.Context) -> None: + """Signing-key commands.""" + ctx.ensure_object(dict) + + +@keys.command("rotate") +@click.pass_context +def keys_rotate(ctx: click.Context) -> None: + """Generate a new signing keypair; previous one stays in JWKS for verification.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.rotate_keys()) + + +@keys.command("list") +@click.pass_context +def keys_list(ctx: click.Context) -> None: + """List all signing keys.""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.list_keys()) + + +@keys.command("retire") +@click.argument("kid") +@click.pass_context +def keys_retire(ctx: click.Context, kid: str) -> None: + """Retire a signing key (remove from JWKS, delete private material).""" + with _client(ctx.obj["auth_url"]) as c: + _print(c.retire_key(kid)) diff --git a/src/madsci_client/madsci/client/cli/commands/doctor.py b/src/madsci_client/madsci/client/cli/commands/doctor.py index 3a8fcb68d..3b81f6d0f 100644 --- a/src/madsci_client/madsci/client/cli/commands/doctor.py +++ b/src/madsci_client/madsci/client/cli/commands/doctor.py @@ -307,6 +307,7 @@ def run_all_checks(categories: list[str] | None = None) -> DiagnosticResults: lambda: check_port(8004, "data_manager"), lambda: check_port(8005, "workcell_manager"), lambda: check_port(8006, "location_manager"), + lambda: check_port(8007, "auth_manager"), ], } diff --git a/src/madsci_client/madsci/client/cli/commands/start.py b/src/madsci_client/madsci/client/cli/commands/start.py index dbc73ac13..24f430ba6 100644 --- a/src/madsci_client/madsci/client/cli/commands/start.py +++ b/src/madsci_client/madsci/client/cli/commands/start.py @@ -31,6 +31,7 @@ "data": "madsci.data_manager.data_server", "workcell": "madsci.workcell_manager.workcell_server", "location": "madsci.location_manager.location_server", + "auth": "madsci.auth_manager.auth_server", } @@ -540,7 +541,7 @@ def _start_local(console: Console) -> None: "[yellow]Warning: Data is ephemeral and will not persist across restarts.[/yellow]" ) console.print() - console.print("Managers starting on ports 8000-8006...") + console.print("Managers starting on ports 8000-8007...") console.print("[dim]Press Ctrl+C to stop.[/dim]") console.print() diff --git a/src/madsci_client/madsci/client/cli/commands/version.py b/src/madsci_client/madsci/client/cli/commands/version.py index 729780786..3d5c5a859 100644 --- a/src/madsci_client/madsci/client/cli/commands/version.py +++ b/src/madsci_client/madsci/client/cli/commands/version.py @@ -23,6 +23,7 @@ "madsci.data_manager", "madsci.workcell_manager", "madsci.location_manager", + "madsci.auth_manager", ] diff --git a/src/madsci_client/madsci/client/cli/utils/service_health.py b/src/madsci_client/madsci/client/cli/utils/service_health.py index 9f35f587d..3abffff9e 100644 --- a/src/madsci_client/madsci/client/cli/utils/service_health.py +++ b/src/madsci_client/madsci/client/cli/utils/service_health.py @@ -24,6 +24,7 @@ "data_manager": "http://localhost:8004/", "workcell_manager": "http://localhost:8005/", "location_manager": "http://localhost:8006/", + "auth_manager": "http://localhost:8007/", } diff --git a/src/madsci_client/pyproject.toml b/src/madsci_client/pyproject.toml index a6231dbe8..0e1273d2e 100644 --- a/src/madsci_client/pyproject.toml +++ b/src/madsci_client/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "structlog>=24.1.0", "httpx>=0.25.0", "rich>=13.0.0", + "joserfc>=1.0.0", ] [project.scripts] diff --git a/src/madsci_client/tests/test_auth_client.py b/src/madsci_client/tests/test_auth_client.py new file mode 100644 index 000000000..8ae48a481 --- /dev/null +++ b/src/madsci_client/tests/test_auth_client.py @@ -0,0 +1,138 @@ +# ruff: noqa: S105, S106 +"""Unit tests for ``AuthClient``. + +Tests use ``httpx.MockTransport`` to mount a real ``AuthManager`` so we +exercise the actual JWT/JWKS round-trip and refresh-token rotation logic. +""" + +from __future__ import annotations + +from typing import Iterator + +import httpx +import pytest +from fastapi.testclient import TestClient +from madsci.auth_manager.auth_server import AuthManager +from madsci.client.auth_client import AuthClient, AuthClientError +from madsci.common.db_handlers.postgres_handler import SQLiteHandler +from madsci.common.types.auth_types import AuthManagerSettings + + +@pytest.fixture +def auth_pair() -> Iterator[tuple[AuthManager, AuthClient]]: + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id="lab-test", + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + # These tests exercise AuthClient against the raw token endpoints + # (login, refresh, introspect, revoke); they don't go through the + # admin-permission surface, so disable enforcement on the manager + # itself. Production defaults to True (security review HIGH finding). + settings.auth_enabled = False + settings.auth_required = False + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username="admin", admin_password="hunter2") + test_client = TestClient(mgr.create_server()) + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.raw_path.decode("ascii") + kwargs: dict = {"headers": dict(request.headers.items())} + if request.content: + kwargs["content"] = request.content + resp = test_client.request(request.method, path, **kwargs) + return httpx.Response( + status_code=resp.status_code, + headers=dict(resp.headers), + content=resp.content, + ) + + transport = httpx.MockTransport(handler) + client = AuthClient(auth_server_url="http://localhost:8007/") + client._http = httpx.Client( + base_url=client.auth_server_url, + transport=transport, + timeout=10.0, + ) + try: + yield mgr, client + finally: + client.close() + + +def test_login_returns_tokens(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + tok = client.login("admin", "hunter2") + assert tok.access_token + assert tok.refresh_token + assert tok.token_type == "Bearer" + + +def test_verify_jwt_round_trip(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + tok = client.login("admin", "hunter2") + claims = client.verify_jwt(tok.access_token) + assert claims.principal_type.value == "user" + + +def test_refresh_rotates_tokens(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + tok1 = client.login("admin", "hunter2") + tok2 = client.refresh() + assert tok2.access_token != tok1.access_token + assert tok2.refresh_token != tok1.refresh_token + + +def test_refresh_reuse_detected(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + tok1 = client.login("admin", "hunter2") + # Snapshot the first refresh token + first_rt = tok1.refresh_token + client.refresh() + # Try to use the original refresh token again + client._refresh_token = first_rt + with pytest.raises(httpx.HTTPStatusError): + client.refresh() + + +def test_introspect_and_revoke(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + tok = client.login("admin", "hunter2") + intro = client.introspect(tok.access_token) + assert intro["active"] is True + client.revoke(token=tok.access_token) + intro2 = client.introspect(tok.access_token) + assert intro2["active"] is False + + +def test_jwks_caching(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + keys1 = client.jwks() + keys2 = client.jwks() + assert keys1 is keys2 # cached object reused + keys3 = client.jwks(force_refresh=True) + assert keys3 == keys1 + + +def test_deny_list_polling_revokes( + auth_pair: tuple[AuthManager, AuthClient], +) -> None: + _, client = auth_pair + tok = client.login("admin", "hunter2") + # Verify works initially + client.verify_jwt(tok.access_token) + # Revoke at server + client.revoke(token=tok.access_token) + # Force a deny-list refresh and re-verify + client.force_deny_list_refresh() + with pytest.raises(AuthClientError): + client.verify_jwt(tok.access_token) + + +def test_close_is_idempotent(auth_pair: tuple[AuthManager, AuthClient]) -> None: + _, client = auth_pair + client.close() + client.close() diff --git a/src/madsci_client/tests/test_cli_auth.py b/src/madsci_client/tests/test_cli_auth.py new file mode 100644 index 000000000..bbb463010 --- /dev/null +++ b/src/madsci_client/tests/test_cli_auth.py @@ -0,0 +1,206 @@ +# ruff: noqa: S106, ARG001, PLC0415 +"""Smoke tests for the ``madsci auth`` CLI command group. + +These tests boot an in-memory Auth Manager, mount its FastAPI app behind an +``httpx.MockTransport`` so the CLI's ``AuthClient`` can talk to it without +opening a real socket, and exercise every subcommand. +""" + +from __future__ import annotations + +import json +from typing import Iterator + +import httpx +import pytest +from click.testing import CliRunner +from fastapi.testclient import TestClient +from madsci.auth_manager.auth_server import AuthManager +from madsci.client.cli import madsci as cli +from madsci.common.db_handlers.postgres_handler import SQLiteHandler +from madsci.common.types.auth_types import AuthManagerSettings + + +@pytest.fixture +def patched_auth_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[AuthManager]: + """Mount AuthManager behind a MockTransport and patch AuthClient to use it.""" + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id="lab-test", + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + # Smoke tests bypass auth — they exercise the CLI command surface, + # not the auth-middleware path. Production defaults enforce auth. + settings.auth_enabled = False + settings.auth_required = False + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username="admin", admin_password="hunter2") + test_client = TestClient(mgr.create_server()) + + def _handler(request: httpx.Request) -> httpx.Response: + # Translate httpx Request -> TestClient call + method = request.method + # Strip the http://localhost:8007 prefix to get path+query + path = request.url.raw_path.decode("ascii") + kwargs: dict = {"headers": dict(request.headers.items())} + if request.content: + kwargs["content"] = request.content + resp = test_client.request(method, path, **kwargs) + return httpx.Response( + status_code=resp.status_code, + headers=dict(resp.headers), + content=resp.content, + ) + + transport = httpx.MockTransport(_handler) + + import madsci.client.auth_client as ac_mod + + original_init = ac_mod.AuthClient.__init__ + + def patched_init(self, *args: object, **kwargs: object) -> None: + original_init(self, *args, **kwargs) + # Override the lazy http property to use the mock transport + self._http = httpx.Client( + base_url=self.auth_server_url, + transport=transport, + timeout=self._timeout, + ) + + monkeypatch.setattr(ac_mod.AuthClient, "__init__", patched_init) + + yield mgr + + +def _run(argv: list[str]) -> str: + runner = CliRunner() + result = runner.invoke(cli, argv, catch_exceptions=False) + if result.exit_code != 0: + raise AssertionError( + f"CLI {argv} failed: exit={result.exit_code}\nout={result.output}\n" + ) + return result.output + + +def test_user_list_and_create(patched_auth_client: AuthManager) -> None: + out = _run(["auth", "user", "list"]) + users = json.loads(out) + usernames = {u["username"] for u in users} + assert "admin" in usernames + + out = _run(["auth", "user", "create", "alice", "--password", "x" * 12]) + created = json.loads(out) + assert created["username"] == "alice" + + +def test_project_create_and_list(patched_auth_client: AuthManager) -> None: + _run(["auth", "project", "create", "proj-cli"]) + out = _run(["auth", "project", "list"]) + names = {p["name"] for p in json.loads(out)} + assert "proj-cli" in names + + +def test_keys_list_and_rotate(patched_auth_client: AuthManager) -> None: + out = _run(["auth", "keys", "list"]) + n_before = len(json.loads(out)) + _run(["auth", "keys", "rotate"]) + out = _run(["auth", "keys", "list"]) + assert len(json.loads(out)) == n_before + 1 + + +def test_manager_register_returns_credentials( + patched_auth_client: AuthManager, +) -> None: + out = _run( + [ + "auth", + "manager", + "register", + "--manager-id", + "01HZZ" + "0" * 21, + ] + ) + body = json.loads(out) + assert body["client_id"].startswith("sa-") + assert body["client_secret"] + + +def test_node_register_returns_credentials(patched_auth_client: AuthManager) -> None: + out = _run( + [ + "auth", + "node", + "register", + "--node-id", + "01HZZ" + "0" * 21, + ] + ) + body = json.loads(out) + assert body["client_id"].startswith("node-") + + +def test_bootstrap_rejects_password_flag() -> None: + """The bootstrap CLI must NOT accept ``--password`` on argv (leaks via ps).""" + runner = CliRunner() + result = runner.invoke(cli, ["auth", "bootstrap", "--password", "x"]) + # Click rejects unknown options with exit code 2 + 'No such option' message + assert result.exit_code != 0 + assert "no such option" in result.output.lower() or "--password" in result.output + + +def test_bootstrap_uses_env_var_for_password(monkeypatch) -> None: + """``MADSCI_AUTH_BOOTSTRAP_PASSWORD`` env var is honored without prompting. + + We test the password-resolution helper directly rather than driving the + full ``madsci auth bootstrap`` command, because the latter would call + ``create_all_tables`` against the global ``SQLModel.metadata`` — which, + when other test modules have been collected, includes tables from other + managers (e.g., ``resource_history``) that real SQLite cannot create + (composite PK + autoincrement). The injected ``SQLiteHandler`` used in + the rest of the suite has a workaround for this; the bootstrap CLI's + file-backed ``SQLAlchemyHandler`` does not. + """ + from madsci.client.cli.commands.auth import ( + BOOTSTRAP_PASSWORD_ENV_VAR, + _resolve_bootstrap_password, + ) + + monkeypatch.setenv(BOOTSTRAP_PASSWORD_ENV_VAR, "envvar-secret-x" * 2) + pw = _resolve_bootstrap_password("envadmin") + assert pw == "envvar-secret-x" * 2 + + +def test_bootstrap_password_helper_fails_without_env_or_tty(monkeypatch) -> None: + """No env var + no TTY => clear ClickException, NOT a silent fallback.""" + import click + from madsci.client.cli.commands.auth import ( + BOOTSTRAP_PASSWORD_ENV_VAR, + _resolve_bootstrap_password, + ) + + monkeypatch.delenv(BOOTSTRAP_PASSWORD_ENV_VAR, raising=False) + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + with pytest.raises(click.ClickException) as exc: + _resolve_bootstrap_password("admin") + assert BOOTSTRAP_PASSWORD_ENV_VAR in str(exc.value.message) + + +def test_credentials_rotate(patched_auth_client: AuthManager) -> None: + reg = json.loads( + _run( + [ + "auth", + "manager", + "register", + "--manager-id", + "01HZZ" + "0" * 21, + ] + ) + ) + out = _run(["auth", "credentials", "rotate", reg["client_id"]]) + body = json.loads(out) + assert body["client_id"] == reg["client_id"] + assert body["client_secret"] != reg["client_secret"] diff --git a/src/madsci_common/madsci/common/auth_audit_fallback.py b/src/madsci_common/madsci/common/auth_audit_fallback.py new file mode 100644 index 000000000..a74bf40e4 --- /dev/null +++ b/src/madsci_common/madsci/common/auth_audit_fallback.py @@ -0,0 +1,121 @@ +"""Local audit-log fallback for consuming managers. + +When a consuming manager cannot deliver an authentication-related audit +event to the Auth Manager (network partition, 5xx, etc.), the event is +appended to a local on-disk log. A background drain attempts re-delivery on +a configurable interval; events are removed locally only after the Auth +Manager confirms persistence. + +The local log is bounded by a configurable max-size; when exceeded, the +oldest segment is rotated out and a structured warning event is emitted so +operators can upsize before bound-bites cause silent loss. + +This module deliberately avoids any direct dependency on ``madsci.client`` +so it can be imported by ``madsci.common.auth_middleware`` without creating +a circular dependency. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import threading +from pathlib import Path +from typing import Any, Callable, Optional + +logger = logging.getLogger(__name__) + +DEFAULT_LOG_PATH = ".madsci/audit/auth-fallback.log" +DEFAULT_MAX_BYTES = 100 * 1024 * 1024 # 100 MB +DEFAULT_DRAIN_INTERVAL_SECONDS = 60.0 + + +class AuthAuditFallback: + """Append-only local fallback for auth audit events.""" + + def __init__( + self, + *, + log_path: Optional[str] = None, + max_bytes: int = DEFAULT_MAX_BYTES, + drain_interval: float = DEFAULT_DRAIN_INTERVAL_SECONDS, + deliver: Optional[Callable[[dict[str, Any]], bool]] = None, + ) -> None: + """Configure the fallback log path, size bound, drain interval, and delivery callable.""" + self._log_path = Path(log_path or DEFAULT_LOG_PATH) + self._log_path.parent.mkdir(parents=True, exist_ok=True) + self._max_bytes = max_bytes + self._drain_interval = drain_interval + self._deliver = deliver + self._lock = threading.RLock() + + def append(self, event: dict[str, Any]) -> None: + """Persist an event to the local fallback log.""" + with self._lock: + self._rotate_if_needed() + with self._log_path.open("a", encoding="utf-8") as fp: + fp.write(json.dumps(event) + "\n") + + def _rotate_if_needed(self) -> None: + try: + size = self._log_path.stat().st_size + except FileNotFoundError: + return + if size < self._max_bytes: + return + # Rotate: rename current to .1 (overwriting any existing) and emit a warning + rotated = self._log_path.with_suffix(self._log_path.suffix + ".1") + with contextlib.suppress(OSError): + self._log_path.replace(rotated) + logger.warning( + "Local auth audit fallback exceeded %d bytes; rotated %s -> %s. " + "Operators should investigate Auth Manager connectivity and " + "consider upsizing local_audit_log_max_bytes (see " + "docs/guides/auth_operator.md).", + self._max_bytes, + self._log_path, + rotated, + ) + + def drain(self) -> int: + """Attempt to deliver all locally-queued events. Returns count drained.""" + if self._deliver is None: + return 0 + with self._lock: + if not self._log_path.exists(): + return 0 + with self._log_path.open("r", encoding="utf-8") as fp: + lines = fp.readlines() + delivered = 0 + still_queued: list[str] = [] + for raw_line in lines: + stripped = raw_line.strip() + if not stripped: + continue + try: + event = json.loads(stripped) + except json.JSONDecodeError: + continue + try: + ok = self._deliver(event) + except Exception: + ok = False + if ok: + delivered += 1 + else: + still_queued.append(stripped) + if still_queued: + with self._log_path.open("w", encoding="utf-8") as fp: + fp.write("\n".join(still_queued) + "\n") + else: + self._log_path.unlink(missing_ok=True) + return delivered + + +__all__ = [ + "DEFAULT_DRAIN_INTERVAL_SECONDS", + "DEFAULT_LOG_PATH", + "DEFAULT_MAX_BYTES", + "AuthAuditFallback", +] diff --git a/src/madsci_common/madsci/common/auth_context.py b/src/madsci_common/madsci/common/auth_context.py new file mode 100644 index 000000000..e524db9d1 --- /dev/null +++ b/src/madsci_common/madsci/common/auth_context.py @@ -0,0 +1,52 @@ +"""Ambient ``AuthClient`` context for outbound credential propagation. + +When an ``AuthClient`` is installed via ``auth_client_context()``, the MADSci +``create_httpx_client()`` factory and other in-process helpers can +transparently pick it up to inject ``Authorization: Bearer `` headers +on outbound requests and to handle on-401 force-refresh-and-retry. + +This module deliberately uses ``Any`` for the client type to avoid importing +the ``madsci.client`` package — ``madsci.common`` must stay +dependency-light. The protocol the client must satisfy is: + +- ``get_access_token() -> str`` — return a (possibly auto-refreshed) token +- ``refresh() -> Any`` — force a refresh-grant exchange + +In practice the only conforming implementation is +``madsci.client.auth_client.AuthClient``. +""" + +from __future__ import annotations + +import contextlib +import contextvars +from typing import Any, Iterator, Optional + +_current_auth_client: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar( + "auth_client", default=None +) + + +def get_current_auth_client() -> Optional[Any]: + """Return the currently-installed ambient AuthClient, if any.""" + return _current_auth_client.get() + + +@contextlib.contextmanager +def auth_client_context(client: Any) -> Iterator[Any]: + """Install ``client`` as the ambient AuthClient for the current scope. + + Mirrors ``event_client_context()`` semantics. Nested contexts replace the + binding for their lifetime; on exit the previous binding is restored. + """ + token = _current_auth_client.set(client) + try: + yield client + finally: + _current_auth_client.reset(token) + + +__all__ = [ + "auth_client_context", + "get_current_auth_client", +] diff --git a/src/madsci_common/madsci/common/auth_decorators.py b/src/madsci_common/madsci/common/auth_decorators.py new file mode 100644 index 000000000..d68c839e9 --- /dev/null +++ b/src/madsci_common/madsci/common/auth_decorators.py @@ -0,0 +1,187 @@ +"""``@requires(permission=...)`` decorator for endpoint authorization. + +Usage:: + + from madsci.common.auth_decorators import requires + from madsci.common.middleware import current_principal + + @get("/events") + @requires(permission="event.read") + async def get_events(self, request: Request) -> list[Event]: + ... + +When ``project_from=`` is supplied, the decorator additionally +verifies that the principal is a member of the project identified by the +named field on the request body or path parameter. +""" + +from __future__ import annotations + +import functools +import inspect +from typing import Any, Callable, Optional + +from fastapi import HTTPException, Request +from madsci.common.auth_middleware import current_principal + + +def _principal_has_permission(principal: Any, permission: str) -> bool: + if principal is None: + return False + perms = set(principal.permissions or []) + return permission in perms or "*" in perms + + +def _resolve_project_id( + request: Request, field_name: str, kwargs: dict[str, Any] +) -> Optional[str]: + """Best-effort lookup of a project_id from the request. + + Looked up in this order: path params, query params, then a body field. + If a body kwarg is a Pydantic model with the named attribute, that wins. + """ + if field_name in request.path_params: + return str(request.path_params[field_name]) + if field_name in request.query_params: + return str(request.query_params[field_name]) + for value in kwargs.values(): + if hasattr(value, field_name): + v = getattr(value, field_name) + if v: + return str(v) + if isinstance(value, dict) and field_name in value: + return str(value[field_name]) + return None + + +def requires( + *, + permission: str, + project_from: Optional[str] = None, +) -> Callable: + """Decorator that enforces a permission check on a Routable endpoint. + + The wrapped function MUST accept ``request: Request`` as a parameter so + we can read ``request.state.principal``. + """ + + def decorator(func: Callable) -> Callable: + sig = inspect.signature(func) + if "request" not in sig.parameters: + raise TypeError( + f"@requires-decorated endpoint {func.__qualname__} must accept" + " a parameter named 'request: Request'" + ) + + is_coro = inspect.iscoroutinefunction(func) + + if is_coro: + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + request = _get_request_arg(sig, args, kwargs) + _check(request, kwargs, permission, project_from) + return await func(*args, **kwargs) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + request = _get_request_arg(sig, args, kwargs) + _check(request, kwargs, permission, project_from) + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def _get_request_arg(sig: inspect.Signature, args: tuple, kwargs: dict) -> Request: + bound = sig.bind_partial(*args, **kwargs) + request = bound.arguments.get("request") + if not isinstance(request, Request): + raise HTTPException( + status_code=500, + detail="@requires: 'request' argument is missing or wrong type", + ) + return request + + +def _check( + request: Request, + kwargs: dict[str, Any], + permission: str, + project_from: Optional[str], +) -> None: + # If AuthMiddleware isn't installed (auth_enabled=False), it never set + # ``request.state.principal`` — not even to None. Treat that as "auth is + # off" and no-op, preserving backwards compatibility for managers that + # haven't enabled auth yet. The middleware ALWAYS sets ``principal`` + # (possibly to None) when it runs, so this signal is reliable. + if not hasattr(request.state, "principal"): + return + principal = current_principal(request) + if principal is None: + raise HTTPException(status_code=401, detail="authentication required") + if not _principal_has_permission(principal, permission): + raise HTTPException( + status_code=403, + detail=f"missing required permission: {permission}", + ) + if project_from is not None: + project_id = _resolve_project_id(request, project_from, kwargs) + if project_id is None: + raise HTTPException( + status_code=400, + detail=f"missing project field {project_from!r} for project-scoped check", + ) + if project_id not in (principal.project_ids or []): + raise HTTPException( + status_code=403, + detail=f"principal is not a member of project {project_id}", + ) + + +# Canonical permission namespace +PERMISSION_NAMESPACE = { + # Wildcard + "*": "Full administrative privileges", + # Experiment + "experiment.read": "Read experiments and their metadata", + "experiment.write": "Create or modify experiments", + # Workflow + "workflow.read": "Read workflow definitions and runs", + "workflow.submit": "Submit new workflow runs", + # Resource + "resource.read": "Read resource state and inventory", + "resource.write": "Mutate resource state, attach/detach to locations", + # Workcell + "workcell.read": "Read workcell configuration", + "workcell.execute": "Execute workcell actions and admin commands", + # Node + "node.read": "Read node status and definitions", + "node.execute_action": "Send action commands to nodes", + # Event / observability + "event.read": "Query the event log", + # Auth admin + "auth.user.read": "List / read users", + "auth.user.write": "Create / modify users", + "auth.project.read": "List / read projects", + "auth.project.write": "Create / modify projects and memberships", + "auth.role.read": "List / read roles", + "auth.role.write": "Create / modify roles", + "auth.role.grant": "Grant / revoke roles to principals", + "auth.principal.write": "Register service accounts and node identities", + "auth.credentials.rotate": "Rotate service-account / node-identity secrets", + "auth.key.read": "List signing keys", + "auth.key.rotate": "Rotate signing keys", + "auth.key.retire": "Retire signing keys", + "auth.token.introspect": "Introspect tokens (RFC 7662)", + "auth.token.revoke": "Revoke other principals' tokens", +} + + +__all__ = [ + "PERMISSION_NAMESPACE", + "requires", +] diff --git a/src/madsci_common/madsci/common/auth_middleware.py b/src/madsci_common/madsci/common/auth_middleware.py new file mode 100644 index 000000000..efd97c3a1 --- /dev/null +++ b/src/madsci_common/madsci/common/auth_middleware.py @@ -0,0 +1,184 @@ +"""AuthMiddleware for ``AbstractManagerBase``-based managers. + +When ``auth_enabled=True`` on a manager, this middleware: + +1. Extracts ``Authorization: Bearer `` from each request. +2. Verifies the JWT against JWKS cached from ``auth_server_url``. +3. Validates ``iss``/``aud``/``exp``. +4. Populates ``request.state.principal`` with a typed ``Principal``. +5. Enters an ``ownership_context()`` for the request lifetime, sourced from + the validated token claims. + +When ``auth_required=False`` (migration mode), unauthenticated requests pass +through with ``request.state.principal = None`` and a structured warning is +logged so operators can identify unauth'd traffic during rollout. + +This middleware is intentionally implemented in ``madsci.common`` (not +``madsci.client``) because ``AbstractManagerBase`` lives in +``madsci.common`` and must not depend on the auth-client package directly — +the AuthClient is dependency-injected by ``AbstractManagerBase`` when +``auth_enabled``. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Optional + +from madsci.common.ownership import ownership_context +from madsci.common.types.auth_types import OwnershipInfo, Principal +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +logger = logging.getLogger(__name__) + + +_DEPRECATION_LAST_LOGGED: dict[str, float] = {} +_DEPRECATION_INTERVAL_SECONDS = 60.0 + + +def warn_caller_asserted_ownership(call_site: str) -> None: + """Emit a sampled deprecation warning for caller-asserted OwnershipInfo. + + Per Decision 10, when ``auth_enabled=False``, caller-asserted + ``OwnershipInfo`` continues to be accepted but a sampled warning is + emitted (default once per process per minute per call-site). + """ + now = time.time() + last = _DEPRECATION_LAST_LOGGED.get(call_site, 0.0) + if now - last < _DEPRECATION_INTERVAL_SECONDS: + return + _DEPRECATION_LAST_LOGGED[call_site] = now + logger.warning( + "DEPRECATION: caller-asserted OwnershipInfo accepted at %s. " + "This behavior will be removed in the same MADSci release that drops " + "auth_required=False; see docs/guides/auth_operator.md for the " + "migration plan.", + call_site, + ) + + +class AuthMiddleware(BaseHTTPMiddleware): + """Validate JWTs and bind validated claims into request state and ownership.""" + + def __init__( + self, + app: Any, + *, + auth_client: Any, + auth_required: bool = False, + lab_id: Optional[str] = None, + unauthenticated_paths: Optional[set[str]] = None, + ) -> None: + """Configure the middleware with an injected ``AuthClient``. + + ``auth_required=False`` enables migration mode: unauth'd requests pass + through with ``request.state.principal = None`` and a structured + warning is logged. + + ``unauthenticated_paths`` is an exact-match set of URL paths that + SHALL bypass the bearer-token check entirely — used for endpoints + that must remain reachable without a token (e.g., the Auth Manager's + own ``/token`` and ``/.well-known/jwks.json``). + """ + super().__init__(app) + self._auth_client = auth_client + self._auth_required = auth_required + self._lab_id = lab_id + self._unauth_paths = unauthenticated_paths or set() + + async def dispatch( # noqa: C901 + self, + request: Request, + call_next: Any, + ) -> Response: + """Verify the bearer token and bind ownership context for the request.""" + # Allowlist: don't enforce auth, but still try to populate the + # principal if a valid token was presented. Some endpoints behave + # differently for authenticated callers (e.g., ``/introspect`` per + # RFC 7662 returns full claims to authorized holders, ``{active: + # false}`` to everyone else). + if request.url.path in self._unauth_paths: + principal: Optional[Principal] = None + auth_header = request.headers.get("authorization") + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + try: + claims = self._auth_client.verify_jwt(token) + principal = Principal.from_claims(claims) + except Exception: + principal = None + request.state.principal = principal + if principal is not None: + ownership = OwnershipInfo.from_jwt_claims(principal.claims) + with ownership_context(**ownership.model_dump(exclude_none=True)): + return await call_next(request) + return await call_next(request) + + auth_header = request.headers.get("authorization") + principal: Optional[Principal] = None + + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + try: + claims = self._auth_client.verify_jwt(token) + principal = Principal.from_claims(claims) + except Exception as exc: + if self._auth_required: + return JSONResponse( + status_code=401, + content={ + "error": "invalid_token", + "error_description": str(exc), + }, + ) + logger.warning( + "AuthMiddleware: invalid token presented but auth_required=False" + " — passing request through (token=%s...)", + token[:12], + ) + else: + if self._auth_required: + return JSONResponse( + status_code=401, + content={ + "error": "missing_token", + "error_description": "Authorization: Bearer header required", + }, + ) + if auth_header is None: + logger.warning( + "AuthMiddleware: unauth'd request to %s (auth_required=False)", + request.url.path, + ) + + request.state.principal = principal + + if principal is not None: + ownership = OwnershipInfo.from_jwt_claims(principal.claims) + with ownership_context(**ownership.model_dump(exclude_none=True)): + return await call_next(request) + return await call_next(request) + + +def current_principal(request: Request) -> Optional[Principal]: + """Return the validated principal on the current request, if any.""" + return getattr(request.state, "principal", None) + + +def current_ownership(request: Request) -> Any: + """Return an OwnershipInfo derived from the request's principal.""" + principal = current_principal(request) + if principal is None: + return OwnershipInfo() + return OwnershipInfo.from_jwt_claims(principal.claims) + + +__all__ = [ + "AuthMiddleware", + "current_ownership", + "current_principal", + "warn_caller_asserted_ownership", +] diff --git a/src/madsci_common/madsci/common/bundled_templates/manager/auth/.gitignore.j2 b/src/madsci_common/madsci/common/bundled_templates/manager/auth/.gitignore.j2 new file mode 100644 index 000000000..47d1fdf03 --- /dev/null +++ b/src/madsci_common/madsci/common/bundled_templates/manager/auth/.gitignore.j2 @@ -0,0 +1,4 @@ +# Auth Manager — keep secrets out of version control. +.madsci/secrets/ +.madsci/audit/ +.env diff --git a/src/madsci_common/madsci/common/bundled_templates/manager/auth/README.md.j2 b/src/madsci_common/madsci/common/bundled_templates/manager/auth/README.md.j2 new file mode 100644 index 000000000..3562be8fb --- /dev/null +++ b/src/madsci_common/madsci/common/bundled_templates/manager/auth/README.md.j2 @@ -0,0 +1,36 @@ +# Auth Manager Deployment + +Generated from the `auth` template. See `docs/guides/auth.md` and +`docs/guides/auth_operator.md` for the full architecture and operator runbook. + +## Quick start + +```bash +# 1. Bootstrap (creates admin user, signing keypair, built-in roles) +madsci auth bootstrap --username admin --lab-id {{ lab_id }} + +# 2. Start the server +python -m madsci.auth_manager.auth_server + +# 3. Register every other manager + node (returns plaintext secrets exactly once) +madsci auth manager register --manager-id +madsci auth node register --node-id --workcell-id +``` + +## Migration mode (recommended initial rollout) + +Set on each consuming manager: + +```yaml +auth_enabled: true +auth_required: false # accept unauth'd requests, log structured warnings +auth_server_url: "http://{{ server_host }}:{{ server_port }}/" +``` + +Watch logs for `AuthMiddleware: unauth'd request` warnings. Once they dry up, +flip `auth_required: true`. + +## Secret hygiene + +`.madsci/secrets/` MUST be `0700` and individual secret files `0600`. +The bundled `.gitignore` already excludes the directory. diff --git a/src/madsci_common/madsci/common/bundled_templates/manager/auth/auth.settings.yaml.j2 b/src/madsci_common/madsci/common/bundled_templates/manager/auth/auth.settings.yaml.j2 new file mode 100644 index 000000000..f3146481c --- /dev/null +++ b/src/madsci_common/madsci/common/bundled_templates/manager/auth/auth.settings.yaml.j2 @@ -0,0 +1,30 @@ +# MADSci Auth Manager settings (port 8007) +# Generated from the bundled `auth` template. + +server_url: "http://{{ server_host }}:{{ server_port }}/" +manager_name: "auth_manager" +manager_description: "MADSci Auth Manager — JWT issuance, JWKS, RBAC, deny-list" + +lab_id: "{{ lab_id }}" +database_url: "{{ database_url }}" + +# Default lifetimes (override only if you understand the trade-offs). +access_token_ttl: 900 # 15 min +refresh_token_ttl: 2592000 # 30 days +signing_key_ttl: 7776000 # 90 days + +# Argon2id tuning (defaults are OWASP-recommended for current hardware). +argon2_time_cost: 3 +argon2_memory_cost: 65536 # KiB (64 MiB) +argon2_parallelism: 4 + +# Deny-list grace period (seconds past exp to retain the row). +deny_list_persist_grace: 300 + +# Local audit-log fallback used by consuming managers when this Auth Manager +# is unreachable. See docs/guides/auth_operator.md for sizing guidance. +local_audit_log_path: ".madsci/audit/auth-fallback.log" +local_audit_log_max_bytes: 104857600 # 100 MB + +# Rate limiting on /token (RFC 6749 §5.2 errors are still surfaced). +rate_limit_enabled: true diff --git a/src/madsci_common/madsci/common/bundled_templates/manager/auth/template.yaml b/src/madsci_common/madsci/common/bundled_templates/manager/auth/template.yaml new file mode 100644 index 000000000..727010ef6 --- /dev/null +++ b/src/madsci_common/madsci/common/bundled_templates/manager/auth/template.yaml @@ -0,0 +1,36 @@ +name: "Auth Manager" +version: "1.0.0" +description: "Auth Manager (port 8007) deployment scaffolding — settings.yaml, .env, and operator notes" +category: "manager" +tags: ["auth", "manager", "security"] +skills: ["madsci-managers"] + +author: "MADSci Team" +license: "MIT" +min_madsci_version: "0.8.0" +schema_version: "1.0" + +target_model: "madsci.common.types.auth_types:AuthManagerSettings" + +parameters: + - name: lab_id + type: string + description: "lab_id (ULID) the Auth Manager binds to (Decision 12)" + required: true + - name: server_host + type: string + description: "Hostname this Auth Manager binds to" + required: false + default: "localhost" + - name: server_port + type: integer + description: "Port — default 8007 (reserved)" + required: false + default: 8007 + min: 1024 + max: 65535 + - name: database_url + type: string + description: "PostgreSQL URL" + required: false + default: "postgresql://madsci:madsci@localhost/madsci_auth" diff --git a/src/madsci_common/madsci/common/http_client.py b/src/madsci_common/madsci/common/http_client.py index 91f67aa21..68eef003a 100644 --- a/src/madsci_common/madsci/common/http_client.py +++ b/src/madsci_common/madsci/common/http_client.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, Union import httpx +from madsci.common.auth_context import get_current_auth_client if TYPE_CHECKING: from madsci.common.types.client_types import MadsciHttpClientConfig @@ -261,6 +262,70 @@ async def __aexit__(self, *args: object) -> None: # --------------------------------------------------------------------------- +def _install_auth_hooks( + event_hooks: dict[str, list], + *, + async_mode: bool, +) -> None: + """Install ambient-AuthClient request/response hooks on ``event_hooks``. + + On request, injects ``Authorization: Bearer `` from the ambient + ``AuthClient`` (set via ``auth_client_context()``) when the caller has + not already supplied an Authorization header. On a 401 response, asks + the ambient client to force a deny-list refresh and retry the refresh + grant; the actual retry is handled by the higher-level call site. + + No-op when no ambient client is set, preserving existing unauthenticated + behavior. + """ + if async_mode: + + async def _async_request_hook(request: httpx.Request) -> None: + _maybe_inject_bearer(request) + + async def _async_response_hook(response: httpx.Response) -> None: + _maybe_refresh_on_401(response) + + event_hooks["request"].append(_async_request_hook) + event_hooks["response"].append(_async_response_hook) + else: + + def _request_hook(request: httpx.Request) -> None: + _maybe_inject_bearer(request) + + def _response_hook(response: httpx.Response) -> None: + _maybe_refresh_on_401(response) + + event_hooks["request"].append(_request_hook) + event_hooks["response"].append(_response_hook) + + +def _maybe_inject_bearer(request: httpx.Request) -> None: + """Inject Authorization from the ambient AuthClient if one is present.""" + client_obj = get_current_auth_client() + if client_obj is None or "authorization" in (k.lower() for k in request.headers): + return + try: + token = client_obj.get_access_token() + except Exception: + return + request.headers["Authorization"] = f"Bearer {token}" + + +def _maybe_refresh_on_401(response: httpx.Response) -> None: + """Force-refresh the ambient AuthClient's tokens after a 401.""" + if response.status_code != 401: + return + client_obj = get_current_auth_client() + if client_obj is None: + return + try: + client_obj.force_deny_list_refresh() + client_obj.refresh() + except Exception: + return + + def _make_rate_limit_hook( tracker: RateLimitTracker, *, @@ -374,10 +439,12 @@ def create_httpx_client( pool=5.0, ) - # -- Event hooks (rate limit tracking) ---------------------------------- + # -- Event hooks (rate limit tracking + ambient auth) ------------------- event_hooks: dict[str, list] = {"request": [], "response": []} rate_limit_tracker: RateLimitTracker | None = None + _install_auth_hooks(event_hooks, async_mode=async_mode) + if config.rate_limit_tracking_enabled: rate_limit_tracker = RateLimitTracker( warning_threshold=config.rate_limit_warning_threshold, diff --git a/src/madsci_common/madsci/common/manager_base.py b/src/madsci_common/madsci/common/manager_base.py index 1f0d0d112..1d49ab446 100644 --- a/src/madsci_common/madsci/common/manager_base.py +++ b/src/madsci_common/madsci/common/manager_base.py @@ -365,6 +365,69 @@ def setup_ownership(self) -> None: if isinstance(self._settings, ManagerSettings): global_ownership_info.manager_id = self._settings.manager_id + def unauthenticated_paths(self) -> set[str]: + """Return URL paths that bypass AuthMiddleware on this manager. + + The default set covers operator/monitor endpoints (``/health``, + ``/settings``, OpenAPI). Subclasses MAY extend this — e.g., the Auth + Manager itself adds ``/token``, ``/.well-known/jwks.json``, + ``/deny-list`` since those are needed to bootstrap and validate + tokens. + """ + return { + "/health", + "/settings", + "/openapi.json", + "/docs", + "/redoc", + } + + def _setup_auth_middleware(self, app: FastAPI) -> None: + """Construct an AuthClient and install AuthMiddleware on the app.""" + from madsci.common.auth_middleware import AuthMiddleware # noqa: PLC0415 + + auth_url = getattr(self._settings, "auth_server_url", None) + if auth_url is None: + self.logger.warning( + "auth_enabled=True but auth_server_url is unset;" + " AuthMiddleware not installed", + event_type=EventType.MANAGER_ERROR, + ) + return + + try: + from madsci.client.auth_client import AuthClient # noqa: PLC0415 + except ImportError: + self.logger.warning( + "auth_enabled=True but madsci.client.auth_client is unavailable", + event_type=EventType.MANAGER_ERROR, + ) + return + + # Defense-in-depth: expected issuer is the auth_server_url itself, + # expected audience is the lab_id. Both are validated on every JWT + # verification when set. + expected_audience = getattr(self._settings, "lab_id", None) + self._auth_client = AuthClient( + auth_server_url=str(auth_url), + expected_issuer=str(auth_url).rstrip("/"), + expected_audience=expected_audience, + ) + app.add_middleware( + AuthMiddleware, + auth_client=self._auth_client, + auth_required=getattr(self._settings, "auth_required", False), + unauthenticated_paths=set(self.unauthenticated_paths()), + lab_id=expected_audience, + ) + self.logger.info( + "AuthMiddleware installed", + event_type=EventType.MANAGER_START, + auth_server_url=str(auth_url), + auth_required=getattr(self._settings, "auth_required", False), + expected_audience=expected_audience, + ) + def get_health(self) -> ManagerHealth: """ Get the health status of this manager. @@ -593,6 +656,12 @@ def configure_app(self, app: FastAPI) -> None: manager_name=manager_name, ) + # Install AuthMiddleware when auth_enabled + if isinstance(self._settings, ManagerSettings) and getattr( + self._settings, "auth_enabled", False + ): + self._setup_auth_middleware(app) + # Server creation and lifecycle methods def create_server(self, **kwargs: Any) -> FastAPI: diff --git a/src/madsci_common/madsci/common/types/auth_types.py b/src/madsci_common/madsci/common/types/auth_types.py index 550a776dc..9b7345179 100644 --- a/src/madsci_common/madsci/common/types/auth_types.py +++ b/src/madsci_common/madsci/common/types/auth_types.py @@ -1,16 +1,46 @@ -"""Types related to authentication and ownership of MADSci objects.""" +"""Types related to authentication, authorization, and ownership of MADSci objects.""" +from datetime import datetime +from enum import Enum from typing import Any, Optional -from madsci.common.types.base_types import MadsciBaseModel +from madsci.common.types.base_types import ( + MadsciBaseModel, + prefixed_alias_generator, + prefixed_model_validator, +) +from madsci.common.types.manager_types import ( + ManagerSettings, + ManagerType, +) +from madsci.common.utils import new_ulid_str from madsci.common.validators import optional_ulid_validator, ulid_validator from pydantic import ( + AliasChoices, + AnyUrl, Field, SerializationInfo, SerializerFunctionWrapHandler, model_serializer, ) from pydantic.functional_validators import field_validator +from pydantic_settings import SettingsConfigDict + + +class PrincipalType(str, Enum): + """Type of principal a token represents.""" + + USER = "user" + SERVICE_ACCOUNT = "service_account" + NODE = "node" + + +class GrantType(str, Enum): + """OAuth 2.0 grant types supported by the Auth Manager.""" + + PASSWORD = "password" # noqa: S105 + REFRESH_TOKEN = "refresh_token" # noqa: S105 + CLIENT_CREDENTIALS = "client_credentials" class OwnershipInfo(MadsciBaseModel): @@ -96,6 +126,35 @@ def check(self, other: "OwnershipInfo") -> bool: return False return True + @classmethod + def from_jwt_claims(cls, claims: "JWTClaims") -> "OwnershipInfo": + """Build an OwnershipInfo from validated JWT claims. + + - ``lab_id`` ← ``claims.aud`` + - ``user_id`` ← ``claims.user_id`` (when ``principal_type=user``) + - ``node_id`` ← ``claims.node_id`` (when ``principal_type=node``) + - ``workcell_id`` ← ``claims.workcell_id`` + - ``manager_id`` ← ``claims.manager_id`` (when ``principal_type=service_account``) + + ``project_id`` is intentionally left unset; project context is + established per-operation via ``@requires(project_from=...)``. + """ + return cls( + lab_id=claims.aud, + user_id=claims.user_id + if claims.principal_type == PrincipalType.USER + else None, + node_id=claims.node_id + if claims.principal_type == PrincipalType.NODE + else None, + workcell_id=claims.workcell_id, + manager_id=( + claims.manager_id + if claims.principal_type == PrincipalType.SERVICE_ACCOUNT + else None + ), + ) + class UserInfo(MadsciBaseModel): """Information about a user.""" @@ -129,3 +188,479 @@ class ProjectInfo(MadsciBaseModel): ) is_ulid = field_validator("project_id", mode="after")(ulid_validator) + + +# --------------------------------------------------------------------------- +# RBAC primitives +# --------------------------------------------------------------------------- + + +class Permission(MadsciBaseModel): + """A permission string in the canonical ``.`` namespace.""" + + name: str = Field( + title="Permission Name", + description="The canonical permission string (e.g., 'experiment.write').", + ) + description: Optional[str] = Field( + title="Description", + description="Human-readable description of what this permission grants.", + default=None, + ) + + +class Role(MadsciBaseModel): + """A named bundle of permissions that can be granted to principals.""" + + role_id: str = Field( + title="Role ID", + description="ULID for this role.", + default_factory=new_ulid_str, + ) + name: str = Field( + title="Role Name", + description="Unique role name (e.g., 'admin', 'experimenter').", + ) + description: Optional[str] = Field( + title="Description", + description="Human-readable description of the role.", + default=None, + ) + permissions: list[str] = Field( + title="Permissions", + description="List of permission strings granted by this role.", + default_factory=list, + ) + + is_ulid = field_validator("role_id", mode="after")(ulid_validator) + + +class ProjectMembership(MadsciBaseModel): + """A user's membership in a project, with one or more roles scoped to it.""" + + user_id: str = Field( + title="User ID", + description="The user who is a member.", + ) + project_id: str = Field( + title="Project ID", + description="The project the user is a member of.", + ) + role_ids: list[str] = Field( + title="Role IDs", + description="Roles granted to this user within this project.", + default_factory=list, + ) + + is_ulid_user = field_validator("user_id", mode="after")(ulid_validator) + is_ulid_project = field_validator("project_id", mode="after")(ulid_validator) + + +class ServiceAccount(MadsciBaseModel): + """A non-human principal representing a manager service. + + ``client_secret`` is never stored or returned in plaintext after the + initial registration; only the Argon2 hash is persisted. + """ + + client_id: str = Field( + title="Client ID", + description="OAuth 2.0 client identifier for this service account.", + ) + manager_id: str = Field( + title="Manager ID", + description="The ULID of the manager this service account represents.", + ) + is_active: bool = Field( + title="Is Active", + description="Whether this service account can authenticate.", + default=True, + ) + role_ids: list[str] = Field( + title="Role IDs", + description="Roles granted to this service account (typically global).", + default_factory=list, + ) + created_at: Optional[datetime] = Field( + title="Created At", + description="When this service account was created.", + default=None, + ) + + is_ulid_manager = field_validator("manager_id", mode="after")(ulid_validator) + + +class NodeIdentity(MadsciBaseModel): + """A principal representing a laboratory node. + + ``client_secret`` is never stored or returned in plaintext after the + initial registration; only the Argon2 hash is persisted. + + The ``mtls_cert_fingerprint`` field is reserved for the future mTLS + follow-on change. + """ + + client_id: str = Field( + title="Client ID", + description="OAuth 2.0 client identifier for this node identity.", + ) + node_id: str = Field( + title="Node ID", + description="The ULID of the node this identity represents.", + ) + workcell_id: Optional[str] = Field( + title="Workcell ID", + description="Optional workcell scope for this node.", + default=None, + ) + is_active: bool = Field( + title="Is Active", + description="Whether this node identity can authenticate.", + default=True, + ) + role_ids: list[str] = Field( + title="Role IDs", + description="Roles granted to this node identity.", + default_factory=list, + ) + mtls_cert_fingerprint: Optional[str] = Field( + title="mTLS Certificate Fingerprint", + description=( + "Reserved for the future mTLS follow-on change. SHA-256" + " fingerprint of the node's mTLS client certificate." + ), + default=None, + ) + created_at: Optional[datetime] = Field( + title="Created At", + description="When this node identity was created.", + default=None, + ) + + is_ulid_node = field_validator("node_id", mode="after")(ulid_validator) + is_ulid_workcell = field_validator("workcell_id", mode="after")( + optional_ulid_validator + ) + + +# --------------------------------------------------------------------------- +# JWT / token model +# --------------------------------------------------------------------------- + + +class JWTClaims(MadsciBaseModel): + """The decoded claims of a MADSci access token.""" + + iss: str = Field( + title="Issuer", + description="The Auth Manager URL that issued the token.", + ) + aud: str = Field( + title="Audience", + description="The deployment's lab_id (single string, not an array).", + ) + sub: str = Field( + title="Subject", + description=( + "The canonical principal identifier — user_id for users, client_id" + " for service accounts and nodes." + ), + ) + iat: int = Field( + title="Issued At", + description="Token issuance time (Unix epoch seconds).", + ) + exp: int = Field( + title="Expiration", + description="Token expiration time (Unix epoch seconds).", + ) + jti: str = Field( + title="JWT ID", + description="Unique identifier for this token (ULID).", + ) + principal_type: PrincipalType = Field( + title="Principal Type", + description="user, service_account, or node.", + ) + roles: list[str] = Field( + title="Roles", + description="List of role IDs granted to this principal.", + default_factory=list, + ) + permissions: list[str] = Field( + title="Permissions", + description="Flattened list of permission strings.", + default_factory=list, + ) + user_id: Optional[str] = Field( + title="User ID", + description="Set for user tokens.", + default=None, + ) + project_ids: list[str] = Field( + title="Project IDs", + description="Project memberships at issuance time (user tokens).", + default_factory=list, + ) + manager_id: Optional[str] = Field( + title="Manager ID", + description=( + "Operational manager identity for service accounts (distinct from" + " ``sub``/``client_id``)." + ), + default=None, + ) + node_id: Optional[str] = Field( + title="Node ID", + description="Set for node tokens.", + default=None, + ) + workcell_id: Optional[str] = Field( + title="Workcell ID", + description="Set for node tokens scoped to a workcell.", + default=None, + ) + + +class TokenResponse(MadsciBaseModel): + """The OAuth 2.0 token-endpoint response.""" + + access_token: str = Field( + title="Access Token", + description="The signed JWT access token.", + ) + token_type: str = Field( + title="Token Type", + description="Always 'Bearer'.", + default="Bearer", + ) + expires_in: int = Field( + title="Expires In", + description="Lifetime of the access token in seconds.", + ) + refresh_token: Optional[str] = Field( + title="Refresh Token", + description="Opaque refresh token. Absent for client_credentials grants.", + default=None, + ) + scope: Optional[str] = Field( + title="Scope", + description="Granted scope (currently unused; reserved for future).", + default=None, + ) + + +class Principal(MadsciBaseModel): + """The validated principal of an authenticated request.""" + + sub: str = Field( + title="Subject", + description="The token's sub claim.", + ) + principal_type: PrincipalType = Field( + title="Principal Type", + description="user, service_account, or node.", + ) + permissions: list[str] = Field( + title="Permissions", + description="Flattened list of permission strings from the token.", + default_factory=list, + ) + roles: list[str] = Field( + title="Roles", + description="Role IDs granted to this principal.", + default_factory=list, + ) + project_ids: list[str] = Field( + title="Project IDs", + description="Project memberships at token-issuance time.", + default_factory=list, + ) + claims: JWTClaims = Field( + title="JWT Claims", + description="The full decoded claims for downstream inspection.", + ) + + @classmethod + def from_claims(cls, claims: JWTClaims) -> "Principal": + """Build a Principal from validated JWT claims.""" + return cls( + sub=claims.sub, + principal_type=claims.principal_type, + permissions=list(claims.permissions), + roles=list(claims.roles), + project_ids=list(claims.project_ids), + claims=claims, + ) + + +# --------------------------------------------------------------------------- +# Auth Manager settings +# --------------------------------------------------------------------------- + + +class AuthManagerSettings( + ManagerSettings, + env_file=(".env", "auth.env"), + toml_file=("settings.toml", "auth.settings.toml"), + yaml_file=("settings.yaml", "auth.settings.yaml"), + json_file=("settings.json", "auth.settings.json"), + env_prefix="AUTH_", +): + """Settings for the Auth Manager.""" + + model_config = SettingsConfigDict( + alias_generator=prefixed_alias_generator("auth"), + populate_by_name=True, + ) + _accept_prefixed_keys = prefixed_model_validator("auth") + + server_url: AnyUrl = Field( + title="Auth Server URL", + description="The URL of the Auth Manager server.", + default=AnyUrl("http://localhost:8007"), + ) + manager_type: Optional[ManagerType] = Field( + title="Manager Type", + description="The type of manager.", + default=ManagerType.AUTH_MANAGER, + ) + # The Auth Manager is the only manager that MUST default to enforcing + # auth on its own admin surface — see the security review of + # ``auth_manager_foundation``. Operators may opt out for local dev / + # testing only; ``run_server()`` refuses to bind in production-style use. + auth_enabled: bool = Field( + default=True, + title="Auth Enabled", + description=( + "Whether AuthMiddleware is installed on the Auth Manager itself." + " Defaults to True (the Auth Manager is the one service where" + " unauth'd admin endpoints would be a privilege-escalation vector)." + " Set to False only for in-process unit tests." + ), + ) + auth_required: bool = Field( + default=True, + title="Auth Required", + description=( + "Whether the Auth Manager rejects unauthenticated requests on" + " non-allowlisted routes (vs. admitting them with" + " ``request.state.principal=None``). Defaults to True; should" + " never be False in production." + ), + ) + database_url: str = Field( + default="postgresql://madsci:madsci@localhost/madsci_auth", + title="Database URL", + description="PostgreSQL URL for Auth Manager persistence.", + validation_alias=AliasChoices("database_url", "AUTH_DB_URL", "db_url"), + json_schema_extra={"secret": True}, + ) + lab_id: Optional[str] = Field( + default=None, + title="Lab ID", + description=( + "The lab_id this Auth Manager binds to. Read at bootstrap; an" + " Auth Manager refuses to start later against a different lab_id" + " without an explicit operator-acknowledged migration." + ), + ) + access_token_ttl: int = Field( + default=900, + title="Access Token TTL", + description="Default access-token lifetime in seconds (15 min).", + ge=60, + ) + refresh_token_ttl: int = Field( + default=60 * 60 * 24 * 30, + title="Refresh Token TTL", + description="Default refresh-token lifetime in seconds (30 days).", + ge=60, + ) + signing_key_ttl: int = Field( + default=60 * 60 * 24 * 90, + title="Signing Key TTL", + description=( + "Recommended lifetime of a signing key before rotation, in seconds" + " (90 days)." + ), + ge=3600, + ) + argon2_time_cost: int = Field( + default=3, + title="Argon2 Time Cost", + description="Argon2id time-cost parameter.", + ge=1, + ) + argon2_memory_cost: int = Field( + default=64 * 1024, + title="Argon2 Memory Cost", + description="Argon2id memory-cost parameter (in KiB; 64 MiB).", + ge=8 * 1024, + ) + argon2_parallelism: int = Field( + default=4, + title="Argon2 Parallelism", + description="Argon2id parallelism parameter.", + ge=1, + ) + deny_list_persist_grace: int = Field( + default=300, + title="Deny-list Persist Grace", + description=( + "Seconds past a revoked token's exp to retain its row in" + " revoked_access_tokens." + ), + ge=0, + ) + token_clock_skew_seconds: int = Field( + default=30, + title="Token Clock-Skew Leeway", + description=( + "Leeway (seconds) applied to ``iat``/``exp``/``nbf`` validation" + " when verifying JWTs. Tolerates small clock drift between issuer" + " and verifier." + ), + ge=0, + ) + trust_forwarded_for: bool = Field( + default=False, + title="Trust X-Forwarded-For", + description=( + "When True, ``_client_ip`` reads the leftmost ``X-Forwarded-For``" + " value. Operators behind a trusted reverse proxy must opt in;" + " otherwise the socket peer is used to prevent audit-log spoofing." + ), + ) + local_audit_log_path: Optional[str] = Field( + default=None, + title="Local Audit Log Path", + description=( + "Path to the on-disk fallback audit log. Defaults to" + " ``.madsci/audit/auth-fallback.log``." + ), + ) + local_audit_log_max_bytes: int = Field( + default=100 * 1024 * 1024, + title="Local Audit Log Max Size", + description="Maximum total size of the local audit log in bytes (100 MB).", + ge=1024 * 1024, + ) + + +__all__ = [ + "AuthManagerSettings", + "GrantType", + "JWTClaims", + "NodeIdentity", + "OwnershipInfo", + "Permission", + "Principal", + "PrincipalType", + "ProjectInfo", + "ProjectMembership", + "Role", + "ServiceAccount", + "TokenResponse", + "UserInfo", +] diff --git a/src/madsci_common/madsci/common/types/manager_types.py b/src/madsci_common/madsci/common/types/manager_types.py index a9042e4e8..9ce660c5e 100644 --- a/src/madsci_common/madsci/common/types/manager_types.py +++ b/src/madsci_common/madsci/common/types/manager_types.py @@ -178,6 +178,32 @@ class ManagerSettings(MadsciBaseSettings): description="OTLP transport protocol ('grpc' or 'http')", ) + # Authentication / authorization integration + auth_enabled: bool = Field( + default=False, + title="Auth Enabled", + description=( + "Enable AuthMiddleware on this manager. When True, an AuthClient" + " is constructed against ``auth_server_url`` and incoming requests" + " carrying ``Authorization: Bearer `` are validated." + ), + ) + auth_required: bool = Field( + default=False, + title="Auth Required", + description=( + "When True, requests without a valid token are rejected with HTTP" + " 401. When False (the migration mode), unauth'd requests are" + " allowed but a structured warning is emitted. Has no effect" + " unless ``auth_enabled`` is True." + ), + ) + auth_server_url: Optional[AnyUrl] = Field( + default=None, + title="Auth Server URL", + description=("URL of the lab's Auth Manager. Required when ``auth_enabled``."), + ) + class ManagerHealth(MadsciBaseModel): """Base health status for MADSci Manager services. diff --git a/src/madsci_common/tests/test_auth_context.py b/src/madsci_common/tests/test_auth_context.py new file mode 100644 index 000000000..7b59a29e7 --- /dev/null +++ b/src/madsci_common/tests/test_auth_context.py @@ -0,0 +1,98 @@ +# ruff: noqa: S106, S107, ARG001 +"""Tests for the ambient AuthClient context propagation.""" + +from __future__ import annotations + +import httpx +from madsci.common.auth_context import ( + auth_client_context, + get_current_auth_client, +) +from madsci.common.http_client import create_httpx_client + + +class _FakeAuthClient: + def __init__(self, token: str = "bearer-abc") -> None: + self._token = token + self.refresh_calls = 0 + self.force_deny_calls = 0 + + def get_access_token(self) -> str: + return self._token + + def refresh(self) -> None: + self.refresh_calls += 1 + self._token = f"refreshed-{self.refresh_calls}" + + def force_deny_list_refresh(self) -> None: + self.force_deny_calls += 1 + + +def test_no_ambient_client_no_header() -> None: + transport_calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + transport_calls.append(request) + return httpx.Response(200) + + client = create_httpx_client() + # Inject mock transport + client._transport = httpx.MockTransport(handler) + client.get("http://example.com/foo") + assert "authorization" not in {k.lower() for k in transport_calls[0].headers} + + +def test_ambient_client_injects_bearer() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200) + + fake = _FakeAuthClient() + client = create_httpx_client() + client._transport = httpx.MockTransport(handler) + + with auth_client_context(fake): + assert get_current_auth_client() is fake + client.get("http://example.com/foo") + + assert captured[0].headers["authorization"] == "Bearer bearer-abc" + + +def test_ambient_client_refresh_on_401() -> None: + counter = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + counter["n"] += 1 + return httpx.Response(401) + + fake = _FakeAuthClient() + client = create_httpx_client() + client._transport = httpx.MockTransport(handler) + + with auth_client_context(fake): + client.get("http://example.com/foo") + + # Refresh and force_deny_list_refresh both called from the response hook + assert fake.refresh_calls == 1 + assert fake.force_deny_calls == 1 + + +def test_explicit_authorization_header_not_overwritten() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200) + + fake = _FakeAuthClient(token="ambient-token") + client = create_httpx_client() + client._transport = httpx.MockTransport(handler) + + with auth_client_context(fake): + client.get( + "http://example.com/foo", headers={"Authorization": "Bearer caller-token"} + ) + + assert captured[0].headers["authorization"] == "Bearer caller-token" diff --git a/src/madsci_common/tests/test_auth_decorators.py b/src/madsci_common/tests/test_auth_decorators.py new file mode 100644 index 000000000..dba446a77 --- /dev/null +++ b/src/madsci_common/tests/test_auth_decorators.py @@ -0,0 +1,105 @@ +# ruff: noqa: ARG001 +"""Tests for the ``@requires`` authorization decorator.""" + +from __future__ import annotations + +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from madsci.common.auth_decorators import requires +from madsci.common.types.auth_types import ( + JWTClaims, + Principal, + PrincipalType, +) +from madsci.common.utils import new_ulid_str + + +def _make_principal( + *, + permissions: list[str] | None = None, + project_ids: list[str] | None = None, + principal_type: PrincipalType = PrincipalType.USER, +) -> Principal: + sub = new_ulid_str() + claims = JWTClaims( + iss="http://localhost:8007", + aud=new_ulid_str(), + sub=sub, + iat=0, + exp=2_000_000_000, + jti=new_ulid_str(), + principal_type=principal_type, + permissions=permissions or [], + project_ids=project_ids or [], + ) + return Principal.from_claims(claims) + + +def _build_app(principal: Principal | None, **decorator_kwargs) -> TestClient: + app = FastAPI() + + @app.middleware("http") + async def inject_principal(request: Request, call_next): + request.state.principal = principal + return await call_next(request) + + @app.get("/restricted") + @requires(**decorator_kwargs) + async def restricted(request: Request) -> dict: + return {"ok": True} + + @app.get("/projects/{project_id}/items") + @requires(permission="experiment.write", project_from="project_id") + async def project_scoped(request: Request, project_id: str) -> dict: + return {"project_id": project_id} + + return TestClient(app) + + +def test_unauthenticated_returns_401() -> None: + client = _build_app(None, permission="event.read") + r = client.get("/restricted") + assert r.status_code == 401 + + +def test_missing_permission_returns_403() -> None: + p = _make_principal(permissions=["other.read"]) + client = _build_app(p, permission="event.read") + r = client.get("/restricted") + assert r.status_code == 403 + + +def test_present_permission_allows() -> None: + p = _make_principal(permissions=["event.read"]) + client = _build_app(p, permission="event.read") + r = client.get("/restricted") + assert r.status_code == 200 + + +def test_wildcard_admin_allows_anything() -> None: + p = _make_principal(permissions=["*"]) + client = _build_app(p, permission="event.read") + r = client.get("/restricted") + assert r.status_code == 200 + + +def test_project_scoped_denies_outsider() -> None: + p = _make_principal( + permissions=["experiment.write"], + project_ids=[new_ulid_str()], + ) + client = _build_app(p, permission="event.read") # other endpoint + proj = new_ulid_str() + r = client.get(f"/projects/{proj}/items") + assert r.status_code == 403 + + +def test_project_scoped_allows_member() -> None: + proj = new_ulid_str() + p = _make_principal( + permissions=["experiment.write"], + project_ids=[proj], + ) + client = _build_app(p, permission="event.read") + r = client.get(f"/projects/{proj}/items") + assert r.status_code == 200 diff --git a/src/madsci_common/tests/test_auth_middleware.py b/src/madsci_common/tests/test_auth_middleware.py new file mode 100644 index 000000000..da54c962c --- /dev/null +++ b/src/madsci_common/tests/test_auth_middleware.py @@ -0,0 +1,156 @@ +# ruff: noqa: S106 +"""Integration tests for AuthMiddleware on AbstractManagerBase.""" + +from __future__ import annotations + +import httpx +from classy_fastapi import get +from fastapi import Request +from fastapi.testclient import TestClient +from madsci.auth_manager.auth_server import AuthManager +from madsci.client.auth_client import AuthClient +from madsci.common.db_handlers.postgres_handler import SQLiteHandler +from madsci.common.manager_base import AbstractManagerBase +from madsci.common.types.auth_types import AuthManagerSettings +from madsci.common.types.manager_types import ( + ManagerSettings, + ManagerType, +) +from madsci.common.utils import new_ulid_str +from pydantic import AnyUrl + +_LAB_ID = new_ulid_str() + + +class _DemoSettings(ManagerSettings): + """Stub settings for the demo manager.""" + + server_url: AnyUrl = AnyUrl("http://localhost:9999") + manager_type: ManagerType | None = None + + +class _DemoManager(AbstractManagerBase[_DemoSettings]): + SETTINGS_CLASS = _DemoSettings + + @get("/whoami") + async def whoami(self, request: Request) -> dict: + principal = getattr(request.state, "principal", None) + if principal is None: + return {"authenticated": False} + return { + "authenticated": True, + "sub": principal.sub, + "principal_type": principal.principal_type.value, + } + + +def _build_auth_pair() -> tuple[AuthManager, AuthClient]: + settings = AuthManagerSettings( + enable_registry_resolution=False, + lab_id=_LAB_ID, + otel_enabled=False, + argon2_time_cost=1, + argon2_memory_cost=8 * 1024, + argon2_parallelism=1, + ) + mgr = AuthManager(settings=settings, postgres_handler=SQLiteHandler()) + mgr.bootstrap(admin_username="admin", admin_password="hunter2") + auth_app = mgr.create_server() + auth_test = TestClient(auth_app) + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.raw_path.decode("ascii") + kwargs: dict = {"headers": dict(request.headers.items())} + if request.content: + kwargs["content"] = request.content + resp = auth_test.request(request.method, path, **kwargs) + return httpx.Response( + status_code=resp.status_code, + headers=dict(resp.headers), + content=resp.content, + ) + + transport = httpx.MockTransport(handler) + client = AuthClient(auth_server_url="http://localhost:8007/") + client._http = httpx.Client( + base_url=client.auth_server_url, transport=transport, timeout=10.0 + ) + return mgr, client + + +def test_middleware_off_by_default() -> None: + settings = _DemoSettings(enable_registry_resolution=False, otel_enabled=False) + demo = _DemoManager(settings=settings) + client = TestClient(demo.create_server()) + r = client.get("/whoami") + assert r.status_code == 200 + assert r.json()["authenticated"] is False + + +def test_middleware_required_rejects_missing_token() -> None: + _, auth_client = _build_auth_pair() + settings = _DemoSettings( + enable_registry_resolution=False, + otel_enabled=False, + auth_enabled=True, + auth_required=True, + auth_server_url=AnyUrl("http://localhost:8007/"), + ) + demo = _DemoManager(settings=settings) + # Inject the patched AuthClient that talks to the in-memory auth manager + demo._auth_client = auth_client + app = demo.create_server() + # Manually replace the AuthMiddleware's client with the patched one + for mw in app.user_middleware: + if "AuthMiddleware" in str(mw.cls): + mw.kwargs["auth_client"] = auth_client + client = TestClient(app) + r = client.get("/whoami") + assert r.status_code == 401 + assert r.json()["error"] == "missing_token" + + +def test_middleware_migration_mode_passes_through() -> None: + _, auth_client = _build_auth_pair() + settings = _DemoSettings( + enable_registry_resolution=False, + otel_enabled=False, + auth_enabled=True, + auth_required=False, + auth_server_url=AnyUrl("http://localhost:8007/"), + ) + demo = _DemoManager(settings=settings) + app = demo.create_server() + for mw in app.user_middleware: + if "AuthMiddleware" in str(mw.cls): + mw.kwargs["auth_client"] = auth_client + client = TestClient(app) + r = client.get("/whoami") + assert r.status_code == 200 + assert r.json()["authenticated"] is False + + +def test_middleware_validates_token() -> None: + _, auth_client = _build_auth_pair() + # Get a valid access token + tok = auth_client.login("admin", "hunter2") + + settings = _DemoSettings( + enable_registry_resolution=False, + otel_enabled=False, + auth_enabled=True, + auth_required=True, + auth_server_url=AnyUrl("http://localhost:8007/"), + ) + demo = _DemoManager(settings=settings) + app = demo.create_server() + for mw in app.user_middleware: + if "AuthMiddleware" in str(mw.cls): + mw.kwargs["auth_client"] = auth_client + client = TestClient(app) + + r = client.get("/whoami", headers={"Authorization": f"Bearer {tok.access_token}"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["authenticated"] is True + assert body["principal_type"] == "user" diff --git a/src/madsci_common/tests/test_auth_types.py b/src/madsci_common/tests/test_auth_types.py new file mode 100644 index 000000000..e2e9ff1a3 --- /dev/null +++ b/src/madsci_common/tests/test_auth_types.py @@ -0,0 +1,212 @@ +# ruff: noqa: S105, S106 +"""Unit tests for the extended ``auth_types`` module.""" + +from __future__ import annotations + +import time + +import pytest +from madsci.common.types.auth_types import ( + AuthManagerSettings, + JWTClaims, + NodeIdentity, + OwnershipInfo, + Permission, + Principal, + PrincipalType, + ProjectMembership, + Role, + ServiceAccount, + TokenResponse, +) +from madsci.common.utils import new_ulid_str +from pydantic import ValidationError + +# --------------------------------------------------------------------------- +# RBAC primitives +# --------------------------------------------------------------------------- + + +def test_permission_round_trip() -> None: + perm = Permission(name="experiment.write", description="Create experiments") + dumped = perm.model_dump() + assert dumped["name"] == "experiment.write" + assert Permission.model_validate(dumped) == perm + + +def test_role_default_role_id_is_ulid() -> None: + role = Role(name="admin", permissions=["experiment.write"]) + # Should be a 26-char ULID and pass validation + assert len(role.role_id) == 26 + Role.model_validate(role.model_dump()) + + +def test_role_rejects_invalid_role_id() -> None: + with pytest.raises(ValidationError): + Role(role_id="not-a-ulid", name="bad") + + +def test_project_membership_validates_ulids() -> None: + user_id = new_ulid_str() + project_id = new_ulid_str() + role_id = new_ulid_str() + + membership = ProjectMembership( + user_id=user_id, + project_id=project_id, + role_ids=[role_id], + ) + assert membership.user_id == user_id + assert membership.role_ids == [role_id] + + with pytest.raises(ValidationError): + ProjectMembership(user_id="bad", project_id=project_id, role_ids=[]) + + +def test_service_account_requires_ulid_manager_id() -> None: + sa = ServiceAccount( + client_id="client-abc", + manager_id=new_ulid_str(), + ) + assert sa.is_active is True + + with pytest.raises(ValidationError): + ServiceAccount(client_id="client", manager_id="not-a-ulid") + + +def test_node_identity_optional_workcell_validates() -> None: + node_id = new_ulid_str() + wc_id = new_ulid_str() + + NodeIdentity(client_id="c", node_id=node_id) + NodeIdentity(client_id="c", node_id=node_id, workcell_id=wc_id) + + with pytest.raises(ValidationError): + NodeIdentity(client_id="c", node_id=node_id, workcell_id="bad") + + +# --------------------------------------------------------------------------- +# JWTClaims and Principal +# --------------------------------------------------------------------------- + + +def _make_user_claims(**overrides: object) -> JWTClaims: + base = { + "iss": "http://localhost:8007", + "aud": new_ulid_str(), # lab_id + "sub": new_ulid_str(), # user_id + "iat": int(time.time()), + "exp": int(time.time()) + 900, + "jti": new_ulid_str(), + "principal_type": PrincipalType.USER, + "permissions": ["experiment.read"], + "roles": [new_ulid_str()], + "user_id": new_ulid_str(), + "project_ids": [new_ulid_str(), new_ulid_str()], + } + base.update(overrides) + return JWTClaims(**base) + + +def test_jwt_claims_user_round_trip() -> None: + claims = _make_user_claims() + dumped = claims.model_dump() + assert dumped["principal_type"] == "user" + assert JWTClaims.model_validate(dumped) == claims + + +def test_principal_from_claims_user() -> None: + claims = _make_user_claims() + p = Principal.from_claims(claims) + assert p.principal_type == PrincipalType.USER + assert p.permissions == claims.permissions + assert p.project_ids == claims.project_ids + assert p.claims == claims + + +def test_ownership_from_jwt_claims_user() -> None: + claims = _make_user_claims() + o = OwnershipInfo.from_jwt_claims(claims) + assert o.lab_id == claims.aud + assert o.user_id == claims.user_id + assert o.node_id is None + assert o.workcell_id is None + assert o.manager_id is None + # project_id intentionally unset (project context is per-operation) + assert o.project_id is None + + +def test_ownership_from_jwt_claims_node() -> None: + workcell_id = new_ulid_str() + node_id = new_ulid_str() + lab_id = new_ulid_str() + claims = JWTClaims( + iss="http://localhost:8007", + aud=lab_id, + sub="client-node-abc", + iat=int(time.time()), + exp=int(time.time()) + 900, + jti=new_ulid_str(), + principal_type=PrincipalType.NODE, + node_id=node_id, + workcell_id=workcell_id, + ) + o = OwnershipInfo.from_jwt_claims(claims) + assert o.lab_id == lab_id + assert o.node_id == node_id + assert o.workcell_id == workcell_id + assert o.user_id is None + assert o.manager_id is None + + +def test_ownership_from_jwt_claims_service_account() -> None: + manager_id = new_ulid_str() + lab_id = new_ulid_str() + claims = JWTClaims( + iss="http://localhost:8007", + aud=lab_id, + sub="client-sa-xyz", + iat=int(time.time()), + exp=int(time.time()) + 900, + jti=new_ulid_str(), + principal_type=PrincipalType.SERVICE_ACCOUNT, + manager_id=manager_id, + ) + o = OwnershipInfo.from_jwt_claims(claims) + assert o.lab_id == lab_id + assert o.manager_id == manager_id + assert o.user_id is None + assert o.node_id is None + + +# --------------------------------------------------------------------------- +# TokenResponse +# --------------------------------------------------------------------------- + + +def test_token_response_defaults() -> None: + tr = TokenResponse(access_token="abc", expires_in=900) + assert tr.token_type == "Bearer" + assert tr.refresh_token is None + + +# --------------------------------------------------------------------------- +# AuthManagerSettings +# --------------------------------------------------------------------------- + + +def test_auth_manager_settings_defaults() -> None: + settings = AuthManagerSettings(enable_registry_resolution=False) + assert str(settings.server_url).startswith("http://localhost:8007") + # Secret-classified field must redact in safe dump + safe = settings.model_dump_safe(include_secrets=False) + assert "***REDACTED***" in str(safe.get("database_url")) + + +def test_auth_manager_settings_prefixed_yaml_keys() -> None: + # Prefixed keys should be accepted via prefixed_model_validator + settings = AuthManagerSettings( + enable_registry_resolution=False, + auth_access_token_ttl=600, + ) + assert settings.access_token_ttl == 600