Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 76 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key-uuid>
```

| 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.
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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/
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions migrations/versions/0004_api_keys.py
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 9 additions & 1 deletion src/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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"])
Expand Down
1 change: 1 addition & 0 deletions src/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""HTTP API authentication (API keys, optional OIDC, bind-host guard)."""
49 changes: 49 additions & 0 deletions src/api/auth/bind_guard.py
Original file line number Diff line number Diff line change
@@ -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)"
)
Loading
Loading