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
7 changes: 7 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ generic 401 envelope; an authenticated context without the route capability
returns the generic 403 envelope. Static API credentials retain all three
capabilities.

The browser UI uses the same normalized capability contract: `web_access` is
required for entry and LiveView reconnects; `query` gates query and table pages
and query operations; `catalog_manage` gates dataset/table creation and
retention changes; and `platform_operate` gates cluster kill, restart, and
drain. Fleet reads remain available with `web_access`; UI controls are only a
convenience and server-side event checks remain authoritative.

```sh
curl http://127.0.0.1:4000/healthz

Expand Down
26 changes: 16 additions & 10 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,11 @@ job status outlives the result TTL even though the rows do not.

Three layers, each fail-closed:

- **The front door** requires the static Bearer key on every `/v1` route; a node
holding the `:api` role with no key refuses to boot.
- **The front door** requires an explicit authentication mode. Static API mode
requires the configured Bearer key; OIDC API mode strictly verifies signed
access tokens through bounded discovery/JWKS caches, then checks the closed
route capability matrix before body parsing. A node holding the `:api` role
with incomplete settings refuses to boot.
- **Internal HTTP** (`HotServer`'s manifest and segment routes) requires the
internal secret; readers attach it — `HotClient` as a header, the DuckDB
engines via an http `CREATE SECRET`. A single node generates one at boot; a
Expand All @@ -766,14 +769,17 @@ Three layers, each fail-closed:
Single-tenant remains the model — auth says *whether* you may query, not *which
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. 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 explicit `SMOLQUERY_AUTH_MODE` and
credential.
trust boundary. The web UI also requires an explicit mode. Static mode uses
its own basic-auth credential (`SMOLQUERY_WEB_USERNAME` /
`SMOLQUERY_WEB_PASSWORD`), separate from the API key. OIDC mode uses
Authorization Code with state, nonce, and S256 PKCE, strict ID-token validation,
and a bounded encrypted cookie containing only normalized identity and
capabilities. LiveView mount, reconnect, events, messages, and async results
recheck the required capability; expired OIDC sessions and rotated static
credentials revoke connected sockets as well as later requests. Both modes
normalize credentials into provider-neutral principals and contexts, never raw
secrets or provider tokens. The UI binds loopback by default, and a node with
the `:web` role refuses to boot when the selected mode's settings are incomplete.

## See also

Expand Down
13 changes: 9 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ method. Returned ID tokens
are strictly verified for issuer, browser audience, subject, timestamps, nonce,
and applicable hash claims. Browser sessions contain only normalized identity,
capabilities, and expiry; they never contain raw tokens or claims. Every web
OIDC session currently requires `web_access`, `query`, `ingest`,
`catalog_manage`, and `platform_operate` until the web capability layer lands.
A CSRF-protected `POST /auth/logout` always drops the local session without
depending on the provider. Production builds mark the encrypted cookie Secure;
OIDC session requires `web_access`. Query and table pages additionally require
`query`; dataset/table creation and retention changes require
`catalog_manage`; cluster kill, restart, and drain require `platform_operate`.
The UI hides controls without those capabilities, but every event handler checks
again before parsing input or causing side effects. Cluster fleet reads remain
available to any `web_access` principal.
A CSRF-protected `POST /auth/logout` drops the local session and all pending
login transaction cookies without depending on the provider. Production builds mark the identity
and transaction cookies Secure;
development and test builds retain explicit HTTP compatibility.

The API verifier requires a non-empty `kid`, a locally allowlisted asymmetric
Expand Down
22 changes: 8 additions & 14 deletions lib/smolquery_web/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ defmodule SmolqueryWeb.Auth do

Static mode retains Basic authentication and its marker rotation semantics.
OIDC mode reconstructs a minimal encrypted session identity on every request
and mount. The temporary T-234 gate requires every currently powerful web
capability, including `platform_operate`, until the web policy layer lands.
and mount. Browser entry and every socket require only `:web_access`; route
and operation-specific capabilities are enforced by `SmolqueryWeb.Authorization`.
"""

@behaviour Plug
Expand All @@ -16,11 +16,10 @@ defmodule SmolqueryWeb.Auth do
alias Smolquery.Auth
alias Smolquery.Auth.Context
alias Smolquery.Auth.Policy
alias SmolqueryWeb.{Runtime, Session}
alias SmolqueryWeb.{Authorization, Runtime, Session}

@realm "smolquery"
@marker :authenticated
@required_web_capabilities [:web_access, :query, :ingest, :catalog_manage, :platform_operate]

@impl Plug
def init(opts), do: Keyword.get(opts, :name, SmolqueryWeb)
Expand Down Expand Up @@ -60,7 +59,6 @@ defmodule SmolqueryWeb.Auth do
{:ok, context} ->
with true <- context.principal.issuer == runtime.oidc.issuer,
:ok <- Policy.authorize(context, :web_access),
true <- coarse_web_access?(context),
{:ok, identity} <- Session.encode(context) do
conn
|> configure_session(renew: true)
Expand Down Expand Up @@ -98,7 +96,8 @@ defmodule SmolqueryWeb.Auth do
with marker when is_binary(marker) <- session[Atom.to_string(@marker)],
true <- marker == runtime.session_marker,
%Context{} = context <- runtime.context do
{:cont, Auth.assign_context(socket, context)}
socket = socket |> Auth.assign_context(context) |> Authorization.attach_static(marker)
{:cont, socket}
else
_ -> {:halt, LiveView.redirect(socket, to: "/")}
end
Expand All @@ -107,19 +106,14 @@ defmodule SmolqueryWeb.Auth do
defp mount_oidc(runtime, session, socket) do
with {:ok, context} <- Session.decode(session[Session.key()]),
true <- context.principal.issuer == runtime.oidc.issuer,
:ok <- Policy.authorize(context, :web_access),
true <- coarse_web_access?(context) do
{:cont, Auth.assign_context(socket, context)}
:ok <- Policy.authorize(context, :web_access) do
socket = Auth.assign_context(socket, context)
{:cont, Authorization.attach(socket, :web_access)}
else
_ -> {:halt, LiveView.redirect(socket, to: "/auth/login")}
end
end

def coarse_web_access?(%Context{} = context),
do: Enum.all?(@required_web_capabilities, &Context.granted?(context, &1))

def coarse_web_access?(_), do: false

defp put_identity(conn, context) do
case Session.encode(context) do
{:ok, identity} ->
Expand Down
177 changes: 177 additions & 0 deletions lib/smolquery_web/authorization.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
defmodule SmolqueryWeb.Authorization do
@moduledoc """
Closed capability authorization for LiveView mounts and socket lifecycle.

Capabilities are compile-time route or handler requirements. Authorization
reads only the normalized context assigned by `Smolquery.Auth` and delegates
expiry and capability semantics to `Smolquery.Auth.Policy`.
"""

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

@capabilities [:web_access, :query, :catalog_manage, :platform_operate]
@max_expiry_timer_ms 86_400_000
@static_check_ms 1_000

@doc "Validates a compile-time LiveView capability requirement."
def init(capability) when capability in @capabilities, do: capability

def init(capability),
do: raise(ArgumentError, "unsupported web capability: #{inspect(capability)}")

@doc "Requires a capability during LiveView initial mount or reconnect."
def on_mount(capability, _params, _session, socket) do
case authorize(socket, capability) do
:ok -> {:cont, attach(socket, capability)}
{:error, :unauthenticated} -> {:halt, redirect(socket, :unauthenticated)}
{:error, :forbidden} -> {:halt, redirect(socket, :forbidden)}
end
end

@doc "Authorizes a normalized socket context for a closed capability."
def authorize(socket, capability) when capability in @capabilities do
case Auth.fetch_context(socket) do
{:ok, context} -> authorize_context(context, capability)
:error -> {:error, :unauthenticated}
end
end

def authorize(_socket, _capability), do: {:error, :forbidden}

@doc "Authorizes a normalized context before a service or catalog action."
def authorize_context(context, capability) when capability in @capabilities,
do: Policy.authorize(context, capability)

def authorize_context(_context, _capability), do: {:error, :forbidden}

@doc "Attaches event, info, and async guards and schedules OIDC expiry."
def attach(socket, capability) when capability in @capabilities do
if lifecycle_socket?(socket) do
socket =
socket
|> LiveView.attach_hook({__MODULE__, capability, :event}, :handle_event, fn _event,
_params,
socket ->
lifecycle_result(socket, capability)
end)
|> LiveView.attach_hook({__MODULE__, capability, :info}, :handle_info, fn message,
socket ->
info_lifecycle_result(message, socket, capability)
end)
|> LiveView.attach_hook({__MODULE__, capability, :async}, :handle_async, fn _key,
_result,
socket ->
lifecycle_result(socket, capability)
end)

if capability == :web_access, do: schedule_expiry(socket), else: socket
else
socket
end
end

def attach(socket, _capability), do: socket

@doc "Revokes connected static sockets when the credential-derived marker rotates."
def attach_static(socket, marker) when is_binary(marker) do
if lifecycle_socket?(socket) do
socket
|> LiveView.attach_hook({__MODULE__, :static, :event}, :handle_event, fn _event,
_params,
socket ->
static_lifecycle_result(socket, marker)
end)
|> LiveView.attach_hook({__MODULE__, :static, :info}, :handle_info, fn message, socket ->
static_info_result(message, socket, marker)
end)
|> LiveView.attach_hook({__MODULE__, :static, :async}, :handle_async, fn _key,
_result,
socket ->
static_lifecycle_result(socket, marker)
end)
|> schedule_static_check()
else
socket
end
end

def attach_static(socket, _marker), do: socket

@doc "Returns a generic socket denial without exposing claims or roles."
def deny_event(socket, capability) do
case authorize(socket, capability) do
:ok -> :ok
{:error, reason} -> {:error, reason, redirect(socket, reason)}
end
end

@doc "Guards a direct event handler before parsing input or doing work."
def event(socket, capability), do: deny_event(socket, capability)

defp lifecycle_result(socket, capability) do
case authorize(socket, capability) do
:ok -> {:cont, socket}
{:error, reason} -> {:halt, redirect(socket, reason)}
end
end

defp info_lifecycle_result(:smolquery_auth_expiry, socket, :web_access) do
case authorize(socket, :web_access) do
:ok -> {:halt, schedule_expiry(socket)}
{:error, reason} -> {:halt, redirect(socket, reason)}
end
end

defp info_lifecycle_result(_message, socket, capability),
do: lifecycle_result(socket, capability)

defp schedule_expiry(socket) do
case Auth.fetch_context(socket) do
{:ok, %{expires_at: expires_at}} when is_integer(expires_at) ->
remaining = max(expires_at * 1_000 - System.system_time(:millisecond), 0)
Process.send_after(self(), :smolquery_auth_expiry, min(remaining, @max_expiry_timer_ms))
socket

_ ->
socket
end
end

defp static_info_result(:smolquery_static_auth_check, socket, marker) do
case static_marker_current?(marker) do
true -> {:halt, schedule_static_check(socket)}
false -> {:halt, LiveView.redirect(socket, to: "/")}
end
end

defp static_info_result(_message, socket, marker),
do: static_lifecycle_result(socket, marker)

defp static_lifecycle_result(socket, marker) do
if static_marker_current?(marker),
do: {:cont, socket},
else: {:halt, LiveView.redirect(socket, to: "/")}
end

defp static_marker_current?(marker),
do: match?({:ok, %{auth_mode: :static, session_marker: ^marker}}, Runtime.fetch(SmolqueryWeb))

defp schedule_static_check(socket) do
Process.send_after(self(), :smolquery_static_auth_check, @static_check_ms)
socket
end

defp redirect(socket, :unauthenticated),
do: LiveView.redirect(socket, to: "/auth/login")

defp redirect(socket, :forbidden),
do: LiveView.redirect(socket, to: "/cluster")

defp lifecycle_socket?(%LiveView.Socket{private: private}),
do: is_map(private) and Map.has_key?(private, :lifecycle)

defp lifecycle_socket?(_socket), do: false
end
10 changes: 7 additions & 3 deletions lib/smolquery_web/components/layouts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ defmodule SmolqueryWeb.Layouts do

"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :can_query, :boolean, default: true

attr :current_scope, :map,
default: nil,
Expand All @@ -39,17 +40,20 @@ defmodule SmolqueryWeb.Layouts do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8 border-b border-base-300 bg-base-100">
<div class="flex-1">
<a href={~p"/tables"} class="flex-1 flex w-fit items-center gap-2">
<a
href={if(@can_query, do: ~p"/tables", else: ~p"/cluster")}
class="flex-1 flex w-fit items-center gap-2"
>
<.icon name="hero-bolt-solid" class="size-5 text-primary" />
<span class="text-lg font-semibold tracking-tight">smolquery</span>
</a>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<li :if={@can_query}>
<a href={~p"/tables"} class="btn btn-ghost">Tables</a>
</li>
<li>
<li :if={@can_query}>
<a href={~p"/query"} class="btn btn-ghost">Query</a>
</li>
<li>
Expand Down
28 changes: 15 additions & 13 deletions lib/smolquery_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ defmodule SmolqueryWeb.AuthController do

use SmolqueryWeb, :controller

alias Smolquery.Auth.Policy
alias SmolqueryWeb.{Auth, OIDC, Runtime}

def login(conn, _params) do
Expand All @@ -24,19 +25,8 @@ defmodule SmolqueryWeb.AuthController do

def callback(conn, %{"state" => state, "code" => code}) do
case OIDC.take_transaction(conn, state) do
{:ok, transaction, conn} ->
with {:ok, runtime} <- oidc_runtime(),
{:ok, context} <-
OIDC.authenticate(runtime, transaction, code, oidc_options(runtime)),
true <- Auth.coarse_web_access?(context),
{:ok, conn} <- Auth.assign_identity(conn, context) do
conn |> no_store() |> redirect(to: "/")
else
_failure -> error(conn)
end

{:error, :invalid_transaction, conn} ->
error(conn)
{:ok, transaction, conn} -> complete_callback(conn, transaction, code)
{:error, :invalid_transaction, conn} -> error(conn)
end
end

Expand All @@ -60,6 +50,18 @@ defmodule SmolqueryWeb.AuthController do
|> redirect(to: "/")
end

defp complete_callback(conn, transaction, code) do
with {:ok, runtime} <- oidc_runtime(),
{:ok, context} <- OIDC.authenticate(runtime, transaction, code, oidc_options(runtime)),
:ok <- Policy.authorize(context, :web_access),
{:ok, conn} <- Auth.assign_identity(conn, context) do
target = if Policy.authorize(context, :query) == :ok, do: "/", else: "/cluster"
conn |> no_store() |> redirect(to: target)
else
_failure -> error(conn)
end
end

defp oidc_options(%{oidc_http_client: client}) when is_function(client, 2),
do: [http_client: client]

Expand Down
Loading
Loading