From 79eb34840fed049647ad343a0062059d5dcc5b6c Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Tue, 6 Oct 2026 15:16:33 +0000 Subject: [PATCH] Tablet 45: the courtyard gate checks the key, not the shape of the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /courtyard/* and /chatzer/* admitted any `Authorization: Bearer `. Every test sent `Bearer test-token`; none sent a wrong one. Every admitted request spends the day's manna. VavPipeline.BigdeiKodesh.Gate: keys are a file (Construction §4.6 — never env, never config, never git), one `tier key` per line, tiers courtyard < chatzer (the inner ring opens the outer). Constant-time compare against every line; no file / empty / unreadable → nobody passes, GATE_NO_KEYS in the log, the caller told only "missing or invalid". 403 FORBIDDEN for an outer key at an inner gate. Read on every request so a rotated key is live in ~1 min without a rollout. Deploy: Secret shorashim-api-keys projected at /etc/shorashim/secrets/api_keys (vav-pipeline only, 0440), API_KEYS_FILE in the configmap, template + issue/rotate recipe in secrets.yaml and KOHEN.md "Issuing a key". Spec: tamid §1.1 (gate, 403), vayakhel §4.6. Doorpost 91d09d8d… → 42b38626…. Tests: tablet45_gate_test (parse, tiers, wrong/near/empty/no key, no file) — Elixir 527/0. Co-Authored-By: Claude Fable 5.1 --- KOHEN.md | 23 ++++ deploy/k8s/configmap.yaml | 5 + deploy/k8s/deployment.yaml | 10 ++ deploy/k8s/secrets.yaml | 35 +++++ specs/shorashim_tamid.md | 11 ++ specs/shorashim_vayakhel.md | 1 + vav_pipeline/config/config.exs | 5 +- vav_pipeline/config/runtime.exs | 2 + vav_pipeline/config/test.exs | 1 + .../lib/vav_pipeline/bigdei_kodesh/gate.ex | 130 ++++++++++++++++++ .../lib/vav_pipeline/bigdei_kodesh/router.ex | 55 ++++---- vav_pipeline/lib/vav_pipeline/doorpost.ex | 2 +- vav_pipeline/test/bedikah_test.exs | 2 +- vav_pipeline/test/bigdei_kodesh_test.exs | 4 +- vav_pipeline/test/fixtures/api_keys | 6 + vav_pipeline/test/fixtures/manna/ledger.json | 1 + vav_pipeline/test/tablet45_gate_test.exs | 113 +++++++++++++++ 17 files changed, 372 insertions(+), 34 deletions(-) create mode 100644 vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex create mode 100644 vav_pipeline/test/fixtures/api_keys create mode 100644 vav_pipeline/test/fixtures/manna/ledger.json create mode 100644 vav_pipeline/test/tablet45_gate_test.exs diff --git a/KOHEN.md b/KOHEN.md index b88e573f..01c8e0b4 100644 --- a/KOHEN.md +++ b/KOHEN.md @@ -488,6 +488,29 @@ curl https://shorashim.ch/kodesh/status | jq .halt_status curl https://shorashim.ch/kodesh/status | jq '.refuge_components' ``` +### Issuing a key — *Mishmeret HaSha'ar* + +The courtyard and the chatzer open only to a key you issued. Keys live +in the `shorashim-api-keys` Secret, mounted as a file; the gate reads +the file on every request, so a change takes effect within about a +minute, without a rollout. One line per key: `courtyard ` (offerings, +bedikot) or `chatzer ` (also the Sanhedrin; opens the courtyard too). + +```bash +umask 077; TMP=$(mktemp) +kubectl get secret shorashim-api-keys -n shorashim -o jsonpath='{.data.api_keys}' \ + | base64 -d > "$TMP" 2>/dev/null || true # existing keys, if any +printf 'courtyard %s\n' "$(openssl rand -hex 32)" >> "$TMP" +kubectl create secret generic shorashim-api-keys -n shorashim \ + --from-file=api_keys="$TMP" --dry-run=client -o yaml | kubectl apply -f - +tail -1 "$TMP" # hand this line's key to its bearer, then +rm -f "$TMP" +``` + +To revoke: remove the line the same way. Without the Secret the pod +does not start; with an empty file nobody passes (`GATE_NO_KEYS` in the +vav-pipeline log). + ### Checking a model's output — *Bedikah* The external check (Tamid §1.1, *lo taaseh lekha* §9.1). Hand it any diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index 5646d5c5..a3b7884e 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -20,6 +20,11 @@ data: # shared tmp-shorashim volume. Pod-internal; never on the network. KORBAN_SOCKET_PATH: "/tmp/shorashim/korban.sock" + # Courtyard / chatzer keys — a FILE (Construction §4.6), projected from + # the shorashim-api-keys Secret (secrets.yaml). The path is config; the + # keys never are. + API_KEYS_FILE: "/etc/shorashim/secrets/api_keys" + # Shoresh Engine binary — staged onto the shared shoresh-bin volume # by the shoresh-engine-install init container (see deployment.yaml). # vav_pipeline spawns it as an Erlang Port, i.e. as a child process of diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 638852fd..2005623f 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -76,6 +76,13 @@ spec: # vav-pipeline, which spawns it as an Erlang Port. - name: shoresh-bin emptyDir: {} + # The courtyard keys, as a file (Construction §4.6). Only + # vav-pipeline mounts it. Absent → the pod waits at + # ContainerCreating, loudly; there is no "open gate" fallback. + - name: api-keys + secret: + secretName: shorashim-api-keys + defaultMode: 0440 # --- POD SECURITY CONTEXT --- # Every image runs as the non-root user `shorashim` (uid/gid @@ -221,6 +228,9 @@ spec: # directory both live under it. - name: tmp-shorashim mountPath: /tmp + - name: api-keys + mountPath: /etc/shorashim/secrets + readOnly: true - name: shoresh-bin mountPath: /opt/shoresh readOnly: true diff --git a/deploy/k8s/secrets.yaml b/deploy/k8s/secrets.yaml index 3b3fdaaa..e7dc6906 100644 --- a/deploy/k8s/secrets.yaml +++ b/deploy/k8s/secrets.yaml @@ -88,3 +88,38 @@ type: Opaque stringData: # ecto://avnadmin:PASSWORD@host:port/database?sslmode=require DATABASE_URL: "REPLACE_WITH_AVNADMIN_DATABASE_URL" + +--- +# ============================================================ +# shorashim-api-keys — who may enter the courtyard +# ============================================================ +# +# Mounted as the file /etc/shorashim/secrets/api_keys in vav-pipeline +# (Construction §4.6: a secret is a file). One key per line: +# +# courtyard offerings and bedikot (/courtyard/*) +# chatzer also the Sanhedrin (/chatzer/*); opens the courtyard too +# +# Issue a key (KOHEN.md "Issuing a key"): +# +# umask 077; TMP=$(mktemp) +# printf 'courtyard %s\n' "$(openssl rand -hex 32)" >> "$TMP" +# kubectl create secret generic shorashim-api-keys -n shorashim \ +# --from-file=api_keys="$TMP" +# # rotate: kubectl create ... --dry-run=client -o yaml | kubectl apply -f - +# rm -f "$TMP" +# +# The kubelet refreshes a projected Secret within about a minute; the +# gate reads the file on every request, so a rotated key takes effect +# without a rollout. No file, no keys → nobody passes (GATE_NO_KEYS in +# the log). +apiVersion: v1 +kind: Secret +metadata: + name: shorashim-api-keys + namespace: shorashim +type: Opaque +stringData: + api_keys: | + # tier key + courtyard REPLACE_WITH_openssl_rand_hex_32 diff --git a/specs/shorashim_tamid.md b/specs/shorashim_tamid.md index 125f71e9..94933da9 100644 --- a/specs/shorashim_tamid.md +++ b/specs/shorashim_tamid.md @@ -53,6 +53,15 @@ Rate limit: 10 requests per minute per IP #### Machaneh Leviyah — Authenticated Endpoint +**The gate.** A key is a line in the keys file +(`/etc/shorashim/secrets/api_keys`, Construction §4.6 — a file, never an +environment variable), `tier key`, tier `courtyard` or `chatzer`. The +key is compared in constant time against every line; a `chatzer` key +opens the courtyard too, a `courtyard` key at the chatzer is 403. No +file or no keys: nobody passes, and the log says `GATE_NO_KEYS`. The +caller is never told why beyond "missing or invalid API key". Issued by +the kohen (KOHEN.md "Issuing a key"); the key never appears in a log. + ``` Endpoint: POST /courtyard/offering @@ -94,6 +103,7 @@ Response: Errors: 401 — Invalid or missing API key + 403 — A key for an outer ring at an inner gate 422 — Input failed kiyor (validation). Body includes purification guidance. 429 — Rate limit exceeded. Body includes manna explanation. 503 — System in Shabbat/festival mode. Body includes resumption time. @@ -162,6 +172,7 @@ Response (200): Errors: 401 — Invalid or missing API key + 403 — A key for an outer ring at an inner gate 422 — Missing or empty output, output over 65,536 bytes, unknown model 503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE, TALMUDIC_UNAVAILABLE. Body says which; resubmit: true. diff --git a/specs/shorashim_vayakhel.md b/specs/shorashim_vayakhel.md index ffff26a1..d51095d0 100644 --- a/specs/shorashim_vayakhel.md +++ b/specs/shorashim_vayakhel.md @@ -629,6 +629,7 @@ table inet filter { # /etc/shorashim/secrets/ (mode 0600, owned by shorashim user) ├── db_password # PostgreSQL password ├── claude_api_key # Anthropic API key for korban source +├── api_keys # Courtyard/chatzer keys, one "tier key" per line (Tamid §1.1) ├── l1_hash # Expected SHA-256 of L1 filesystem └── kohanim_ssh_keys/ # Authorized keys for kohanim (operators) SSH ``` diff --git a/vav_pipeline/config/config.exs b/vav_pipeline/config/config.exs index 45915514..63298222 100644 --- a/vav_pipeline/config/config.exs +++ b/vav_pipeline/config/config.exs @@ -17,7 +17,10 @@ config :vav_pipeline, shoresh_engine_cwd: nil, # Talmudic Engine (Prolog) — Unix domain socket path talmudic_socket_path: "/tmp/shorashim/talmudic.sock", - korban_socket_path: "/tmp/shorashim/korban.sock" + korban_socket_path: "/tmp/shorashim/korban.sock", + # Courtyard / chatzer keys (BigdeiKodesh.Gate). nil → the gate is shut. + # Construction §4.6: a file, never an environment variable. + api_keys_file: nil # L3 — Oral Tradition (PostgreSQL) # Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. diff --git a/vav_pipeline/config/runtime.exs b/vav_pipeline/config/runtime.exs index e0dea181..ff0fe777 100644 --- a/vav_pipeline/config/runtime.exs +++ b/vav_pipeline/config/runtime.exs @@ -30,6 +30,8 @@ if config_env() == :prod do System.get_env("TALMUDIC_SOCKET_PATH") || "/tmp/shorashim/talmudic.sock", korban_socket_path: System.get_env("KORBAN_SOCKET_PATH") || "/tmp/shorashim/korban.sock", + # The keys themselves are in the file (a mounted Secret), not in env. + api_keys_file: System.get_env("API_KEYS_FILE") || "/etc/shorashim/secrets/api_keys", # Shoresh Engine binary — VavPipeline.ShoreshEnginePort opens an # Erlang Port with {:spawn_executable, binary}, which spawns a CHILD # PROCESS OF THIS CONTAINER. A Port cannot cross a container diff --git a/vav_pipeline/config/test.exs b/vav_pipeline/config/test.exs index ac20dc89..7615199b 100644 --- a/vav_pipeline/config/test.exs +++ b/vav_pipeline/config/test.exs @@ -6,6 +6,7 @@ config :logger, level: :warning config :vav_pipeline, l1_mount_point: "../l1", manna_provision_file: "test/fixtures/manna/today.json", + api_keys_file: "test/fixtures/api_keys", cycle_boundary_check_interval_ms: :timer.hours(24), http_port: 0 diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex new file mode 100644 index 00000000..a1cd543d --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/gate.ex @@ -0,0 +1,130 @@ +defmodule VavPipeline.BigdeiKodesh.Gate do + @moduledoc """ + The gate of the courtyard — who may bring an offering past the pe'ah. + + Bamidbar 1:51 — *v'hazar hakarev yumat*: the stranger who draws near + the inner service does not pass. The pe'ah (`/peah/*`) is open to the + ger; the courtyard (`/courtyard/*`) and the chatzer (`/chatzer/*`) are + for those who were given a key. + + Until 2026-10-06 the gate looked only at the SHAPE of the header: any + `Authorization: Bearer ` was admitted. A gate that opens for + every hand is a wall with a hole in it, and every admitted request + spends the day's manna. + + **Keys.** A file (Construction §4.6: secrets are files, mode 0600, + never environment variables, never configuration, never the + repository), one key per line: + + # tier key + courtyard 5f2c… # may bring offerings and bedikot + chatzer 9a1e… # may also convene the Sanhedrin + + A chatzer key opens the courtyard too — the inner ring contains the + outer. Blank lines and `#` comments are ignored. The file is read on + every request so that a rotated key takes effect at once; it is a few + lines, and the cost is nothing beside a cycle. + + **Fail closed.** No file, an empty file, an unreadable file — nobody + passes. The operator sees it in the log (`GATE_NO_KEYS`), never the + caller: the caller is told only that the key is missing or invalid. + + Keys are compared in constant time (`Plug.Crypto.secure_compare/2`). + No key, valid or not, is ever written to a log. + """ + + require Logger + + @tiers [:courtyard, :chatzer] + @type tier :: :courtyard | :chatzer + + @doc "Tiers, outermost first. A key of a later tier opens every earlier one." + @spec tiers() :: [tier()] + def tiers, do: @tiers + + @doc "Where the keys are read from. `nil` means the gate is shut." + @spec keys_file() :: String.t() | nil + def keys_file, do: Application.get_env(:vav_pipeline, :api_keys_file) + + @doc """ + Admit the request to `tier`, or say why not. + + * `{:ok, granted}` — the key is good for `granted`, which is `tier` + or an inner one. + * `{:error, :no_header}` — no `Authorization: Bearer ` header. + * `{:error, :unknown_key}` — the key is not in the file, or there + is no file (logged as GATE_NO_KEYS, once per denial). + * `{:error, :tier_too_low}` — a good key, but for an outer ring. + """ + @spec admit(Plug.Conn.t(), tier()) :: + {:ok, tier()} | {:error, :no_header | :unknown_key | :tier_too_low} + def admit(conn, tier) when tier in @tiers do + with {:ok, key} <- bearer(conn), + {:ok, granted} <- lookup(key, keys_file()) do + if rank(granted) >= rank(tier), do: {:ok, granted}, else: {:error, :tier_too_low} + end + end + + @doc "Parse the contents of a keys file into `[{tier, key}]`. Malformed lines are skipped." + @spec parse(String.t()) :: [{tier(), String.t()}] + def parse(contents) do + contents + |> String.split(~r/\r?\n/) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "" or String.starts_with?(&1, "#"))) + |> Enum.flat_map(fn line -> + case String.split(line, ~r/\s+/, parts: 3) do + [tier, key | _] when tier in ["courtyard", "chatzer"] and key != "" -> + [{String.to_existing_atom(tier), key}] + + _ -> + [] + end + end) + end + + # ------------------------------------------------------------ + + defp bearer(conn) do + case Plug.Conn.get_req_header(conn, "authorization") do + ["Bearer " <> key] when key != "" -> {:ok, String.trim(key)} + _ -> {:error, :no_header} + end + end + + defp lookup(_key, nil) do + Logger.warning("GATE_NO_KEYS: no api_keys_file configured — the courtyard is shut") + {:error, :unknown_key} + end + + defp lookup(key, path) do + case File.read(path) do + {:ok, contents} -> + entries = parse(contents) + + if entries == [] do + Logger.warning("GATE_NO_KEYS: #{path} holds no keys — the courtyard is shut") + end + + # Compare against every entry, not just until the first match: + # the time taken must not say how far down the file the key was. + entries + |> Enum.reduce(nil, fn {tier, candidate}, found -> + if Plug.Crypto.secure_compare(candidate, key), do: best(found, tier), else: found + end) + |> case do + nil -> {:error, :unknown_key} + tier -> {:ok, tier} + end + + {:error, reason} -> + Logger.warning("GATE_NO_KEYS: cannot read #{path} (#{reason}) — the courtyard is shut") + {:error, :unknown_key} + end + end + + defp best(nil, tier), do: tier + defp best(a, b), do: if(rank(a) >= rank(b), do: a, else: b) + + defp rank(tier), do: Enum.find_index(@tiers, &(&1 == tier)) +end diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 8e2cd195..26e2ecea 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -28,6 +28,8 @@ defmodule VavPipeline.BigdeiKodesh.Router do require Logger + alias VavPipeline.BigdeiKodesh.Gate + # Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger # replaces Plug.Logger; sanitized paths on /peah/* honor the # no-tracking promise made in /peah/ask's own response body. @@ -128,21 +130,35 @@ defmodule VavPipeline.BigdeiKodesh.Router do end # === COURTYARD — Authenticated === + # + # The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate), + # not merely for its shape. Until 2026-10-06 any Bearer value passed. + defp through_the_gate(conn, tier, serve) do + case Gate.admit(conn, tier) do + {:ok, _granted} -> + serve.(conn) - post "/courtyard/offering" do - case get_req_header(conn, "authorization") do - ["Bearer " <> _token] -> - process_courtyard(conn) + {:error, :tier_too_low} -> + send_error(conn, 403, "FORBIDDEN", %{ + issue: "This key does not open the #{tier}", + correction: "Use a key issued for the #{tier} tier", + resubmit: false + }) - _ -> + {:error, _} -> send_error(conn, 401, "UNAUTHORIZED", %{ - issue: "Missing or invalid Authorization header", - correction: "Include 'Authorization: Bearer ' header", + issue: "Missing or invalid API key", + correction: "Include 'Authorization: Bearer ' with a key issued by the kohen", resubmit: true }) end end + + post "/courtyard/offering" do + through_the_gate(conn, :courtyard, &process_courtyard/1) + end + # === COURTYARD — the bedikah (Construction §3.9) === # # An external model's output, brought by hand to be examined against @@ -152,33 +168,13 @@ defmodule VavPipeline.BigdeiKodesh.Router do # engraved in L3. See VavPipeline.Bedikah. post "/courtyard/bedikah" do - case get_req_header(conn, "authorization") do - ["Bearer " <> _token] -> - process_bedikah(conn) - - _ -> - send_error(conn, 401, "UNAUTHORIZED", %{ - issue: "Missing or invalid Authorization header", - correction: "Include 'Authorization: Bearer ' header", - resubmit: true - }) - end + through_the_gate(conn, :courtyard, &process_bedikah/1) end # === CHATZER — Full Access (Sanhedrin) === post "/chatzer/sanhedrin" do - case get_req_header(conn, "authorization") do - ["Bearer " <> _token] -> - process_sanhedrin(conn) - - _ -> - send_error(conn, 401, "UNAUTHORIZED", %{ - issue: "Missing or invalid Authorization header", - correction: "Include 'Authorization: Bearer ' header", - resubmit: true - }) - end + through_the_gate(conn, :chatzer, &process_sanhedrin/1) end # === KODESH — Operator Interface === @@ -954,6 +950,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do defp error_type("CLOUD" <> _), do: "system_transition" defp error_type("HALT" <> _), do: "cardinal_sin_detected" defp error_type("UNAUTHORIZED"), do: "authentication_required" + defp error_type("FORBIDDEN"), do: "insufficient_tier" defp error_type("TEIKU" <> _), do: "unresolvable" defp error_type("LO_TOV"), do: "specification_mismatch" defp error_type("CHATAT" <> _), do: "correction_needed" diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex index 9a0176cc..62277e74 100644 --- a/vav_pipeline/lib/vav_pipeline/doorpost.ex +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do # changes; the update is a deliberate operator commit. A bare # hash-update commit (without a corresponding spec change in the # same commit) is itself a drift signal. - @expected_hash "91d09d8dfb47f4440e5e02f8ef2695026fade315cfaadf7c72c126bad3903547" + @expected_hash "42b38626c6e2bd3e6fe10ed7f4cb34ee8dae118205379b7d822f278fe7eaa110" @doc "Return the list of files that constitute the category-grade specification." @spec files() :: [String.t()] diff --git a/vav_pipeline/test/bedikah_test.exs b/vav_pipeline/test/bedikah_test.exs index 63f70a22..ce8e51a2 100644 --- a/vav_pipeline/test/bedikah_test.exs +++ b/vav_pipeline/test/bedikah_test.exs @@ -342,7 +342,7 @@ defmodule VavPipeline.BedikahTest do defp post_bedikah(body, opts \\ []) do conn = conn(:post, "/courtyard/bedikah", Jason.encode!(body)) |> put_req_header("content-type", "application/json") - conn = if Keyword.get(opts, :auth, true), do: put_req_header(conn, "authorization", "Bearer test"), else: conn + conn = if Keyword.get(opts, :auth, true), do: put_req_header(conn, "authorization", "Bearer test-token"), else: conn Router.call(conn, @opts) end diff --git a/vav_pipeline/test/bigdei_kodesh_test.exs b/vav_pipeline/test/bigdei_kodesh_test.exs index 09f34ed8..d4b69582 100644 --- a/vav_pipeline/test/bigdei_kodesh_test.exs +++ b/vav_pipeline/test/bigdei_kodesh_test.exs @@ -294,7 +294,7 @@ defmodule VavPipeline.BigdeiKodeshTest do conn = conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"})) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", "Bearer test-token") + |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 422 @@ -306,7 +306,7 @@ defmodule VavPipeline.BigdeiKodeshTest do case: "Is cooking on Shabbat permitted for pikuach nefesh?" })) |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", "Bearer test-token") + |> put_req_header("authorization", "Bearer chatzer-test-token") |> Router.call(@opts) assert conn.status == 200 diff --git a/vav_pipeline/test/fixtures/api_keys b/vav_pipeline/test/fixtures/api_keys new file mode 100644 index 00000000..233a369c --- /dev/null +++ b/vav_pipeline/test/fixtures/api_keys @@ -0,0 +1,6 @@ +# Test keys for the gate (BigdeiKodesh.Gate). tier, whitespace, key. +courtyard test-token +chatzer chatzer-test-token + +# A malformed line, ignored: +kodesh not-a-tier diff --git a/vav_pipeline/test/fixtures/manna/ledger.json b/vav_pipeline/test/fixtures/manna/ledger.json new file mode 100644 index 00000000..b034a61c --- /dev/null +++ b/vav_pipeline/test/fixtures/manna/ledger.json @@ -0,0 +1 @@ +{"consumed":{"api_calls_external":0,"compute_units":3,"memory_mb":0,"sanhedrin_panels_max":0,"storage_delta_mb":0},"cycle_date":"2026-10-06","written_at":"2026-10-06T15:15:24.042079Z"} \ No newline at end of file diff --git a/vav_pipeline/test/tablet45_gate_test.exs b/vav_pipeline/test/tablet45_gate_test.exs new file mode 100644 index 00000000..9b224a55 --- /dev/null +++ b/vav_pipeline/test/tablet45_gate_test.exs @@ -0,0 +1,113 @@ +defmodule VavPipeline.Tablet45GateTest do + @moduledoc """ + Tablet 45 (2026-10-06) — the courtyard gate opened for any hand. + + `/courtyard/*` and `/chatzer/*` checked that the Authorization header + had the SHAPE `Bearer `, and nothing else. Every test of it + sent `Bearer test-token` and passed; none sent `Bearer wrong`. + + These tests send the wrong key, no key, a key for the outer ring at + the inner gate, and a key when there is no keys file at all. + """ + + use ExUnit.Case, async: false + + import Plug.Test + import Plug.Conn + + alias VavPipeline.BigdeiKodesh.{Gate, Router} + + @opts Router.init([]) + + describe "Gate.parse/1" do + test "reads tier and key, skips comments, blanks and unknown tiers" do + assert Gate.parse(""" + # keys + courtyard abc + + chatzer\tdef # trailing words are ignored + kodesh ghi + courtyard + """) == [courtyard: "abc", chatzer: "def"] + end + end + + describe "Gate.admit/2" do + test "the right key for the ring" do + assert {:ok, :courtyard} = Gate.admit(with_key("test-token"), :courtyard) + assert {:ok, :chatzer} = Gate.admit(with_key("chatzer-test-token"), :chatzer) + end + + test "the inner ring's key opens the outer gate; not the reverse" do + assert {:ok, :chatzer} = Gate.admit(with_key("chatzer-test-token"), :courtyard) + assert {:error, :tier_too_low} = Gate.admit(with_key("test-token"), :chatzer) + end + + test "a wrong key, a near key, an empty key, no header" do + assert {:error, :unknown_key} = Gate.admit(with_key("wrong"), :courtyard) + assert {:error, :unknown_key} = Gate.admit(with_key("test-token2"), :courtyard) + assert {:error, :unknown_key} = Gate.admit(with_key("test-toke"), :courtyard) + assert {:error, :no_header} = Gate.admit(with_key(""), :courtyard) + assert {:error, :no_header} = Gate.admit(conn(:post, "/courtyard/offering"), :courtyard) + + basic = conn(:post, "/courtyard/offering") |> put_req_header("authorization", "Basic dGVzdA==") + assert {:error, :no_header} = Gate.admit(basic, :courtyard) + end + + test "no keys file, an empty one, a missing one — nobody passes" do + original = Application.get_env(:vav_pipeline, :api_keys_file) + on_exit(fn -> Application.put_env(:vav_pipeline, :api_keys_file, original) end) + + Application.put_env(:vav_pipeline, :api_keys_file, nil) + assert {:error, :unknown_key} = Gate.admit(with_key("test-token"), :courtyard) + + empty = Path.join(System.tmp_dir!(), "tablet45_empty_#{System.unique_integer([:positive])}") + File.write!(empty, "# nothing here\n\n") + Application.put_env(:vav_pipeline, :api_keys_file, empty) + assert {:error, :unknown_key} = Gate.admit(with_key("test-token"), :courtyard) + File.rm!(empty) + + Application.put_env(:vav_pipeline, :api_keys_file, "/nonexistent/api_keys") + assert {:error, :unknown_key} = Gate.admit(with_key("test-token"), :courtyard) + end + end + + describe "at the router" do + test "a wrong key is 401 at every gated door, and says nothing about the keys" do + for {path, body} <- [ + {"/courtyard/offering", %{offering_type: "shelamim", content: "x"}}, + {"/courtyard/bedikah", %{output: "x", model: "gpt"}}, + {"/chatzer/sanhedrin", %{case: "x"}} + ] do + conn = post(path, body, "Bearer wrong") + assert conn.status == 401, path + decoded = Jason.decode!(conn.resp_body) + assert decoded["error"]["code"] == "UNAUTHORIZED" + refute conn.resp_body =~ "test-token" + end + end + + test "a courtyard key at the chatzer is 403, not 401" do + conn = post("/chatzer/sanhedrin", %{case: "x"}, "Bearer test-token") + assert conn.status == 403 + assert Jason.decode!(conn.resp_body)["error"]["code"] == "FORBIDDEN" + end + + test "a chatzer key is good in the courtyard" do + conn = post("/courtyard/offering", %{offering_type: "shelamim"}, "Bearer chatzer-test-token") + # Past the gate: the next check (no content) answers, not the gate. + assert conn.status == 422 + end + end + + defp with_key(key) do + conn(:post, "/courtyard/offering") |> put_req_header("authorization", "Bearer " <> key) + end + + defp post(path, body, auth) do + conn(:post, path, Jason.encode!(body)) + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", auth) + |> Router.call(@opts) + end +end