From 6b434d3e6b083ca70c3934a047a1a2867d6176f2 Mon Sep 17 00:00:00 2001 From: David Whittington Date: Sat, 15 Aug 2026 15:36:22 +0000 Subject: [PATCH] feat(auth): normalize static credentials through auth context Normalize the existing API Bearer key and web Basic credential into stable, non-secret principals and single-tenant auth contexts. Attach those contexts to authenticated Plug connections and LiveView sockets while preserving constant-time checks, pre-parser denial, session rotation, and the current capability surface. Require an explicit SMOLQUERY_AUTH_MODE=static for API and web roles, reject unsupported OIDC mode without fallback, and update deployment configuration and documentation for the fail-closed boot contract. Tests: ../bin/x mix ci Tests: ../bin/test Tests: ../bin/x mix dialyzer T-230 --- README.md | 8 +- config/dev.exs | 3 +- config/runtime.exs | 11 +++ config/test.exs | 7 +- .../kind-symmetric/kustomization.yaml | 1 + deploy/overlays/kind/kustomization.yaml | 1 + docs/api.md | 10 ++- docs/architecture.md | 7 +- docs/configuration.md | 5 +- docs/deployment.md | 13 +++- lib/smolquery/auth/static.ex | 73 +++++++++++++++++++ lib/smolquery_api/auth.ex | 24 +++--- lib/smolquery_api/runtime.ex | 24 ++++-- lib/smolquery_web/auth.ex | 16 ++-- lib/smolquery_web/runtime.ex | 27 +++++-- test/smolquery/auth/static_test.exs | 48 ++++++++++++ test/smolquery_api/router_test.exs | 33 +++++++++ test/smolquery_api/runtime_test.exs | 18 ++++- test/smolquery_web/auth_test.exs | 43 ++++++++++- test/smolquery_web/runtime_test.exs | 17 +++++ 20 files changed, 339 insertions(+), 50 deletions(-) create mode 100644 lib/smolquery/auth/static.ex create mode 100644 test/smolquery/auth/static_test.exs diff --git a/README.md b/README.md index 6238a22c..187e4950 100644 --- a/README.md +++ b/README.md @@ -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 \ @@ -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 diff --git a/config/dev.exs b/config/dev.exs index d9f92802..a004f0b3 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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" diff --git a/config/runtime.exs b/config/runtime.exs index e5652dfe..a8f05d1a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/config/test.exs b/config/test.exs index b9667ac7..70343a84 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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 diff --git a/deploy/overlays/kind-symmetric/kustomization.yaml b/deploy/overlays/kind-symmetric/kustomization.yaml index 707f2dcc..6159c00f 100644 --- a/deploy/overlays/kind-symmetric/kustomization.yaml +++ b/deploy/overlays/kind-symmetric/kustomization.yaml @@ -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 diff --git a/deploy/overlays/kind/kustomization.yaml b/deploy/overlays/kind/kustomization.yaml index 63df85dc..283a8c55 100644 --- a/deploy/overlays/kind/kustomization.yaml +++ b/deploy/overlays/kind/kustomization.yaml @@ -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 diff --git a/docs/api.md b/docs/api.md index e5f3900a..d48c6911 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index ed8e2019..1216ad8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -763,9 +763,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 diff --git a/docs/configuration.md b/docs/configuration.md index 299b257c..c52738f9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,10 +18,11 @@ 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_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 | diff --git a/docs/deployment.md b/docs/deployment.md index 487749f5..66124886 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/lib/smolquery/auth/static.ex b/lib/smolquery/auth/static.ex new file mode 100644 index 00000000..b7db5dc0 --- /dev/null +++ b/lib/smolquery/auth/static.ex @@ -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 diff --git a/lib/smolquery_api/auth.ex b/lib/smolquery_api/auth.ex index b9d8bac0..9ce694f6 100644 --- a/lib/smolquery_api/auth.ex +++ b/lib/smolquery_api/auth.ex @@ -20,6 +20,7 @@ defmodule SmolqueryApi.Auth do import Plug.Conn + alias Smolquery.Auth alias Smolquery.InternalSecret alias SmolqueryApi.Errors alias SmolqueryApi.Runtime @@ -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 @@ -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 diff --git a/lib/smolquery_api/runtime.ex b/lib/smolquery_api/runtime.ex index 52733b43..e79978c7 100644 --- a/lib/smolquery_api/runtime.ex +++ b/lib/smolquery_api/runtime.ex @@ -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 @@ -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, @@ -45,7 +50,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(), @@ -57,7 +64,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 @@ -67,8 +75,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", @@ -77,6 +88,7 @@ defmodule SmolqueryApi.Runtime do scope: SmolqueryApi, role: :api ), + context: Static.api_context(), catalog: catalog, catalog_opts: catalog_opts } diff --git a/lib/smolquery_web/auth.ex b/lib/smolquery_web/auth.ex index b070122a..6ac08503 100644 --- a/lib/smolquery_web/auth.ex +++ b/lib/smolquery_web/auth.ex @@ -35,6 +35,7 @@ defmodule SmolqueryWeb.Auth do import Plug.Conn alias Phoenix.LiveView + alias Smolquery.Auth alias SmolqueryWeb.Runtime @realm "smolquery" @@ -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 @@ -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 diff --git a/lib/smolquery_web/runtime.ex b/lib/smolquery_web/runtime.ex index 191ef927..d1a5e821 100644 --- a/lib/smolquery_web/runtime.ex +++ b/lib/smolquery_web/runtime.ex @@ -9,13 +9,14 @@ defmodule SmolqueryWeb.Runtime do ## Configuration config :smolquery, SmolqueryWeb, + auth_mode: :static, username: "...", password: "...", catalog: [metadata: "sqlite:...", data_path: "..."] - `username` and `password` are the one static basic-auth credential that - every UI route requires (`SmolqueryWeb.Auth`). There is no default and no - fallback: a node with the `:web` role and no credential refuses to boot. The + `auth_mode: :static` explicitly selects the static Basic-auth adapter. + There is no default or fallback: a node holding the `:web` role with a + missing or unsupported mode, or without credentials, refuses to boot. The credential is not the API key, so a UI rotation does not break an ingest client. Multiple credentials and rotation come later, as for the API. @@ -34,15 +35,19 @@ defmodule SmolqueryWeb.Runtime do test. """ + alias Smolquery.Auth.Context + alias Smolquery.Auth.Static alias Smolquery.Catalog - @enforce_keys [:name, :username, :password, :session_marker, :catalog] - @derive {Inspect, except: [:username, :password]} + @enforce_keys [:name, :auth_mode, :username, :password, :session_marker, :context, :catalog] + @derive {Inspect, except: [:username, :password, :session_marker]} defstruct [ :name, + :auth_mode, :username, :password, :session_marker, + :context, :catalog, :catalog_opts, ingest_name: Smolquery.IngestService, @@ -51,9 +56,11 @@ defmodule SmolqueryWeb.Runtime do @type t :: %__MODULE__{ name: atom(), + auth_mode: :static, username: String.t(), password: String.t(), session_marker: String.t(), + context: Context.t(), catalog: Catalog.t(), catalog_opts: keyword() | nil, ingest_name: atom(), @@ -66,9 +73,10 @@ defmodule SmolqueryWeb.Runtime do Resolves configuration into a runtime. Application config for `SmolqueryWeb` supplies the defaults; `opts` - overrides them. Raises unless the merged options hold a non-empty `username` - and a non-empty `password`. Also raises unless a session secret of at least - 64 bytes is present — the `:secret_key_base` option, or the endpoint's own + overrides them. Raises if the authentication mode is missing or unsupported, + or unless the merged options hold a non-empty `username` and a non-empty + `password`. Also raises unless a session secret of at least 64 bytes is + present — the `:secret_key_base` option, or the endpoint's own `secret_key_base` config. """ @spec new(keyword()) :: t() @@ -79,15 +87,18 @@ defmodule SmolqueryWeb.Runtime do {catalog, catalog_opts} = Catalog.DuckLake.resolve(Keyword.get(config, :catalog), catalog_engine(name)) + auth_mode = Static.mode!(config, "the web UI", :web) username = fetch_credential!(config, :username) password = fetch_credential!(config, :password) secret_key_base = validate_session_secret!(resolve_session_secret(config)) %__MODULE__{ name: name, + auth_mode: auth_mode, username: username, password: password, session_marker: session_marker(secret_key_base, username, password), + context: Static.web_context(), catalog: catalog, catalog_opts: catalog_opts } diff --git a/test/smolquery/auth/static_test.exs b/test/smolquery/auth/static_test.exs new file mode 100644 index 00000000..df4f07e9 --- /dev/null +++ b/test/smolquery/auth/static_test.exs @@ -0,0 +1,48 @@ +defmodule Smolquery.Auth.StaticTest do + use ExUnit.Case, async: true + + alias Smolquery.Auth.Static + + describe "mode!/3" do + test "accepts explicit static mode" do + assert Static.mode!([auth_mode: :static], "the API", :api) == :static + end + + test "rejects missing, invalid, and unsupported modes" do + assert_raise ArgumentError, ~r/SMOLQUERY_AUTH_MODE/, fn -> + Static.mode!([], "the API", :api) + end + + assert_raise ArgumentError, ~r/invalid value/, fn -> + Static.mode!([auth_mode: :other], "the API", :api) + end + + assert_raise ArgumentError, ~r/not supported/, fn -> + Static.mode!([auth_mode: :oidc], "the API", :api) + end + end + end + + test "static contexts use stable non-secret identities and capabilities" do + api = Static.api_context() + web = Static.web_context() + + assert api.principal.authn == :api_key + assert api.principal.kind == :service + assert MapSet.equal?(api.capabilities, MapSet.new([:query, :ingest, :catalog_manage])) + assert web.principal.authn == :basic + assert web.principal.kind == :user + + assert MapSet.equal?( + web.capabilities, + MapSet.new([:web_access, :query, :catalog_manage, :platform_operate]) + ) + + for secret <- ["api-service", "web-operator", "smolquery-dev", "operator-secret"] do + refute api.principal.id =~ secret + refute inspect(api) =~ secret + refute web.principal.id =~ secret + refute inspect(web) =~ secret + end + end +end diff --git a/test/smolquery_api/router_test.exs b/test/smolquery_api/router_test.exs index 485e75ce..4a3d5e88 100644 --- a/test/smolquery_api/router_test.exs +++ b/test/smolquery_api/router_test.exs @@ -4,6 +4,7 @@ defmodule SmolqueryApi.RouterTest do import Plug.Conn, only: [put_req_header: 3] import Plug.Test + alias Smolquery.Auth alias Smolquery.Test.ApiEndpoint alias SmolqueryApi.Runtime @@ -82,6 +83,20 @@ defmodule SmolqueryApi.RouterTest do assert message =~ "API key" end + test "a correct key attaches the normalized service context" do + name = start_api() + + response = request(name, conn(:get, "/v1/no/such/route") |> authorized()) + + assert response.status == 404 + assert {:ok, context} = Auth.fetch_context(response) + assert context.principal.authn == :api_key + assert context.principal.kind == :service + assert context.principal.id == Runtime.new(name: name, api_key: @key).context.principal.id + assert MapSet.equal?(context.capabilities, MapSet.new([:query, :ingest, :catalog_manage])) + refute inspect(context) =~ @key + end + test "a wrong key is a 401" do name = start_api() @@ -90,6 +105,24 @@ defmodule SmolqueryApi.RouterTest do assert response.status == 401 end + test "rotating the API key preserves the principal identity" do + name = start_api() + first = request(name, authorized(conn(:get, "/v1/no/such/route"))) + + first_id = + first |> Auth.fetch_context() |> elem(1) |> Map.fetch!(:principal) |> Map.fetch!(:id) + + Runtime.put(Runtime.new(name: name, api_key: "rotated-api-key")) + second = request(name, authorized(conn(:get, "/v1/no/such/route"), "rotated-api-key")) + + second_id = + second |> Auth.fetch_context() |> elem(1) |> Map.fetch!(:principal) |> Map.fetch!(:id) + + assert first_id == second_id + {:ok, context} = Auth.fetch_context(second) + refute inspect(context) =~ "rotated-api-key" + end + test "a non-bearer scheme is a 401" do name = start_api() diff --git a/test/smolquery_api/runtime_test.exs b/test/smolquery_api/runtime_test.exs index 38560650..e51f3887 100644 --- a/test/smolquery_api/runtime_test.exs +++ b/test/smolquery_api/runtime_test.exs @@ -16,9 +16,25 @@ defmodule SmolqueryApi.RuntimeTest do assert Runtime.new(api_key: "k").name == SmolqueryApi end + test "refuses to resolve without an auth mode" do + assert_raise ArgumentError, ~r/SMOLQUERY_AUTH_MODE/, fn -> + Runtime.new(name: :api_runtime_test, auth_mode: nil, api_key: "k") + end + end + + test "refuses oidc and malformed auth modes without falling back" do + assert_raise ArgumentError, ~r/not supported/, fn -> + Runtime.new(name: :api_runtime_test, auth_mode: :oidc, api_key: "k") + end + + assert_raise ArgumentError, ~r/invalid value/, fn -> + Runtime.new(name: :api_runtime_test, auth_mode: :invalid, api_key: "k") + end + end + test "refuses to resolve without an api_key" do assert_raise ArgumentError, ~r/refuses to boot without an API key/, fn -> - Runtime.new(name: :api_runtime_test) + Runtime.new(name: :api_runtime_test, api_key: nil) end end diff --git a/test/smolquery_web/auth_test.exs b/test/smolquery_web/auth_test.exs index b3d861f2..538bf522 100644 --- a/test/smolquery_web/auth_test.exs +++ b/test/smolquery_web/auth_test.exs @@ -2,6 +2,7 @@ defmodule SmolqueryWeb.AuthTest do use SmolqueryWeb.ConnCase, async: false alias Phoenix.LiveView + alias Smolquery.Auth, as: AuthContext alias Smolquery.Test.MapCatalog alias SmolqueryWeb.Auth alias SmolqueryWeb.Runtime @@ -46,10 +47,22 @@ defmodule SmolqueryWeb.AuthTest do assert conn.status == 401 end - test "the correct credential is served", %{conn: conn} do + test "the correct credential is served with a normalized operator context", %{conn: conn} do conn = get(conn, ~p"/") assert conn.status == 200 + assert {:ok, context} = AuthContext.fetch_context(conn) + assert context.principal.authn == :basic + assert context.principal.kind == :user + + assert MapSet.equal?( + context.capabilities, + MapSet.new([:web_access, :query, :catalog_manage, :platform_operate]) + ) + + {username, password} = credential() + refute inspect(context) =~ username + refute inspect(context) =~ password end test "every route requires the credential" do @@ -128,10 +141,13 @@ defmodule SmolqueryWeb.AuthTest do assert {:redirect, %{to: "/"}} = socket.redirected end - test "a rotated password revokes the marker old sessions carry" do + test "a rotated password revokes the marker old sessions carry and preserves identity" do runtime = start_web!() + first_id = runtime.context.principal.id Runtime.put(Runtime.new(catalog: MapCatalog.new(), password: "rotated-password")) + {:ok, rotated} = Runtime.fetch(SmolqueryWeb) + assert rotated.context.principal.id == first_id assert {:halt, socket} = Auth.on_mount( @@ -144,7 +160,26 @@ defmodule SmolqueryWeb.AuthTest do assert {:redirect, %{to: "/"}} = socket.redirected end - test "the hook admits a mount whose session carries the marker" do + test "a rotated username revokes the marker while preserving identity" do + runtime = start_web!() + rotated = Runtime.new(catalog: MapCatalog.new(), username: "rotated-user") + + assert rotated.context.principal.id == runtime.context.principal.id + refute rotated.session_marker == runtime.session_marker + Runtime.put(rotated) + + assert {:halt, socket} = + Auth.on_mount( + :require_authenticated, + %{}, + %{"authenticated" => runtime.session_marker}, + %LiveView.Socket{} + ) + + assert {:redirect, %{to: "/"}} = socket.redirected + end + + test "the hook admits a mount whose session carries the marker and assigns context" do runtime = start_web!() assert {:cont, socket} = @@ -155,6 +190,8 @@ defmodule SmolqueryWeb.AuthTest do %LiveView.Socket{} ) + assert {:ok, context} = AuthContext.fetch_context(socket) + assert context.principal.id == runtime.context.principal.id refute socket.redirected end end diff --git a/test/smolquery_web/runtime_test.exs b/test/smolquery_web/runtime_test.exs index 5091b389..a00021e3 100644 --- a/test/smolquery_web/runtime_test.exs +++ b/test/smolquery_web/runtime_test.exs @@ -18,6 +18,22 @@ defmodule SmolqueryWeb.RuntimeTest do assert Runtime.new().name == SmolqueryWeb end + test "refuses to resolve without an auth mode" do + assert_raise ArgumentError, ~r/SMOLQUERY_AUTH_MODE/, fn -> + Runtime.new(name: :web_runtime_test, auth_mode: nil) + end + end + + test "refuses oidc and malformed auth modes without falling back" do + assert_raise ArgumentError, ~r/not supported/, fn -> + Runtime.new(name: :web_runtime_test, auth_mode: :oidc) + end + + assert_raise ArgumentError, ~r/invalid value/, fn -> + Runtime.new(name: :web_runtime_test, auth_mode: :invalid) + end + end + test "refuses to resolve without a username" do assert_raise ArgumentError, ~r/SMOLQUERY_WEB_USERNAME/, fn -> Runtime.new(name: :web_runtime_test, username: nil) @@ -83,6 +99,7 @@ defmodule SmolqueryWeb.RuntimeTest do refute inspect(runtime) =~ "operator-name-7739" refute inspect(runtime) =~ "operator-secret-7739" + refute inspect(runtime) =~ runtime.session_marker end end