Tablet 45: the courtyard gate checks the key, not the shape of the header
/courtyard/* and /chatzer/* admitted any `Authorization: Bearer <x>`. 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
996a8efe68
commit
79eb34840f
17 files changed
+372
-34
No files matched your search
@@ -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 <key>` (offerings,
|
||||
bedikot) or `chatzer <key>` (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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <key> offerings and bedikot (/courtyard/*)
|
||||
# chatzer <key> 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <anything>` 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 <key>` 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
|
||||
@@ -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 <api-key>' header",
|
||||
issue: "Missing or invalid API key",
|
||||
correction: "Include 'Authorization: Bearer <api-key>' 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 <api-key>' 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 <api-key>' 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"
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+6
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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 <something>`, 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
|
||||
Reference in new issue
Block a user