Skip to content
Draft
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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ docker build -t smolquery .
docker run -d --name smolquery \
-p 4000:4000 -p 4002:4002 \
-v smolquery-data:/data \
-e SMOLQUERY_AUTH_MODE=static \
-e SMOLQUERY_API_KEY=change-me \
-e SMOLQUERY_WEB_IP=0.0.0.0 \
-e SMOLQUERY_WEB_USERNAME=smolquery \
Expand Down Expand Up @@ -80,9 +81,10 @@ release publishes a multi-architecture image to GHCR and attaches both a
manifest. `release-manifest.yaml` is not a standalone production deployment:
integrate it with, and provide, the `smolquery-env` Secret, Postgres catalog and
discovery, and the sealed-store dependencies before deploying. The Secret must
also hold `SMOLQUERY_WEB_USERNAME`, `SMOLQUERY_WEB_PASSWORD`, and
`SMOLQUERY_SECRET_KEY_BASE` for any pod whose roles include `web`; a pod
without them refuses to boot.
also hold `SMOLQUERY_AUTH_MODE=static` for any pod whose roles include `api` or
`web`. Pods with the `web` role additionally need
`SMOLQUERY_WEB_USERNAME`, `SMOLQUERY_WEB_PASSWORD`, and
`SMOLQUERY_SECRET_KEY_BASE`; a pod without the required settings refuses to boot.

## Features

Expand Down
3 changes: 2 additions & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import Config

config :logger, level: :debug

config :smolquery, SmolqueryApi, api_key: "smolquery-dev"
config :smolquery, SmolqueryApi, auth_mode: :static, api_key: "smolquery-dev"

config :smolquery, SmolqueryWeb,
auth_mode: :static,
username: "smolquery",
password: "smolquery"

Expand Down
11 changes: 11 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ if roles = System.get_env("SMOLQUERY_ROLES") do
config :smolquery, roles: Smolquery.Roles.parse!(roles)
end

if auth_mode = System.get_env("SMOLQUERY_AUTH_MODE") do
mode =
Smolquery.RuntimeConfig.enum!("SMOLQUERY_AUTH_MODE", auth_mode, [
{"static", :static},
{"oidc", :oidc}
])

config :smolquery, SmolqueryApi, auth_mode: mode
config :smolquery, SmolqueryWeb, auth_mode: mode
end

if api_key = System.get_env("SMOLQUERY_API_KEY") do
config :smolquery, SmolqueryApi, api_key: api_key
end
Expand Down
7 changes: 6 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ config :smolquery, roles: []

config :smolquery, SmolqueryApi.Endpoint, http: [ip: {127, 0, 0, 1}, port: 0], server: false

config :smolquery, SmolqueryWeb, username: "smolquery", password: "smolquery"
config :smolquery, SmolqueryApi, auth_mode: :static

config :smolquery, SmolqueryWeb,
auth_mode: :static,
username: "smolquery",
password: "smolquery"

config :smolquery, SmolqueryWeb.ClusterLive.Index, pod_actions: false

Expand Down
1 change: 1 addition & 0 deletions deploy/overlays/kind-symmetric/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ secretGenerator:
namespace: smolquery
literals:
- CATALOG_DATABASE_URL=postgres://postgres:postgres@postgres/smolquery
- SMOLQUERY_AUTH_MODE=static
- SMOLQUERY_API_KEY=kind-only-api-key
- SMOLQUERY_INTERNAL_SECRET=kind-only-internal-secret
- SMOLQUERY_WEB_IP=0.0.0.0
Expand Down
1 change: 1 addition & 0 deletions deploy/overlays/kind/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ secretGenerator:
namespace: smolquery
literals:
- CATALOG_DATABASE_URL=postgres://postgres:postgres@postgres/smolquery
- SMOLQUERY_AUTH_MODE=static
- SMOLQUERY_API_KEY=kind-only-api-key
- SMOLQUERY_INTERNAL_SECRET=kind-only-internal-secret
- SMOLQUERY_WEB_IP=0.0.0.0
Expand Down
10 changes: 6 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
`SmolqueryApi` is the front door — a Phoenix endpoint served by Bandit (the
same stack as the web UI's `SmolqueryWeb`), started by the `:api` role, routing
only to service client modules and the catalog (the same boundary rule the
services hold each other to). Every `/v1` route requires the static
Bearer key (`SMOLQUERY_API_KEY`); a node with the `:api` role and no key
configured fails the boot rather than serve an open API. `/healthz` is the one
unauthenticated route.
services hold each other to). Set `SMOLQUERY_AUTH_MODE=static` while OIDC
runtime support is unavailable. Every `/v1` route then requires the static
Bearer key (`SMOLQUERY_API_KEY`); a node with the `:api` role and no mode or key
configured fails the boot rather than serve an open API. Successful static
requests carry a normalized service principal and context. `/healthz` is the
one unauthenticated route.

```sh
curl http://127.0.0.1:4000/healthz
Expand Down
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -768,9 +768,12 @@ tables*. Inter-node traffic can be switched to mutual TLS (`GEN_RPC_TLS`,
`DIST_TLS`); verification is chain-only against the cluster CA, so the CA is the
trust boundary. The web UI requires its own basic-auth credential
(`SMOLQUERY_WEB_USERNAME` / `SMOLQUERY_WEB_PASSWORD`). The credential is not
the API key, so a UI rotation does not break an ingest client. A rotation also
the API key, so a UI rotation does not break an ingest client. Static mode
normalizes both credentials into provider-neutral principals and contexts; the
credential itself never enters the identity or session. A rotation also
revokes existing UI sessions. The UI binds loopback by default. A node with
the `:web` role refuses to boot without the credential.
the `:web` role refuses to boot without the explicit `SMOLQUERY_AUTH_MODE` and
credential.

## See also

Expand Down
5 changes: 3 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ error.
| variable | effect (default) |
|---|---|
| `SMOLQUERY_ROLES` | which service subtrees start — `all`, or a comma-separated subset of `api,ingest,buffer,storage,query,web` (all). An unknown name fails the boot |
| `SMOLQUERY_API_KEY` | the Bearer key every `/v1` route requires; a node with the `:api` role and no key refuses to boot |
| `SMOLQUERY_AUTH_MODE` | authentication mode (`static` or `oidc`); required on `:api` and `:web` nodes, with `oidc` rejected until OIDC runtime support exists |
| `SMOLQUERY_API_KEY` | the Bearer key every `/v1` route requires in static mode; a node with the `:api` role and no key refuses to boot |
| `SMOLQUERY_API_IP` / `SMOLQUERY_API_PORT` | API bind (`0.0.0.0` in the prod image / `4000`) |
| `SMOLQUERY_INSERT_MAX_IN_FLIGHT_BYTES` | the most ingest-body bytes an API node admits at once (T-245). `SmolqueryApi.Admission` counts `POST .../insert` and `.../load` bodies by declared `content-length` before any body is read, and refuses past the limit with a 429 and `retry-after: 1` — the refusal costs a header read, never a body, so a client burst sheds load instead of OOMKilling the pod. Unset, the limit is **a quarter of the container's cgroup memory limit**, floored at one NDJSON body (`8000000`); without a cgroup limit, `268435456`. An idle counter always admits one request — the route's own body cap decides what is too large |
| `SMOLQUERY_WEB_IP` / `SMOLQUERY_WEB_PORT` | web UI bind — expose the listener only on purpose (`127.0.0.1` / `4002`) |
| `SMOLQUERY_WEB_USERNAME` / `SMOLQUERY_WEB_PASSWORD` | the basic-auth credential every UI route requires; a node with the `:web` role and no credential refuses to boot |
| `SMOLQUERY_WEB_USERNAME` / `SMOLQUERY_WEB_PASSWORD` | the basic-auth credential every UI route requires in static mode; a node with the `:web` role and no credential refuses to boot |
| `SMOLQUERY_WEB_HOST` | the public host of the UI; also the default `check_origin` source (`localhost`) |
| `SMOLQUERY_WEB_CHECK_ORIGIN` | `false` to accept any websocket origin, or a comma-separated origin list — each entry needs a scheme or a leading `//`, e.g. `https://ui.example.com` (the `SMOLQUERY_WEB_HOST` value) |
| `SMOLQUERY_SECRET_KEY_BASE` | signs the web UI session that guards the LiveView socket; **required** on a node with the `:web` role, at least 64 bytes (`mix phx.gen.secret`), same value on every `:web` node |
Expand Down
13 changes: 10 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,16 @@ the sealed-store dependencies before you deploy it.

From 0.7.1, the `smolquery-env` Secret must hold `SMOLQUERY_WEB_USERNAME`,
`SMOLQUERY_WEB_PASSWORD`, and `SMOLQUERY_SECRET_KEY_BASE` for any pod whose
roles include `web`. A web pod without them **refuses to boot**, and that
boot failure stops the pod's other roles too. Push the secrets before you
roll the image.
roles include `web`. A web pod without them **refuses to boot**, and that boot
failure stops the pod's other roles too. Push the secrets before you roll the
image.

### Explicit authentication mode

The current release requires `SMOLQUERY_AUTH_MODE=static` on every pod whose
roles include `api` or `web`. The mode is explicit and fail-closed; a missing or
unsupported mode refuses to boot. Pods with the `web` role still additionally
need the web credentials and session secret described above.

## Catalog format upgrades

Expand Down
73 changes: 73 additions & 0 deletions lib/smolquery/auth/static.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
defmodule Smolquery.Auth.Static do
@moduledoc """
Trusted adapters for the static authentication mode.

Static credentials are verified by their transport-specific adapters. This
module only supplies the normalized identities and capabilities after that
verification succeeds. Source keys are constructor-owned labels, never
credential material.
"""

alias Smolquery.Auth.Context
alias Smolquery.Auth.Principal

@api_source "api-service"
@web_source "web-operator"
@api_capabilities [:query, :ingest, :catalog_manage]
@web_capabilities [:web_access, :query, :catalog_manage, :platform_operate]

@doc """
Resolves the explicit authentication mode from application configuration.

OIDC is rejected until its runtime support is available; it never falls
through to static authentication.
"""
@spec mode!(keyword(), String.t(), atom()) :: :static
def mode!(config, service, role) do
case Keyword.get(config, :auth_mode) do
:static -> :static
:oidc -> unsupported!(service, role)
nil -> missing!(service, role)
mode -> invalid!(mode, service, role)
end
end

@doc """
Builds the stable service context used by an authenticated API key.
"""
@spec api_context() :: Context.t()
def api_context do
{:ok, principal} = Principal.local(@api_source, :api_key, :service)
{:ok, context} = Context.single_tenant(principal, @api_capabilities)
context
end

@doc """
Builds the stable operator context used by authenticated web Basic auth.
"""
@spec web_context() :: Context.t()
def web_context do
{:ok, principal} = Principal.local(@web_source, :basic, :user)
{:ok, context} = Context.single_tenant(principal, @web_capabilities)
context
end

defp missing!(service, role) do
raise ArgumentError,
"#{service} refuses to boot without an authentication mode: set " <>
"SMOLQUERY_AUTH_MODE to static (or oidc when supported) on every node " <>
"running the #{inspect(role)} role"
end

defp invalid!(mode, service, role) do
raise ArgumentError,
"SMOLQUERY_AUTH_MODE has invalid value #{inspect(mode)} for #{service}; " <>
"expected static or oidc on the #{inspect(role)} role"
end

defp unsupported!(service, role) do
raise ArgumentError,
"#{service} cannot start in oidc authentication mode yet; " <>
"SMOLQUERY_AUTH_MODE=oidc is not supported for the #{inspect(role)} role"
end
end
24 changes: 14 additions & 10 deletions lib/smolquery_api/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule SmolqueryApi.Auth do

import Plug.Conn

alias Smolquery.Auth
alias Smolquery.InternalSecret
alias SmolqueryApi.Errors
alias SmolqueryApi.Runtime
Expand All @@ -41,12 +42,14 @@ defmodule SmolqueryApi.Auth do
end

def call(conn, _opts) do
if authenticated?(conn) do
conn
else
conn
|> Errors.send_error(401, "UNAUTHENTICATED", "missing or invalid API key")
|> halt()
case authenticated_context(conn) do
{:ok, context} ->
Auth.assign_context(conn, context)

:error ->
conn
|> Errors.send_error(401, "UNAUTHENTICATED", "missing or invalid API key")
|> halt()
end
end

Expand All @@ -57,12 +60,13 @@ defmodule SmolqueryApi.Auth do
end
end

defp authenticated?(conn) do
defp authenticated_context(conn) do
with ["Bearer " <> key] <- get_req_header(conn, "authorization"),
{:ok, runtime} <- Runtime.fetch(conn.private.smolquery_api) do
Plug.Crypto.secure_compare(key, runtime.api_key)
{:ok, runtime} <- Runtime.fetch(conn.private.smolquery_api),
true <- Plug.Crypto.secure_compare(key, runtime.api_key) do
{:ok, runtime.context}
else
_unauthenticated -> false
_unauthenticated -> :error
end
end
end
24 changes: 18 additions & 6 deletions lib/smolquery_api/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ defmodule SmolqueryApi.Runtime do
## Configuration

config :smolquery, SmolqueryApi,
auth_mode: :static,
api_key: "..."

`api_key` is the one static Bearer key every `/v1` route requires (PL-8 D5).
There is no default and no fallback: a node holding the `:api` role with no
key configured refuses to boot rather than serve an open API. Multi-key and
rotation are explicitly later.
`auth_mode: :static` explicitly selects the static Bearer-key adapter. There
is no default or fallback: a node holding the `:api` role with a missing or
unsupported mode, or without a key, refuses to boot rather than serve an open
API. Multi-key and rotation are explicitly later.

The listener (ip, port) is Phoenix's own concern and lives under
`config :smolquery, SmolqueryApi.Endpoint` — the same split
Expand All @@ -29,13 +30,17 @@ defmodule SmolqueryApi.Runtime do
`%Smolquery.Catalog{}` outright, it reads through that and starts nothing.
"""

alias Smolquery.Auth.Context
alias Smolquery.Auth.Static
alias Smolquery.Catalog

@enforce_keys [:name, :api_key, :catalog]
@enforce_keys [:name, :auth_mode, :api_key, :context, :catalog]
@derive {Inspect, except: [:api_key]}
defstruct [
:name,
:auth_mode,
:api_key,
:context,
:catalog,
:catalog_opts,
ingest_name: Smolquery.IngestService,
Expand All @@ -46,7 +51,9 @@ defmodule SmolqueryApi.Runtime do

@type t :: %__MODULE__{
name: atom(),
auth_mode: :static,
api_key: String.t(),
context: Context.t(),
catalog: Catalog.t(),
catalog_opts: keyword() | nil,
ingest_name: atom(),
Expand All @@ -59,7 +66,8 @@ defmodule SmolqueryApi.Runtime do
Resolves configuration into a runtime.

Application config for `SmolqueryApi` supplies the defaults; `opts`
overrides them. Raises if no non-empty `api_key` is present in either.
overrides them. Raises if the authentication mode is missing or unsupported,
or if no non-empty `api_key` is present in either.
"""
@spec new(keyword()) :: t()
def new(opts \\ []) do
Expand All @@ -69,8 +77,11 @@ defmodule SmolqueryApi.Runtime do
{catalog, catalog_opts} =
Catalog.DuckLake.resolve(Keyword.get(config, :catalog), catalog_engine(name))

auth_mode = Static.mode!(config, "the API", :api)

%__MODULE__{
name: name,
auth_mode: auth_mode,
api_key:
Smolquery.Runtime.fetch_required!(config, :api_key,
service: "the API",
Expand All @@ -79,6 +90,7 @@ defmodule SmolqueryApi.Runtime do
scope: SmolqueryApi,
role: :api
),
context: Static.api_context(),
catalog: catalog,
catalog_opts: catalog_opts
}
Expand Down
16 changes: 10 additions & 6 deletions lib/smolquery_web/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ defmodule SmolqueryWeb.Auth do
import Plug.Conn

alias Phoenix.LiveView
alias Smolquery.Auth
alias SmolqueryWeb.Runtime

@realm "smolquery"
Expand Down Expand Up @@ -64,11 +65,14 @@ defmodule SmolqueryWeb.Auth do
defp mark(%Plug.Conn{halted: true} = conn, _runtime), do: conn

defp mark(conn, runtime) do
if get_session(conn, @marker) == runtime.session_marker do
conn
else
put_session(conn, @marker, runtime.session_marker)
end
conn =
if get_session(conn, @marker) == runtime.session_marker do
conn
else
put_session(conn, @marker, runtime.session_marker)
end

Auth.assign_context(conn, runtime.context)
end

defp challenge(conn) do
Expand All @@ -90,7 +94,7 @@ defmodule SmolqueryWeb.Auth do
def on_mount(:require_authenticated, _params, session, socket) do
with {:ok, runtime} <- Runtime.fetch(SmolqueryWeb),
marker when marker == runtime.session_marker <- session[Atom.to_string(@marker)] do
{:cont, socket}
{:cont, Auth.assign_context(socket, runtime.context)}
else
_unauthenticated -> {:halt, LiveView.redirect(socket, to: "/")}
end
Expand Down
Loading
Loading