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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ ORM entities are in `src/gateway/models/entities.py` (User, APIKey, Budget, Usag
- Docker local build/run: `docker compose up --build`
- CI Docker smoke check is implemented in `scripts/docker_liveness_check.sh`.
## Web Dashboard (`web/`)
- The standalone admin dashboard is a React + HeroUI v3 SPA in `web/` (Vite, Tailwind v4, TanStack Query). It browses the model catalogue, sets model pricing, manages aliases, adds/edits provider API keys, and toggles runtime settings; it calls the management API (`/v1/models`, `/v1/pricing`, `/v1/aliases`, `/v1/provider-credentials`, `/v1/settings`) with the master key, entered on a sign-in screen and kept only in the browser tab's session storage.
- The standalone admin dashboard is a React + HeroUI v3 SPA in `web/` (Vite, Tailwind v4, TanStack Query). It browses the model catalogue, sets model pricing, manages aliases, adds/edits provider API keys, and toggles runtime settings; it calls the management API (`/v1/models`, `/v1/pricing`, `/v1/aliases`, `/v1/provider-credentials`, `/v1/settings`) as the master key: entered once on a sign-in screen, the key is exchanged (`POST /v1/auth/session`) for an HttpOnly session cookie with a TTL (`dashboard_session_ttl_hours`, table `dashboard_sessions`), so the raw key is never persisted in the browser and a sign-in survives tab closes and restarts. The auth dependencies in `api/deps.py` accept the cookie only when a request carries no header credentials; master-key rotation revokes every session and re-mints the caller's.
- Runtime provider management: the Providers page (`web/src/pages/ProvidersPage.tsx`) manages the `provider_credentials` table via `/v1/provider-credentials` (CRUD + `POST /{instance}/test`). Backend: `services/provider_store_service.py` overlays stored providers onto `config.providers` (per-config baseline on `config._provider_baseline`, refreshed by a TTL refresher wired in the lifespan like the alias refresher, standalone-only); `services/secret_box.py` encrypts keys with `OTARI_SECRET_KEY` (Fernet); `services/master_key_service.py` generates + prints a master key on first run when none is set (hash in `runtime_settings`). Keys are write-only over the API (responses expose only `last4`).
- Build: `npm --prefix web ci && npm --prefix web run build`. Output goes to `src/gateway/static/dashboard/` (set in `web/vite.config.ts`), which is committed so the wheel and Docker image ship the dashboard with no Node build stage. Rebuild and commit after any change under `web/src`.
- Checks: `npm --prefix web run typecheck`, `npm --prefix web test`. CI runs these and fails if the committed bundle is stale (`.github/workflows/otari-dashboard.yml`).
Expand Down
36 changes: 36 additions & 0 deletions alembic/versions/a9c1e3b5d7f9_add_dashboard_sessions_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Add dashboard_sessions table for cookie-based dashboard sign-in.

Revision ID: a9c1e3b5d7f9
Revises: 7d9e1f3a5b7c
Create Date: 2026-07-23 00:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a9c1e3b5d7f9"
down_revision: str | Sequence[str] | None = "7d9e1f3a5b7c"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"dashboard_sessions",
sa.Column("token_hash", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("token_hash"),
)
op.create_index("ix_dashboard_sessions_expires_at", "dashboard_sessions", ["expires_at"])


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_dashboard_sessions_expires_at", table_name="dashboard_sessions")
op.drop_table("dashboard_sessions")
88 changes: 87 additions & 1 deletion docs/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,21 @@
"title": "CreateKeyResponse",
"type": "object"
},
"CreateSessionRequest": {
"description": "Sign in to the dashboard by proving possession of the master key.",
"properties": {
"master_key": {
"description": "The gateway master key; verified once and never stored by the browser.",
"title": "Master Key",
"type": "string"
}
},
"required": [
"master_key"
],
"title": "CreateSessionRequest",
"type": "object"
},
"CreateStoredProviderRequest": {
"description": "Create a stored provider. ``api_key`` is write-only and requires OTARI_SECRET_KEY.",
"properties": {
Expand Down Expand Up @@ -3681,6 +3696,22 @@
"title": "RotateMasterKeyResponse",
"type": "object"
},
"SessionResponse": {
"description": "A freshly minted dashboard session (the token travels only in the cookie).",
"properties": {
"expires_at": {
"description": "When the session cookie stops being accepted.",
"format": "date-time",
"title": "Expires At",
"type": "string"
}
},
"required": [
"expires_at"
],
"title": "SessionResponse",
"type": "object"
},
"SetPricingRequest": {
"description": "Create a versioned per-model price, with optional cache and context tiers.",
"properties": {
Expand Down Expand Up @@ -5668,6 +5699,61 @@
]
}
},
"/v1/auth/session": {
"delete": {
"description": "Sign out: revoke the cookie's session server-side and expire the cookie.\n\nDeliberately unauthenticated and idempotent: it only ever revokes the\nsession named by the caller's own cookie, and the dashboard calls it on the\n401-bounce path where no valid credential exists anymore. Unlike the read\npath in ``deps.py`` it applies no Sec-Fetch-Site check: ``SameSite=Strict``\nalready keeps cross-site requests from carrying the cookie, and the worst a\nforged call could do is sign the operator out.",
"operationId": "delete_session_v1_auth_session_delete",
"responses": {
"204": {
"description": "Successful Response"
}
},
"summary": "Delete Session",
"tags": [
"auth"
]
},
"post": {
"description": "Verify the master key and set the HttpOnly session cookie.",
"operationId": "create_session_v1_auth_session_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateSessionRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionResponse"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"summary": "Create Session",
"tags": [
"auth"
]
}
},
"/v1/batches": {
"get": {
"description": "List batches for a provider.\n\nNon-master keys only see batches they own (plus legacy batches without an\nownership marker); the page is filtered after the provider call, so a page\nmay contain fewer than ``limit`` items.",
Expand Down Expand Up @@ -8489,7 +8575,7 @@
},
"/v1/settings/master-key/rotate": {
"post": {
"description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.",
"description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.\n\nEvery dashboard session is revoked with the rotation (a session only proves\npossession of the now-dead key); the caller's own session is re-minted under\nthe new key so the tab that performed the rotation stays signed in.",
"operationId": "rotate_master_key_v1_settings_master_key_rotate_post",
"responses": {
"200": {
Expand Down
59 changes: 58 additions & 1 deletion docs/public/otari.postman_collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,63 @@
],
"name": "audio"
},
{
"item": [
{
"name": "Create Session",
"request": {
"body": {
"mode": "raw",
"options": {
"raw": {
"language": "json"
}
},
"raw": "{\n \"master_key\": \"string\"\n}"
},
"description": "Verify the master key and set the HttpOnly session cookie.",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"method": "POST",
"url": {
"host": [
"{{baseUrl}}"
],
"path": [
"v1",
"auth",
"session"
],
"raw": "{{baseUrl}}/v1/auth/session"
}
}
},
{
"name": "Delete Session",
"request": {
"description": "Sign out: revoke the cookie's session server-side and expire the cookie.\n\nDeliberately unauthenticated and idempotent: it only ever revokes the\nsession named by the caller's own cookie, and the dashboard calls it on the\n401-bounce path where no valid credential exists anymore. Unlike the read\npath in ``deps.py`` it applies no Sec-Fetch-Site check: ``SameSite=Strict``\nalready keeps cross-site requests from carrying the cookie, and the worst a\nforged call could do is sign the operator out.",
"header": [],
"method": "DELETE",
"url": {
"host": [
"{{baseUrl}}"
],
"path": [
"v1",
"auth",
"session"
],
"raw": "{{baseUrl}}/v1/auth/session"
}
}
}
],
"name": "auth"
},
{
"item": [
{
Expand Down Expand Up @@ -1904,7 +1961,7 @@
{
"name": "Rotate Master Key",
"request": {
"description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.",
"description": "Regenerate the database-backed master key and invalidate the old one.\n\nOnly the first-run generated master key can be rotated here. When a master\nkey is supplied through config or ``OTARI_MASTER_KEY``, the dashboard cannot\ninvalidate it; the operator must change that value and restart instead.\n\nEvery dashboard session is revoked with the rotation (a session only proves\npossession of the now-dead key); the caller's own session is re-minted under\nthe new key so the tab that performed the rotation stays signed in.",
"header": [],
"method": "POST",
"url": {
Expand Down
70 changes: 66 additions & 4 deletions src/gateway/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from gateway.log_config import logger
from gateway.metrics import record_auth_failure
from gateway.models.entities import APIKey
from gateway.services.dashboard_session_service import SESSION_COOKIE_NAME, is_valid_dashboard_session
from gateway.services.file_store import FileStore
from gateway.services.log_writer import LogWriter
from gateway.services.master_key_service import hash_master_key, is_generated_master_key, load_master_key_hash
Expand Down Expand Up @@ -179,7 +180,55 @@ async def _bump_last_used_at(api_key_id: str, now: datetime) -> None:
logger.warning("Failed to update last_used_at for API key %s", api_key_id, exc_info=True)


async def _is_valid_master_key(token: str, config: GatewayConfig, db: AsyncSession) -> bool:
def _header_credentials_present(request: Request) -> bool:
"""Whether the request carries any of the header credential forms.

Header credentials always win over the dashboard session cookie: an API
client that sends a key gets exactly today's behavior (including failures),
and the cookie is only consulted for requests that present nothing else,
i.e. the browser-driven dashboard.
"""
return bool(
request.headers.get(API_KEY_HEADER)
or request.headers.get("Authorization")
or request.headers.get(X_API_KEY_HEADER)
)


# Sec-Fetch-Site values under which a cookie may authenticate a request:
# same-origin fetches (the dashboard itself) and non-site-initiated requests
# ("none", e.g. a direct navigation). "same-site" is deliberately excluded, so a
# sibling-subdomain page cannot ride the cookie.
_COOKIE_SAFE_FETCH_SITES = ("same-origin", "none")


async def _session_cookie_authenticates(request: Request, config: GatewayConfig, db: AsyncSession) -> bool:
"""Whether a valid dashboard session cookie grants master-key authority.

``SameSite=Strict`` on the cookie is the primary CSRF control; the
Sec-Fetch-Site check is belt-and-braces for clients that send the header.
Standalone-only: hybrid mode has no dashboard or management API.
"""
if config.is_hybrid_mode:
return False
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
return False
fetch_site = request.headers.get("Sec-Fetch-Site")
if fetch_site is not None and fetch_site not in _COOKIE_SAFE_FETCH_SITES:
record_auth_failure("cross_site_cookie")
return False
try:
return await is_valid_dashboard_session(db, token)
except SQLAlchemyError as exc:
record_auth_failure("db_error")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Authentication temporarily unavailable, please retry",
) from exc


async def is_valid_master_key(token: str, config: GatewayConfig, db: AsyncSession) -> bool:
"""Check if token matches the configured key or the current generated key."""
if config.master_key is not None and secrets.compare_digest(token, config.master_key):
return True
Expand Down Expand Up @@ -232,17 +281,23 @@ async def verify_master_key(
request: Request,
db: Annotated[AsyncSession, Depends(get_db)],
config: Annotated[GatewayConfig, Depends(get_config)],
) -> str:
"""Verify master key from Otari-Key header.
) -> str | None:
"""Verify master key from Otari-Key header or the dashboard session cookie.

Args:
request: FastAPI request object
config: Gateway configuration

Returns:
The raw master key when header-authenticated, or None when a dashboard
session cookie authenticated the request (the raw key is not available).

Raises:
HTTPException: If master key is not configured or invalid

"""
if not _header_credentials_present(request) and await _session_cookie_authenticates(request, config, db):
return None
token = _extract_bearer_token(request, config)

if config.master_key is None:
Expand Down Expand Up @@ -270,6 +325,9 @@ async def verify_api_key_or_master_key(
) -> tuple[APIKey | None, bool]:
"""Verify either API key or master key from Otari-Key header.

A valid dashboard session cookie also grants master-key authority, but only
when the request carries no header credentials at all.

Args:
request: FastAPI request object
db: Database session
Expand All @@ -282,9 +340,12 @@ async def verify_api_key_or_master_key(
HTTPException: If key is invalid, inactive, or expired

"""
if not _header_credentials_present(request) and await _session_cookie_authenticates(request, config, db):
return None, True

token = _extract_bearer_token(request, config)

if await _is_valid_master_key(token, config, db):
if await is_valid_master_key(token, config, db):
return None, True

api_key = await _verify_and_update_api_key(db, token)
Expand Down Expand Up @@ -322,6 +383,7 @@ def get_file_store(request: Request) -> FileStore:
"get_db_if_needed",
"get_file_store",
"get_log_writer",
"is_valid_master_key",
"verify_api_key",
"verify_api_key_or_master_key",
"verify_master_key",
Expand Down
2 changes: 2 additions & 0 deletions src/gateway/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from gateway.api.routes import (
aliases,
audio,
auth_session,
batches,
budgets,
chat,
Expand Down Expand Up @@ -39,6 +40,7 @@ def register_routers(app: FastAPI, config: GatewayConfig) -> None:
app.include_router(hybrid_mode.router)
return # Remaining routers (including batches) are standalone-mode only

app.include_router(auth_session.router)
app.include_router(embeddings.router)
app.include_router(images.router)
app.include_router(audio.router)
Expand Down
Loading
Loading