diff --git a/.env.example b/.env.example index 5173389..27c6a0e 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,17 @@ MAX_CLUSTERS_FOR_EXPLAIN=10 # ── Worker ──────────────────────────────────────────────────────────────────── +# ── HTTP API auth ───────────────────────────────────────────────────────────── +# Default AUTH_ENABLED=false so local demo and unit tests stay open. +# Production and Docker Compose hosts that bind 0.0.0.0 should set AUTH_ENABLED=true. +# AUTH_ENABLED=false +# AUTH_MODE=api_key # api_key | oidc | both +# OIDC_ISSUER= +# OIDC_AUDIENCE= +# OIDC_JWKS_URL= +# API_BIND_HOST=127.0.0.1 +# AUTH_REFUSE_INSECURE_BIND=false + # Seconds the worker sleeps between polling for new jobs when the queue is empty. WORKER_POLL_INTERVAL=2 diff --git a/Makefile b/Makefile index 45c5266..83b425d 100644 --- a/Makefile +++ b/Makefile @@ -87,18 +87,23 @@ ask: # ── Development servers ──────────────────────────────────────────────────────── +# Bind host passed to uvicorn *and* read by the startup auth guard (API_BIND_HOST). +# 0.0.0.0 matches historical `make api` behaviour; with AUTH_ENABLED=false the +# process logs a warning. Loopback: `make api API_BIND_HOST=127.0.0.1`. +API_BIND_HOST ?= 0.0.0.0 + api: - uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload + API_BIND_HOST=$(API_BIND_HOST) uvicorn src.api.app:app --host $(API_BIND_HOST) --port 8000 --reload web: demo @echo "Web UI: http://localhost:8000/ — open it in a browser" - uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload + API_BIND_HOST=$(API_BIND_HOST) uvicorn src.api.app:app --host $(API_BIND_HOST) --port 8000 --reload web-serve: db-up @sleep 2 alembic upgrade head @echo "Web UI: http://localhost:8000/ — open it in a browser" - uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload + API_BIND_HOST=$(API_BIND_HOST) uvicorn src.api.app:app --host $(API_BIND_HOST) --port 8000 --reload worker: raglogs worker diff --git a/README.md b/README.md index 539c4df..4dd4b87 100644 --- a/README.md +++ b/README.md @@ -630,6 +630,26 @@ raglogs config llm_provider --- +### `raglogs keys` + +Mint, list, and revoke HTTP API keys. The plaintext secret is printed **once** at create time and is never stored or logged — only an argon2 hash and a short prefix are kept. + +```bash +raglogs keys create --role query --scope default --name "ci" +raglogs keys list +raglogs keys revoke +``` + +| Flag | Description | +|---|---| +| `--role` | `ingest`, `query`, or `admin` (default `query`) | +| `--scope` | Stored on the key for later tenant isolation (G8). Default `default`. Not used to filter log queries yet. | +| `--name` | Optional label | + +Requires a migrated database (`raglogs init`). See [HTTP API authentication](#http-api-authentication). + +--- + ## Configuration All settings are read from `.env`, environment variables, or CLI flags. Priority: CLI > env var > `.env` file > defaults. @@ -658,6 +678,13 @@ All settings are read from `.env`, environment variables, or CLI flags. Priority | `DATADOG_SITE` | `datadoghq.com` | Datadog site (`us3.datadoghq.com`, `datadoghq.eu`, …) | | `DATADOG_PAGE_SIZE` | `1000` | Logs per Datadog page (API max 1000) | | `DATADOG_MAX_ROWS` | `10000` | Max events pulled in one Datadog ingest run | +| `AUTH_ENABLED` | `false` | Require `Authorization: Bearer` on the HTTP API (except `/health` and `/metrics`). Keep `false` for local demo; set `true` in production/Docker | +| `AUTH_MODE` | `api_key` | `api_key`, `oidc`, or `both` | +| `OIDC_ISSUER` | _(empty)_ | JWT issuer when `AUTH_MODE` is `oidc` or `both` | +| `OIDC_AUDIENCE` | _(empty)_ | Optional JWT audience | +| `OIDC_JWKS_URL` | _(empty)_ | Optional JWKS URL; default is `{issuer}/.well-known/openid-configuration` then `{issuer}/.well-known/jwks.json` | +| `API_BIND_HOST` | `127.0.0.1` | Host the startup guard treats as the bind address. `make api` sets this to `0.0.0.0` to match uvicorn | +| `AUTH_REFUSE_INSECURE_BIND` | `false` | If `true`, refuse to start when auth is off and the bind host is not loopback; if `false`, log a warning only | --- @@ -872,11 +899,54 @@ Possible values: `low`, `medium`, `medium-high`, `high`. raglogs exposes a FastAPI server for integrations and future tooling. ```bash -uvicorn raglogs.api.app:app --host 0.0.0.0 --port 8000 --reload +uvicorn src.api.app:app --host 0.0.0.0 --port 8000 --reload # or make api ``` +`make api` still binds `0.0.0.0`. With the default `AUTH_ENABLED=false` that logs a loud startup warning. Bind loopback instead with `make api API_BIND_HOST=127.0.0.1`, or enable auth (below). + +### HTTP API authentication + +Auth is **off by default** so local demo and existing clients keep working. Set `AUTH_ENABLED=true` before exposing the API on a network (including Docker Compose on `0.0.0.0`). + +```bash +export AUTH_ENABLED=true +raglogs keys create --role admin --name "local" +# copy the rlk_… secret from the panel — it is shown only once + +curl -X POST http://localhost:8000/query/explain \ + -H "Authorization: Bearer rlk_…" \ + -H "Content-Type: application/json" \ + -d '{"since": "30m", "no_llm": true}' +``` + +| Role | Allowed | +|---|---| +| `ingest` | `POST /ingestions` | +| `query` | `GET /ingestions*`, `POST /query/*`, web UI (`GET /`, `/static`), OpenAPI (`/docs`) | +| `admin` | everything, including `GET /config` | + +`GET /health` and `GET /metrics` (path reserved; no Prometheus body yet) are always unauthenticated. `/docs` is **not** exempt. + +Missing or invalid `Authorization: Bearer` returns **401**: + +```json +{"error_code": "AUTH_UNAUTHORIZED", "message": "…"} +``` + +A valid key with the wrong role returns **403**: + +```json +{"error_code": "AUTH_FORBIDDEN", "message": "…"} +``` + +Keys are stored argon2-hashed with a short indexed prefix. `scope` defaults to `default` and is stored for later isolation work — this release does **not** filter clusters or logs by scope. + +Optional OIDC: set `AUTH_MODE=oidc` or `both` and `OIDC_ISSUER`. A JWT (three dotted segments) is validated via JWKS (`iss`, `exp`, and `aud` when `OIDC_AUDIENCE` is set). Role comes from claim `raglogs_role` or `roles`, defaulting to `query`. When `AUTH_MODE=api_key`, JWTs are rejected. + +If auth is disabled and the process binds a non-loopback address (`0.0.0.0`, `::`, a public IP), raglogs logs a warning. Set `AUTH_REFUSE_INSECURE_BIND=true` to refuse startup instead. + | Method | Endpoint | Description | |---|---|---| | `GET` | `/health` | Service and DB health check | @@ -964,9 +1034,10 @@ different ingestion or "All ingestions" to change what a query is scoped to. The UI is server-rendered (Jinja2 + vanilla JS/CSS, no CORS, no node/npm) and calls the same `/query/*` JSON endpoints listed above. -There is no authentication — fine for local dev. If you deploy raglogs -anywhere reachable outside your machine, put it behind a reverse proxy -(nginx, Caddy, etc.) that handles auth. +With default `AUTH_ENABLED=false` the UI is open — fine for local dev. When +auth is on, load the UI with a `query` or `admin` bearer token (the browser +does not attach `Authorization` on its own; put raglogs behind a proxy that +injects the header, or keep auth off on loopback). `/health` stays public. --- @@ -1000,6 +1071,7 @@ raglogs/ ├── src/ │ ├── adapters/ Log source adapters (file, cloudwatch, datadog, loki, k8s) │ ├── api/routes/ FastAPI route handlers +│ ├── api/auth/ API keys, roles, OIDC, bind-host guard │ ├── cli/commands/ Typer CLI commands │ ├── config/ Pydantic settings │ ├── core/ diff --git a/docker-compose.yml b/docker-compose.yml index 0e3dbf6..1befdad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,11 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY:-} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} EMBEDDINGS_PROVIDER: ${EMBEDDINGS_PROVIDER:-disabled} + API_BIND_HOST: "0.0.0.0" + # Compose binds 0.0.0.0 inside the container. Default AUTH_ENABLED=false + # keeps the demo open and logs a startup warning. Set AUTH_ENABLED=true + # (and mint a key with `raglogs keys create`) before exposing this API. + AUTH_ENABLED: ${AUTH_ENABLED:-false} command: > sh -c "alembic upgrade head && uvicorn src.api.app:app --host 0.0.0.0 --port 8000" volumes: diff --git a/migrations/versions/0004_api_keys.py b/migrations/versions/0004_api_keys.py new file mode 100644 index 0000000..3dedada --- /dev/null +++ b/migrations/versions/0004_api_keys.py @@ -0,0 +1,37 @@ +"""api_keys table for hashed HTTP API credentials + +Revision ID: 0004_api_keys +Revises: 0003_source_provenance +Create Date: 2026-08-17 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "0004_api_keys" +down_revision: Union[str, None] = "0003_source_provenance" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("name", sa.String(255), nullable=True), + sa.Column("key_prefix", sa.String(32), nullable=False), + sa.Column("key_hash", sa.Text, nullable=False), + sa.Column("role", sa.String(20), nullable=False), + sa.Column("scope", sa.String(255), nullable=False, server_default="default"), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_api_keys_key_prefix", "api_keys", ["key_prefix"]) + + +def downgrade() -> None: + op.drop_index("ix_api_keys_key_prefix", table_name="api_keys") + op.drop_table("api_keys") diff --git a/pyproject.toml b/pyproject.toml index 0e5929c..c9becf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,9 @@ dependencies = [ "structlog>=24.1.0", "python-dotenv>=1.0.0", "jinja2>=3.1.0", + "argon2-cffi>=23.1.0", + "PyJWT>=2.8.0", + "cryptography>=42.0.0", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 91d32a3..5bc9ace 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,6 @@ python-dateutil>=2.9.0 structlog>=24.1.0 python-dotenv>=1.0.0 jinja2>=3.1.0 +argon2-cffi>=23.1.0 +PyJWT>=2.8.0 +cryptography>=42.0.0 diff --git a/src/api/app.py b/src/api/app.py index 7149fea..3f39e21 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -5,13 +5,19 @@ from fastapi.responses import ORJSONResponse from fastapi.staticfiles import StaticFiles +from src.api.auth.middleware import AuthMiddleware from src.api.routes import ask, clusters, compare_windows, config, explain, health, ingestions, timeline, ui @asynccontextmanager async def lifespan(app: FastAPI): - # Startup: check DB + from src.api.auth.bind_guard import warn_if_insecure_bind + from src.config import get_settings from src.db.session import check_connection + + settings = get_settings() + warn_if_insecure_bind(settings.api_bind_host, settings) + if not check_connection(): import structlog log = structlog.get_logger() @@ -27,6 +33,8 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +app.add_middleware(AuthMiddleware) + app.include_router(health.router, tags=["health"]) app.include_router(ingestions.router, prefix="/ingestions", tags=["ingestions"]) app.include_router(explain.router, prefix="/query", tags=["query"]) diff --git a/src/api/auth/__init__.py b/src/api/auth/__init__.py new file mode 100644 index 0000000..0581fc7 --- /dev/null +++ b/src/api/auth/__init__.py @@ -0,0 +1 @@ +"""HTTP API authentication (API keys, optional OIDC, bind-host guard).""" diff --git a/src/api/auth/bind_guard.py b/src/api/auth/bind_guard.py new file mode 100644 index 0000000..4720d46 --- /dev/null +++ b/src/api/auth/bind_guard.py @@ -0,0 +1,49 @@ +"""Warn (or refuse) when the API binds off-loopback with authentication disabled.""" + +from __future__ import annotations + +from typing import Any + +LOOPBACK_HOSTS: frozenset[str] = frozenset({"127.0.0.1", "::1", "localhost"}) + + +class InsecureBindError(RuntimeError): + """Raised when AUTH_REFUSE_INSECURE_BIND is set and the bind is not loopback.""" + + +def is_loopback_host(host: str) -> bool: + """True for 127.0.0.1, ::1, localhost, and other 127.0.0.0/8 addresses.""" + raw = (host or "").strip().lower() + if raw.startswith("[") and raw.endswith("]"): + raw = raw[1:-1] + if raw in LOOPBACK_HOSTS: + return True + if raw.startswith("127."): + return True + return False + + +def warn_if_insecure_bind(host: str, settings: Any) -> None: + """Log a loud warning when auth is off and `host` is not loopback. + + If `settings.auth_refuse_insecure_bind` is True, raise InsecureBindError + instead of (or after) warning so the process refuses to start. + """ + if getattr(settings, "auth_enabled", False): + return + if is_loopback_host(host): + return + + import structlog + + log = structlog.get_logger() + log.warning( + "auth_disabled_on_non_loopback_bind", + host=host, + hint="Set AUTH_ENABLED=true before exposing the API on a network interface", + ) + if getattr(settings, "auth_refuse_insecure_bind", False): + raise InsecureBindError( + f"Refusing to bind {host!r} with AUTH_ENABLED=false " + "(set AUTH_ENABLED=true or AUTH_REFUSE_INSECURE_BIND=false)" + ) diff --git a/src/api/auth/keys.py b/src/api/auth/keys.py new file mode 100644 index 0000000..e1627ba --- /dev/null +++ b/src/api/auth/keys.py @@ -0,0 +1,213 @@ +"""Argon2-hashed API keys. Plaintext is never stored or logged.""" + +from __future__ import annotations + +import secrets +import uuid +from dataclasses import dataclass +from datetime import datetime +from typing import Sequence + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError + +KEY_TOKEN_PREFIX = "rlk_" +KEY_PREFIX_LEN = 12 +VALID_ROLES = frozenset({"ingest", "query", "admin"}) +DEFAULT_SCOPE = "default" + +_hasher = PasswordHasher() + + +@dataclass(frozen=True) +class ApiKeyInfo: + """Public key metadata — never includes the hash or plaintext.""" + + id: uuid.UUID + name: str | None + key_prefix: str + role: str + scope: str + revoked_at: datetime | None + created_at: datetime | None + + +@dataclass(frozen=True) +class ApiKeyRecord: + """Lookup result used by middleware. Hash is for verify only; do not log it.""" + + id: uuid.UUID + name: str | None + key_prefix: str + key_hash: str + role: str + scope: str + revoked_at: datetime | None + created_at: datetime | None = None + + +def generate_api_key() -> str: + """Return a new plaintext key (`rlk_` + url-safe secret). Show it once.""" + return KEY_TOKEN_PREFIX + secrets.token_urlsafe(32) + + +def key_prefix(token: str) -> str: + """Indexed lookup prefix — first 12 characters, not the full secret.""" + return token[:KEY_PREFIX_LEN] + + +def hash_api_key(token: str) -> str: + """Argon2 hash of the plaintext key.""" + return _hasher.hash(token) + + +def verify_api_key(token: str, key_hash: str) -> bool: + """Return True if `token` matches `key_hash`. Never logs either value.""" + try: + return _hasher.verify(key_hash, token) + except (VerifyMismatchError, InvalidHashError, TypeError, ValueError): + return False + + +def find_verified_key(token: str, candidates: Sequence[ApiKeyRecord]) -> ApiKeyRecord | None: + """Pick the non-revoked candidate whose prefix matches and whose hash verifies.""" + prefix = key_prefix(token) + for candidate in candidates: + if candidate.revoked_at is not None: + continue + if candidate.key_prefix != prefix: + continue + if verify_api_key(token, candidate.key_hash): + return candidate + return None + + +def lookup_api_key(token: str) -> ApiKeyRecord | None: + """Load non-revoked rows by prefix and argon2-verify. Patchable in unit tests.""" + from sqlalchemy import select + + from src.db.models import ApiKey + from src.db.session import get_db + + prefix = key_prefix(token) + with get_db() as db: + rows = db.execute( + select(ApiKey).where( + ApiKey.key_prefix == prefix, + ApiKey.revoked_at.is_(None), + ) + ).scalars().all() + records = [ + ApiKeyRecord( + id=row.id, + name=row.name, + key_prefix=row.key_prefix, + key_hash=row.key_hash, + role=row.role, + scope=row.scope, + revoked_at=row.revoked_at, + created_at=row.created_at, + ) + for row in rows + ] + return find_verified_key(token, records) + + +def create_api_key( + *, + role: str, + scope: str = DEFAULT_SCOPE, + name: str | None = None, +) -> tuple[str, ApiKeyInfo]: + """Persist a hashed key and return (plaintext, metadata). Caller must not log plaintext.""" + if role not in VALID_ROLES: + raise ValueError(f"role must be one of {sorted(VALID_ROLES)}") + if not scope: + raise ValueError("scope must be a non-empty string") + + plaintext = generate_api_key() + record = _persist_key( + plaintext=plaintext, + role=role, + scope=scope, + name=name, + ) + return plaintext, record + + +def _persist_key( + *, + plaintext: str, + role: str, + scope: str, + name: str | None, +) -> ApiKeyInfo: + from src.db.models import ApiKey + from src.db.session import get_db + + row = ApiKey( + name=name, + key_prefix=key_prefix(plaintext), + key_hash=hash_api_key(plaintext), + role=role, + scope=scope, + ) + with get_db() as db: + db.add(row) + db.flush() + return ApiKeyInfo( + id=row.id, + name=row.name, + key_prefix=row.key_prefix, + role=row.role, + scope=row.scope, + revoked_at=row.revoked_at, + created_at=row.created_at, + ) + + +def list_api_keys() -> list[ApiKeyInfo]: + """Return all keys (including revoked) without hashes.""" + from sqlalchemy import select + + from src.db.models import ApiKey + from src.db.session import get_db + + with get_db() as db: + rows = db.execute(select(ApiKey).order_by(ApiKey.created_at.desc())).scalars().all() + return [ + ApiKeyInfo( + id=row.id, + name=row.name, + key_prefix=row.key_prefix, + role=row.role, + scope=row.scope, + revoked_at=row.revoked_at, + created_at=row.created_at, + ) + for row in rows + ] + + +def revoke_api_key(key_id: uuid.UUID) -> ApiKeyInfo | None: + """Set revoked_at. Returns the updated row, or None if the id is unknown.""" + from datetime import timezone + + from src.db.models import ApiKey + from src.db.session import get_db + + with get_db() as db: + row = db.get(ApiKey, key_id) + if row is None: + return None + if row.revoked_at is None: + row.revoked_at = datetime.now(timezone.utc) + return ApiKeyInfo( + id=row.id, + name=row.name, + key_prefix=row.key_prefix, + role=row.role, + scope=row.scope, + revoked_at=row.revoked_at, + created_at=row.created_at, + ) diff --git a/src/api/auth/middleware.py b/src/api/auth/middleware.py new file mode 100644 index 0000000..c812d67 --- /dev/null +++ b/src/api/auth/middleware.py @@ -0,0 +1,136 @@ +"""ASGI/HTTP middleware: Bearer API keys or optional OIDC JWT. + +Settings are read per request so tests can patch `src.config.get_settings` +on the existing TestClient. Bearer tokens are never written to logs. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +ERROR_UNAUTHORIZED = "AUTH_UNAUTHORIZED" +ERROR_FORBIDDEN = "AUTH_FORBIDDEN" + + +@dataclass(frozen=True) +class AuthPrincipal: + role: str + scope: str + auth_method: str + key_id: str | None = None + subject: str | None = None + + +def _auth_error(status_code: int, error_code: str, message: str) -> JSONResponse: + headers = {} + if status_code == 401: + headers["WWW-Authenticate"] = "Bearer" + return JSONResponse( + status_code=status_code, + content={"error_code": error_code, "message": message}, + headers=headers, + ) + + +def _extract_bearer(request: Request) -> str | None: + header = request.headers.get("authorization") + if header is None: + return None + scheme, _, remainder = header.partition(" ") + if scheme.lower() != "bearer" or not remainder.strip(): + return None + return remainder.strip() + + +def _authenticate_token(token: str, settings: Any) -> AuthPrincipal | None: + from src.api.auth.oidc import looks_like_jwt, validate_oidc_token + + mode = getattr(settings, "auth_mode", "api_key") + if looks_like_jwt(token): + if mode not in ("oidc", "both"): + return None + principal = validate_oidc_token(token, settings) + if principal is None: + return None + return AuthPrincipal( + role=principal.role, + scope=principal.scope, + auth_method="oidc", + subject=principal.subject, + ) + + if mode not in ("api_key", "both"): + return None + from src.api.auth.keys import lookup_api_key + + record = lookup_api_key(token) + if record is None: + return None + return AuthPrincipal( + role=record.role, + scope=record.scope, + auth_method="api_key", + key_id=str(record.id), + ) + + +def authorize_request(request: Request) -> JSONResponse | None: + """Return an error response, or None if the request may proceed. + + Reads settings on every call (not at import) so AUTH_ENABLED can be patched. + """ + from src.api.auth.roles import required_roles + from src.config import get_settings + + allowed = required_roles(request.method, request.url.path) + if allowed is None: + return None + + settings = get_settings() + if not settings.auth_enabled: + return None + + token = _extract_bearer(request) + if token is None: + return _auth_error( + 401, + ERROR_UNAUTHORIZED, + "Missing or invalid Authorization bearer token", + ) + + principal = _authenticate_token(token, settings) + if principal is None: + return _auth_error( + 401, + ERROR_UNAUTHORIZED, + "Invalid or revoked credentials", + ) + + if principal.role not in allowed: + return _auth_error( + 403, + ERROR_FORBIDDEN, + "Insufficient role for this endpoint", + ) + + request.state.auth_principal = principal + request.state.auth_role = principal.role + request.state.auth_scope = principal.scope + return None + + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + error = authorize_request(request) + if error is not None: + return error + return await call_next(request) diff --git a/src/api/auth/oidc.py b/src/api/auth/oidc.py new file mode 100644 index 0000000..f8f2156 --- /dev/null +++ b/src/api/auth/oidc.py @@ -0,0 +1,162 @@ +"""Optional OIDC JWT validation via JWKS. No live IdP in unit tests — mock this.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx +import jwt +from jwt import PyJWK + +from src.api.auth.keys import VALID_ROLES + +ALLOWED_ALGORITHMS: tuple[str, ...] = ( + "RS256", + "RS384", + "RS512", + "ES256", + "ES384", + "ES512", +) + +_jwks_cache: dict[str, dict[str, Any]] = {} + + +@dataclass(frozen=True) +class OidcPrincipal: + role: str + scope: str + subject: str | None + auth_method: str = "oidc" + + +def looks_like_jwt(token: str) -> bool: + """True when the bearer has three dotted segments (typical JWT).""" + parts = token.split(".") + return len(parts) == 3 and all(parts) + + +def clear_jwks_cache() -> None: + _jwks_cache.clear() + + +def role_from_claims(claims: dict[str, Any]) -> str: + """Map `raglogs_role` or `roles` to ingest|query|admin; default `query`.""" + role = claims.get("raglogs_role") + if isinstance(role, str) and role in VALID_ROLES: + return role + roles = claims.get("roles") + if isinstance(roles, str) and roles in VALID_ROLES: + return roles + if isinstance(roles, list): + for item in roles: + if item in VALID_ROLES: + return str(item) + return "query" + + +def _http_get_json(url: str) -> dict[str, Any]: + with httpx.Client(timeout=10.0) as client: + response = client.get(url) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("OIDC response was not a JSON object") + return payload + + +def _looks_like_jwks_url(url: str) -> bool: + lowered = url.lower() + return lowered.endswith("jwks.json") or lowered.endswith("/jwks") or "/jwks" in lowered + + +def resolve_jwks_uri(settings: Any) -> str: + """JWKS URL: explicit setting, else OIDC discovery, else `{issuer}/.well-known/jwks.json`.""" + explicit = (getattr(settings, "oidc_jwks_url", "") or "").strip() + if explicit: + if _looks_like_jwks_url(explicit): + return explicit + try: + document = _http_get_json(explicit) + jwks_uri = document.get("jwks_uri") + if isinstance(jwks_uri, str) and jwks_uri: + return jwks_uri + except (httpx.HTTPError, ValueError): + return explicit + return explicit + + issuer = (getattr(settings, "oidc_issuer", "") or "").rstrip("/") + if not issuer: + return "" + discovery = f"{issuer}/.well-known/openid-configuration" + try: + document = _http_get_json(discovery) + jwks_uri = document.get("jwks_uri") + if isinstance(jwks_uri, str) and jwks_uri: + return jwks_uri + except (httpx.HTTPError, ValueError): + pass + return f"{issuer}/.well-known/jwks.json" + + +def get_jwks(settings: Any) -> dict[str, Any]: + """Fetch JWKS, cached in process memory by URL.""" + uri = resolve_jwks_uri(settings) + if not uri: + raise ValueError("OIDC JWKS URL is not configured") + cached = _jwks_cache.get(uri) + if cached is not None: + return cached + document = _http_get_json(uri) + _jwks_cache[uri] = document + return document + + +def _key_from_jwks(jwks: dict[str, Any], kid: str | None) -> Any: + keys = jwks.get("keys") + if not isinstance(keys, list) or not keys: + raise ValueError("JWKS document has no keys") + selected: dict[str, Any] | None = None + if kid: + for item in keys: + if isinstance(item, dict) and item.get("kid") == kid: + selected = item + break + elif len(keys) == 1 and isinstance(keys[0], dict): + selected = keys[0] + if selected is None: + raise ValueError("No matching JWK for token kid") + return PyJWK.from_dict(selected).key + + +def validate_oidc_token(token: str, settings: Any) -> OidcPrincipal | None: + """Validate signature, iss, exp, and optional aud. Returns None on failure.""" + issuer = (getattr(settings, "oidc_issuer", "") or "").strip() + if not issuer: + return None + try: + header = jwt.get_unverified_header(token) + jwks = get_jwks(settings) + key = _key_from_jwks(jwks, header.get("kid")) + audience = (getattr(settings, "oidc_audience", "") or "").strip() + decode_kwargs: dict[str, Any] = { + "algorithms": list(ALLOWED_ALGORITHMS), + "issuer": issuer, + "options": { + "verify_aud": bool(audience), + "require": ["exp", "iss"], + }, + } + if audience: + decode_kwargs["audience"] = audience + claims = jwt.decode(token, key, **decode_kwargs) + except (jwt.InvalidTokenError, ValueError, httpx.HTTPError, KeyError, TypeError): + return None + if not isinstance(claims, dict): + return None + return OidcPrincipal( + role=role_from_claims(claims), + scope="default", + subject=str(claims["sub"]) if claims.get("sub") is not None else None, + ) diff --git a/src/api/auth/roles.py b/src/api/auth/roles.py new file mode 100644 index 0000000..bc0f22b --- /dev/null +++ b/src/api/auth/roles.py @@ -0,0 +1,80 @@ +"""Route → allowed role mapping. + +`admin` is included in every non-exempt set. Scope is stored on keys for later +G8 isolation; this module does not filter log queries by scope. + +Exempt (no auth): GET/HEAD `/health`, `/metrics` (prefix match). `/docs` is +not exempt — OpenAPI can leak adapter-oriented config. + +Role map +-------- +POST /ingestions ingest, admin +GET /ingestions* query, admin (job listing/status is query-ish) +POST /query/* query, admin +GET /config admin +GET / query, admin (web UI) +GET /static* query, admin +GET /docs, /redoc, /openapi.json query, admin +POST /admin/* admin +unmatched paths admin +""" + +from __future__ import annotations + +INGEST_ROLES: frozenset[str] = frozenset({"ingest", "admin"}) +QUERY_ROLES: frozenset[str] = frozenset({"query", "admin"}) +ADMIN_ROLES: frozenset[str] = frozenset({"admin"}) + +EXEMPT_PREFIXES: tuple[str, ...] = ("/health", "/metrics") + + +def _normalize_path(path: str) -> str: + if not path: + return "/" + if len(path) > 1 and path.endswith("/"): + return path.rstrip("/") + return path + + +def is_exempt_path(path: str) -> bool: + """True for `/health` and `/metrics` (and nested paths under those).""" + normalized = _normalize_path(path) + for prefix in EXEMPT_PREFIXES: + if normalized == prefix or normalized.startswith(prefix + "/"): + return True + return False + + +def required_roles(method: str, path: str) -> frozenset[str] | None: + """Return allowed roles for this request, or None if the path is exempt.""" + if is_exempt_path(path): + return None + + normalized = _normalize_path(path) + verb = method.upper() + + if normalized == "/ingestions" or normalized.startswith("/ingestions/"): + if verb == "POST": + return INGEST_ROLES + return QUERY_ROLES + + if normalized == "/query" or normalized.startswith("/query/"): + return QUERY_ROLES + + if normalized == "/config" or normalized.startswith("/config/"): + return ADMIN_ROLES + + if normalized == "/" or normalized.startswith("/static"): + return QUERY_ROLES + + if ( + normalized in {"/docs", "/redoc", "/openapi.json"} + or normalized.startswith("/docs/") + or normalized.startswith("/redoc/") + ): + return QUERY_ROLES + + if normalized == "/admin" or normalized.startswith("/admin/"): + return ADMIN_ROLES + + return ADMIN_ROLES diff --git a/src/cli/commands/keys.py b/src/cli/commands/keys.py new file mode 100644 index 0000000..61e64c1 --- /dev/null +++ b/src/cli/commands/keys.py @@ -0,0 +1,119 @@ +"""Admin CLI for minting and revoking HTTP API keys. + +Plaintext keys are printed once and never written to logs. +""" + +from __future__ import annotations + +import uuid +from typing import Optional + +import typer +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + +from src.api.auth.keys import VALID_ROLES + +app = typer.Typer( + name="keys", + help="Mint, list, and revoke HTTP API keys. Plaintext is shown only at create time.", + no_args_is_help=True, +) +console = Console() + + +@app.command("create") +def create_cmd( + role: str = typer.Option("query", "--role", help="ingest | query | admin"), + scope: str = typer.Option("default", "--scope", help="Key scope (stored for later isolation; unused today)"), + name: Optional[str] = typer.Option(None, "--name", help="Optional label for this key"), +) -> None: + """Create an API key and print the plaintext secret once.""" + from src.api.auth.keys import create_api_key + + role = role.strip().lower() + if role not in VALID_ROLES: + console.print(f"[red]Error:[/red] role must be one of {sorted(VALID_ROLES)}") + raise typer.Exit(1) + + try: + plaintext, info = create_api_key(role=role, scope=scope, name=name) + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + console.print( + Panel( + plaintext, + title="[bold cyan]API key — copy it now; it will not be shown again[/bold cyan]", + expand=False, + ) + ) + console.print( + f"[dim]id={info.id} prefix={info.key_prefix} role={info.role} scope={info.scope}[/dim]" + ) + + +@app.command("list") +def list_cmd() -> None: + """List keys (id, prefix, role, scope, created, revoked). Hashes are never shown.""" + from src.api.auth.keys import list_api_keys + + try: + keys = list_api_keys() + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + table = Table(title="API keys", show_header=True, header_style="bold cyan") + table.add_column("id") + table.add_column("prefix") + table.add_column("role") + table.add_column("scope") + table.add_column("name") + table.add_column("created") + table.add_column("revoked") + + for key in keys: + created = key.created_at.isoformat() if key.created_at else "" + revoked = key.revoked_at.isoformat() if key.revoked_at else "" + table.add_row( + str(key.id), + key.key_prefix, + key.role, + key.scope, + key.name or "", + created, + revoked, + ) + + console.print(table) + if not keys: + console.print("[dim]No API keys. Create one with: raglogs keys create --role query[/dim]") + + +@app.command("revoke") +def revoke_cmd( + key_id: str = typer.Argument(..., help="UUID of the key to revoke"), +) -> None: + """Revoke a key so it can no longer authenticate.""" + from src.api.auth.keys import revoke_api_key + + try: + parsed = uuid.UUID(key_id) + except ValueError: + console.print("[red]Error:[/red] key id must be a UUID") + raise typer.Exit(1) + + try: + info = revoke_api_key(parsed) + except Exception as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) + + if info is None: + console.print(f"[red]Error:[/red] no API key with id {key_id}") + raise typer.Exit(1) + + console.print(f"[green]Revoked[/green] {info.id} (prefix {info.key_prefix})") diff --git a/src/cli/main.py b/src/cli/main.py index ae3b31c..2ba3628 100644 --- a/src/cli/main.py +++ b/src/cli/main.py @@ -16,6 +16,7 @@ def _build_app() -> typer.Typer: from src.cli.commands.worker import worker_cmd from src.cli.commands.timeline import timeline_cmd from src.cli.commands.compare import compare_cmd + from src.cli.commands.keys import app as keys_app _app = typer.Typer( name="raglogs", @@ -35,6 +36,7 @@ def _build_app() -> typer.Typer: _app.command("timeline")(timeline_cmd) _app.command("compare")(compare_cmd) _app.add_typer(ask_app, name="ask") + _app.add_typer(keys_app, name="keys") return _app diff --git a/src/config/settings.py b/src/config/settings.py index 144b22d..c84c8de 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -35,6 +35,18 @@ class Settings(BaseSettings): max_evidence_items: int = 8 max_clusters_for_explain: int = 10 + # HTTP API authentication (G2). Default off so local demo and existing + # TestClient tests stay unauthenticated. Production/Docker should set + # AUTH_ENABLED=true. Scope on keys is stored for later G8 isolation; + # queries are not filtered by scope yet. + auth_enabled: bool = False + auth_mode: Literal["api_key", "oidc", "both"] = "api_key" + oidc_issuer: str = "" + oidc_audience: str = "" + oidc_jwks_url: str = "" + api_bind_host: str = "127.0.0.1" + auth_refuse_insecure_bind: bool = False + # Worker worker_poll_interval: int = 2 # seconds between idle polls diff --git a/src/db/__init__.py b/src/db/__init__.py index 5d52e09..3ba8dc7 100644 --- a/src/db/__init__.py +++ b/src/db/__init__.py @@ -1,4 +1,5 @@ from .models import ( + ApiKey, AppConfig, Base, Cluster, @@ -22,6 +23,7 @@ "Cluster", "ClusterMember", "Explanation", + "ApiKey", "AppConfig", "get_db", "get_engine", diff --git a/src/db/models.py b/src/db/models.py index 982e7e8..876674d 100644 --- a/src/db/models.py +++ b/src/db/models.py @@ -176,6 +176,21 @@ class AppConfig(Base): value_json: Mapped[Any] = mapped_column(JSONB, nullable=True) +class ApiKey(Base): + """Hashed HTTP API key. Plaintext is shown once at mint time and never stored.""" + + __tablename__ = "api_keys" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + key_prefix: Mapped[str] = mapped_column(String(32), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(Text, nullable=False) + role: Mapped[str] = mapped_column(String(20), nullable=False) + scope: Mapped[str] = mapped_column(String(255), nullable=False, default="default") + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + class WorkerJob(Base): __tablename__ = "worker_jobs" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 0000000..88075ba --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,441 @@ +"""Unit tests for HTTP API authentication (no database).""" +from __future__ import annotations + +import time +import uuid +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi.testclient import TestClient +from jwt.utils import to_base64url_uint + +from src.api.app import app +from src.api.auth.bind_guard import InsecureBindError, is_loopback_host, warn_if_insecure_bind +from src.api.auth.keys import ( + ApiKeyRecord, + create_api_key, + find_verified_key, + generate_api_key, + hash_api_key, + key_prefix, + verify_api_key, +) +from src.api.auth.middleware import AuthPrincipal +from src.api.auth.oidc import ( + clear_jwks_cache, + looks_like_jwt, + role_from_claims, + validate_oidc_token, +) +from src.api.auth.roles import required_roles +from src.config.settings import Settings + +client = TestClient(app, raise_server_exceptions=False) + +FAKE_JWT = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature" + + +def _auth_settings(**kwargs: object) -> Settings: + values: dict[str, object] = {"auth_enabled": True, "auth_mode": "api_key"} + values.update(kwargs) + return Settings(_env_file=None, **values) + + +def _key(role: str, *, revoked: bool = False) -> MagicMock: + record = MagicMock() + record.id = uuid.uuid4() + record.name = "test" + record.key_prefix = "rlk_testhash" + record.role = role + record.scope = "default" + record.revoked_at = datetime.now(timezone.utc) if revoked else None + return record + + +# ── Key hashing / prefix ────────────────────────────────────────────────────── + +class TestApiKeyCrypto: + def test_generate_has_rlk_prefix(self) -> None: + token = generate_api_key() + assert token.startswith("rlk_") + assert len(token) > 12 + + def test_hash_does_not_equal_plaintext_and_verifies(self) -> None: + token = generate_api_key() + hashed = hash_api_key(token) + assert hashed != token + assert "argon2" in hashed.lower() or hashed.startswith("$argon2") + assert verify_api_key(token, hashed) is True + assert verify_api_key("rlk_not-the-key", hashed) is False + + def test_prefix_is_first_12_chars(self) -> None: + token = generate_api_key() + assert key_prefix(token) == token[:12] + assert len(key_prefix(token)) == 12 + + def test_prefix_lookup_helper_verifies_matching_candidate(self) -> None: + token = generate_api_key() + other = generate_api_key() + matching = ApiKeyRecord( + id=uuid.uuid4(), + name="ok", + key_prefix=key_prefix(token), + key_hash=hash_api_key(token), + role="query", + scope="default", + revoked_at=None, + ) + distractor = ApiKeyRecord( + id=uuid.uuid4(), + name="other", + key_prefix=key_prefix(other), + key_hash=hash_api_key(other), + role="admin", + scope="default", + revoked_at=None, + ) + assert find_verified_key(token, [distractor, matching]) is matching + + def test_prefix_lookup_skips_revoked(self) -> None: + token = generate_api_key() + revoked = ApiKeyRecord( + id=uuid.uuid4(), + name="revoked", + key_prefix=key_prefix(token), + key_hash=hash_api_key(token), + role="admin", + scope="default", + revoked_at=datetime.now(timezone.utc), + ) + assert find_verified_key(token, [revoked]) is None + + def test_create_rejects_unknown_role_before_db(self) -> None: + with pytest.raises(ValueError, match="role"): + create_api_key(role="superuser") + + +# ── Role map ────────────────────────────────────────────────────────────────── + +class TestRequiredRoles: + def test_health_and_metrics_exempt(self) -> None: + assert required_roles("GET", "/health") is None + assert required_roles("GET", "/health/") is None + assert required_roles("GET", "/metrics") is None + assert required_roles("GET", "/metrics/prom") is None + + def test_ingest_post_vs_get(self) -> None: + assert required_roles("POST", "/ingestions") == frozenset({"ingest", "admin"}) + assert required_roles("GET", "/ingestions") == frozenset({"query", "admin"}) + assert required_roles("GET", "/ingestions/latest") == frozenset({"query", "admin"}) + + def test_query_and_config_and_ui(self) -> None: + assert required_roles("POST", "/query/explain") == frozenset({"query", "admin"}) + assert required_roles("GET", "/config") == frozenset({"admin"}) + assert required_roles("GET", "/") == frozenset({"query", "admin"}) + assert required_roles("GET", "/static/js/app.js") == frozenset({"query", "admin"}) + + +# ── Bind-host guard ─────────────────────────────────────────────────────────── + +class TestBindGuard: + def test_loopback_hosts(self) -> None: + assert is_loopback_host("127.0.0.1") is True + assert is_loopback_host("localhost") is True + assert is_loopback_host("::1") is True + assert is_loopback_host("[::1]") is True + assert is_loopback_host("127.0.0.2") is True + + def test_non_loopback_hosts(self) -> None: + assert is_loopback_host("0.0.0.0") is False + assert is_loopback_host("::") is False + assert is_loopback_host("*") is False + assert is_loopback_host("") is False + assert is_loopback_host("8.8.8.8") is False + + def test_loopback_does_not_warn_when_auth_off(self) -> None: + settings = Settings(auth_enabled=False, auth_refuse_insecure_bind=True, _env_file=None) + with patch("structlog.get_logger") as mock_log: + warn_if_insecure_bind("127.0.0.1", settings) + mock_log.return_value.warning.assert_not_called() + + def test_non_loopback_warns_when_auth_off(self) -> None: + settings = Settings(auth_enabled=False, auth_refuse_insecure_bind=False, _env_file=None) + with patch("structlog.get_logger") as mock_log: + warn_if_insecure_bind("0.0.0.0", settings) + mock_log.return_value.warning.assert_called_once() + + def test_refuse_flag_raises_on_non_loopback(self) -> None: + settings = Settings(auth_enabled=False, auth_refuse_insecure_bind=True, _env_file=None) + with pytest.raises(InsecureBindError): + warn_if_insecure_bind("0.0.0.0", settings) + + def test_auth_enabled_skips_guard(self) -> None: + settings = Settings(auth_enabled=True, auth_refuse_insecure_bind=True, _env_file=None) + with patch("structlog.get_logger") as mock_log: + warn_if_insecure_bind("0.0.0.0", settings) + mock_log.return_value.warning.assert_not_called() + + +# ── Middleware / TestClient ─────────────────────────────────────────────────── + +class TestAuthMiddleware: + def test_health_ok_without_authorization_when_auth_enabled(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.db.session.check_connection", return_value=False): + resp = client.get("/health") + assert resp.status_code == 200 + + def test_metrics_path_exempt_when_auth_enabled(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()): + resp = client.get("/metrics") + assert resp.status_code not in {401, 403} + + def test_explain_401_without_header_when_auth_enabled(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()): + resp = client.post("/query/explain", json={"since": "1h"}) + assert resp.status_code == 401 + body = resp.json() + assert body["error_code"] == "AUTH_UNAUTHORIZED" + assert "message" in body + + def test_invalid_bearer_401_typed_body(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=None): + resp = client.post( + "/query/explain", + json={"since": "1h"}, + headers={"Authorization": "Bearer rlk_not-a-real-key"}, + ) + assert resp.status_code == 401 + assert resp.json()["error_code"] == "AUTH_UNAUTHORIZED" + + def test_revoked_key_is_401(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=None): + resp = client.post( + "/query/explain", + json={"since": "1h"}, + headers={"Authorization": "Bearer rlk_revokedkeyxx"}, + ) + assert resp.status_code == 401 + assert resp.json()["error_code"] == "AUTH_UNAUTHORIZED" + + def test_ingest_role_cannot_post_explain(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("ingest")): + resp = client.post( + "/query/explain", + json={"since": "1h"}, + headers={"Authorization": "Bearer rlk_ingestrole1"}, + ) + assert resp.status_code == 403 + assert resp.json()["error_code"] == "AUTH_FORBIDDEN" + + def test_query_role_can_post_explain(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("query")): + resp = client.post( + "/query/explain", + json={}, + headers={"Authorization": "Bearer rlk_queryrolexx"}, + ) + assert resp.status_code == 400 + assert resp.status_code != 403 + + def test_query_role_cannot_post_ingestions(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("query")): + resp = client.post( + "/ingestions", + json={"paths": ["/logs"]}, + headers={"Authorization": "Bearer rlk_queryrolexx"}, + ) + assert resp.status_code == 403 + assert resp.json()["error_code"] == "AUTH_FORBIDDEN" + + def test_ingest_role_can_post_ingestions(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("ingest")): + resp = client.post( + "/ingestions", + json={}, + headers={"Authorization": "Bearer rlk_ingestrole1"}, + ) + assert resp.status_code == 422 + assert resp.status_code != 403 + + def test_admin_allowed_on_explain_and_ingestions(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("admin")): + explain = client.post( + "/query/explain", + json={}, + headers={"Authorization": "Bearer rlk_adminrolexx"}, + ) + ingest = client.post( + "/ingestions", + json={}, + headers={"Authorization": "Bearer rlk_adminrolexx"}, + ) + config = client.get( + "/config", + headers={"Authorization": "Bearer rlk_adminrolexx"}, + ) + assert explain.status_code == 400 + assert ingest.status_code == 422 + assert config.status_code == 200 + + def test_query_role_cannot_get_config(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()), \ + patch("src.api.auth.keys.lookup_api_key", return_value=_key("query")): + resp = client.get("/config", headers={"Authorization": "Bearer rlk_queryrolexx"}) + assert resp.status_code == 403 + + def test_docs_not_exempt(self) -> None: + with patch("src.config.get_settings", return_value=_auth_settings()): + resp = client.get("/docs") + assert resp.status_code == 401 + + def test_default_auth_off_allows_query_without_header(self) -> None: + settings = Settings(_env_file=None) + assert settings.auth_enabled is False + with patch("src.config.get_settings", return_value=settings): + resp = client.post("/query/explain", json={}) + assert resp.status_code == 400 + + +class TestOidcMiddleware: + def test_mocked_jwt_accepted_when_mode_oidc(self) -> None: + principal = AuthPrincipal(role="query", scope="default", auth_method="oidc", subject="user-1") + settings = _auth_settings(auth_mode="oidc", oidc_issuer="https://idp.example") + with patch("src.config.get_settings", return_value=settings), \ + patch("src.api.auth.oidc.validate_oidc_token", return_value=principal): + resp = client.post( + "/query/explain", + json={}, + headers={"Authorization": f"Bearer {FAKE_JWT}"}, + ) + assert resp.status_code == 400 + + def test_jwt_rejected_when_mode_api_key(self) -> None: + settings = _auth_settings(auth_mode="api_key") + with patch("src.config.get_settings", return_value=settings), \ + patch("src.api.auth.oidc.validate_oidc_token") as mock_oidc: + resp = client.post( + "/query/explain", + json={}, + headers={"Authorization": f"Bearer {FAKE_JWT}"}, + ) + mock_oidc.assert_not_called() + assert resp.status_code == 401 + assert resp.json()["error_code"] == "AUTH_UNAUTHORIZED" + + +# ── OIDC helpers (mocked JWKS, no live IdP) ─────────────────────────────────── + +def _rsa_jwk_and_token( + *, + issuer: str, + audience: str | None = None, + role_claim: dict | None = None, + kid: str = "test-key", +) -> tuple[dict, str, rsa.RSAPrivateKey]: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + numbers = private_key.public_key().public_numbers() + + def _b64(value: int) -> str: + encoded = to_base64url_uint(value) + return encoded.decode("ascii") if isinstance(encoded, bytes) else encoded + + jwk = { + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": _b64(numbers.n), + "e": _b64(numbers.e), + } + claims: dict = { + "sub": "user-1", + "iss": issuer, + "exp": int(time.time()) + 3600, + "iat": int(time.time()), + } + if audience: + claims["aud"] = audience + if role_claim: + claims.update(role_claim) + token = jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": kid}) + return jwk, token, private_key + + +class TestOidcValidation: + def setup_method(self) -> None: + clear_jwks_cache() + + def teardown_method(self) -> None: + clear_jwks_cache() + + def test_looks_like_jwt(self) -> None: + assert looks_like_jwt(FAKE_JWT) is True + assert looks_like_jwt("rlk_abc") is False + assert looks_like_jwt("only.two") is False + + def test_role_from_claims(self) -> None: + assert role_from_claims({"raglogs_role": "admin"}) == "admin" + assert role_from_claims({"roles": ["ingest", "query"]}) == "ingest" + assert role_from_claims({}) == "query" + + def test_valid_jwt_with_mocked_jwks(self) -> None: + issuer = "https://idp.example" + jwk, token, _ = _rsa_jwk_and_token(issuer=issuer, role_claim={"raglogs_role": "admin"}) + settings = SimpleNamespace( + oidc_issuer=issuer, + oidc_audience="", + oidc_jwks_url="https://idp.example/.well-known/jwks.json", + ) + with patch("src.api.auth.oidc._http_get_json", return_value={"keys": [jwk]}): + principal = validate_oidc_token(token, settings) + assert principal is not None + assert principal.role == "admin" + assert principal.subject == "user-1" + + def test_expired_jwt_rejected(self) -> None: + issuer = "https://idp.example" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + numbers = private_key.public_key().public_numbers() + encoded_n = to_base64url_uint(numbers.n) + encoded_e = to_base64url_uint(numbers.e) + jwk = { + "kty": "RSA", + "kid": "test-key", + "n": encoded_n.decode("ascii") if isinstance(encoded_n, bytes) else encoded_n, + "e": encoded_e.decode("ascii") if isinstance(encoded_e, bytes) else encoded_e, + } + token = jwt.encode( + {"sub": "user-1", "iss": issuer, "exp": int(time.time()) - 10}, + private_key, + algorithm="RS256", + headers={"kid": "test-key"}, + ) + settings = SimpleNamespace( + oidc_issuer=issuer, + oidc_audience="", + oidc_jwks_url="https://idp.example/.well-known/jwks.json", + ) + with patch("src.api.auth.oidc._http_get_json", return_value={"keys": [jwk]}): + assert validate_oidc_token(token, settings) is None + + def test_wrong_issuer_rejected(self) -> None: + jwk, token, _ = _rsa_jwk_and_token(issuer="https://idp.example") + settings = SimpleNamespace( + oidc_issuer="https://other.example", + oidc_audience="", + oidc_jwks_url="https://idp.example/.well-known/jwks.json", + ) + with patch("src.api.auth.oidc._http_get_json", return_value={"keys": [jwk]}): + assert validate_oidc_token(token, settings) is None diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 2757fe5..b96cdec 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -52,3 +52,32 @@ def test_ignores_legacy_raglogs_prefix(monkeypatch): assert settings.llm_provider == "disabled" assert settings.db_url == "postgresql+psycopg://postgres:postgres@localhost:5432/raglogs" + + +def test_auth_settings_defaults_disabled(): + settings = Settings(_env_file=None) + assert settings.auth_enabled is False + assert settings.auth_mode == "api_key" + assert settings.oidc_issuer == "" + assert settings.api_bind_host == "127.0.0.1" + assert settings.auth_refuse_insecure_bind is False + + +def test_auth_settings_from_env(monkeypatch): + monkeypatch.setenv("AUTH_ENABLED", "true") + monkeypatch.setenv("AUTH_MODE", "both") + monkeypatch.setenv("OIDC_ISSUER", "https://idp.example") + monkeypatch.setenv("OIDC_AUDIENCE", "raglogs") + monkeypatch.setenv("OIDC_JWKS_URL", "https://idp.example/jwks.json") + monkeypatch.setenv("API_BIND_HOST", "0.0.0.0") + monkeypatch.setenv("AUTH_REFUSE_INSECURE_BIND", "true") + + settings = Settings(_env_file=None) + + assert settings.auth_enabled is True + assert settings.auth_mode == "both" + assert settings.oidc_issuer == "https://idp.example" + assert settings.oidc_audience == "raglogs" + assert settings.oidc_jwks_url == "https://idp.example/jwks.json" + assert settings.api_bind_host == "0.0.0.0" + assert settings.auth_refuse_insecure_bind is True