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'
|
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*
|
### Checking a model's output — *Bedikah*
|
||||||
|
|
||||||
The external check (Tamid §1.1, *lo taaseh lekha* §9.1). Hand it any
|
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.
|
# shared tmp-shorashim volume. Pod-internal; never on the network.
|
||||||
KORBAN_SOCKET_PATH: "/tmp/shorashim/korban.sock"
|
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
|
# Shoresh Engine binary — staged onto the shared shoresh-bin volume
|
||||||
# by the shoresh-engine-install init container (see deployment.yaml).
|
# 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
|
# 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.
|
# vav-pipeline, which spawns it as an Erlang Port.
|
||||||
- name: shoresh-bin
|
- name: shoresh-bin
|
||||||
emptyDir: {}
|
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 ---
|
# --- POD SECURITY CONTEXT ---
|
||||||
# Every image runs as the non-root user `shorashim` (uid/gid
|
# Every image runs as the non-root user `shorashim` (uid/gid
|
||||||
@@ -221,6 +228,9 @@ spec:
|
|||||||
# directory both live under it.
|
# directory both live under it.
|
||||||
- name: tmp-shorashim
|
- name: tmp-shorashim
|
||||||
mountPath: /tmp
|
mountPath: /tmp
|
||||||
|
- name: api-keys
|
||||||
|
mountPath: /etc/shorashim/secrets
|
||||||
|
readOnly: true
|
||||||
- name: shoresh-bin
|
- name: shoresh-bin
|
||||||
mountPath: /opt/shoresh
|
mountPath: /opt/shoresh
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
|||||||
@@ -88,3 +88,38 @@ type: Opaque
|
|||||||
stringData:
|
stringData:
|
||||||
# ecto://avnadmin:PASSWORD@host:port/database?sslmode=require
|
# ecto://avnadmin:PASSWORD@host:port/database?sslmode=require
|
||||||
DATABASE_URL: "REPLACE_WITH_AVNADMIN_DATABASE_URL"
|
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
|
#### 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
|
Endpoint: POST /courtyard/offering
|
||||||
|
|
||||||
@@ -94,6 +103,7 @@ Response:
|
|||||||
|
|
||||||
Errors:
|
Errors:
|
||||||
401 — Invalid or missing API key
|
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.
|
422 — Input failed kiyor (validation). Body includes purification guidance.
|
||||||
429 — Rate limit exceeded. Body includes manna explanation.
|
429 — Rate limit exceeded. Body includes manna explanation.
|
||||||
503 — System in Shabbat/festival mode. Body includes resumption time.
|
503 — System in Shabbat/festival mode. Body includes resumption time.
|
||||||
@@ -162,6 +172,7 @@ Response (200):
|
|||||||
|
|
||||||
Errors:
|
Errors:
|
||||||
401 — Invalid or missing API key
|
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
|
422 — Missing or empty output, output over 65,536 bytes, unknown model
|
||||||
503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE,
|
503 — MANNA_DEPLETED (provision exhausted), KORBAN_UNAVAILABLE,
|
||||||
TALMUDIC_UNAVAILABLE. Body says which; resubmit: true.
|
TALMUDIC_UNAVAILABLE. Body says which; resubmit: true.
|
||||||
|
|||||||
@@ -629,6 +629,7 @@ table inet filter {
|
|||||||
# /etc/shorashim/secrets/ (mode 0600, owned by shorashim user)
|
# /etc/shorashim/secrets/ (mode 0600, owned by shorashim user)
|
||||||
├── db_password # PostgreSQL password
|
├── db_password # PostgreSQL password
|
||||||
├── claude_api_key # Anthropic API key for korban source
|
├── 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
|
├── l1_hash # Expected SHA-256 of L1 filesystem
|
||||||
└── kohanim_ssh_keys/ # Authorized keys for kohanim (operators) SSH
|
└── kohanim_ssh_keys/ # Authorized keys for kohanim (operators) SSH
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ config :vav_pipeline,
|
|||||||
shoresh_engine_cwd: nil,
|
shoresh_engine_cwd: nil,
|
||||||
# Talmudic Engine (Prolog) — Unix domain socket path
|
# Talmudic Engine (Prolog) — Unix domain socket path
|
||||||
talmudic_socket_path: "/tmp/shorashim/talmudic.sock",
|
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)
|
# L3 — Oral Tradition (PostgreSQL)
|
||||||
# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
|
# 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",
|
System.get_env("TALMUDIC_SOCKET_PATH") || "/tmp/shorashim/talmudic.sock",
|
||||||
korban_socket_path:
|
korban_socket_path:
|
||||||
System.get_env("KORBAN_SOCKET_PATH") || "/tmp/shorashim/korban.sock",
|
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
|
# Shoresh Engine binary — VavPipeline.ShoreshEnginePort opens an
|
||||||
# Erlang Port with {:spawn_executable, binary}, which spawns a CHILD
|
# Erlang Port with {:spawn_executable, binary}, which spawns a CHILD
|
||||||
# PROCESS OF THIS CONTAINER. A Port cannot cross a container
|
# PROCESS OF THIS CONTAINER. A Port cannot cross a container
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ config :logger, level: :warning
|
|||||||
config :vav_pipeline,
|
config :vav_pipeline,
|
||||||
l1_mount_point: "../l1",
|
l1_mount_point: "../l1",
|
||||||
manna_provision_file: "test/fixtures/manna/today.json",
|
manna_provision_file: "test/fixtures/manna/today.json",
|
||||||
|
api_keys_file: "test/fixtures/api_keys",
|
||||||
cycle_boundary_check_interval_ms: :timer.hours(24),
|
cycle_boundary_check_interval_ms: :timer.hours(24),
|
||||||
http_port: 0
|
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
|
require Logger
|
||||||
|
|
||||||
|
alias VavPipeline.BigdeiKodesh.Gate
|
||||||
|
|
||||||
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
|
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
|
||||||
# replaces Plug.Logger; sanitized paths on /peah/* honor the
|
# replaces Plug.Logger; sanitized paths on /peah/* honor the
|
||||||
# no-tracking promise made in /peah/ask's own response body.
|
# no-tracking promise made in /peah/ask's own response body.
|
||||||
@@ -128,21 +130,35 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# === COURTYARD — Authenticated ===
|
# === 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
|
{:error, :tier_too_low} ->
|
||||||
case get_req_header(conn, "authorization") do
|
send_error(conn, 403, "FORBIDDEN", %{
|
||||||
["Bearer " <> _token] ->
|
issue: "This key does not open the #{tier}",
|
||||||
process_courtyard(conn)
|
correction: "Use a key issued for the #{tier} tier",
|
||||||
|
resubmit: false
|
||||||
|
})
|
||||||
|
|
||||||
_ ->
|
{:error, _} ->
|
||||||
send_error(conn, 401, "UNAUTHORIZED", %{
|
send_error(conn, 401, "UNAUTHORIZED", %{
|
||||||
issue: "Missing or invalid Authorization header",
|
issue: "Missing or invalid API key",
|
||||||
correction: "Include 'Authorization: Bearer <api-key>' header",
|
correction: "Include 'Authorization: Bearer <api-key>' with a key issued by the kohen",
|
||||||
resubmit: true
|
resubmit: true
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
post "/courtyard/offering" do
|
||||||
|
through_the_gate(conn, :courtyard, &process_courtyard/1)
|
||||||
|
end
|
||||||
|
|
||||||
# === COURTYARD — the bedikah (Construction §3.9) ===
|
# === COURTYARD — the bedikah (Construction §3.9) ===
|
||||||
#
|
#
|
||||||
# An external model's output, brought by hand to be examined against
|
# 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.
|
# engraved in L3. See VavPipeline.Bedikah.
|
||||||
|
|
||||||
post "/courtyard/bedikah" do
|
post "/courtyard/bedikah" do
|
||||||
case get_req_header(conn, "authorization") do
|
through_the_gate(conn, :courtyard, &process_bedikah/1)
|
||||||
["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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# === CHATZER — Full Access (Sanhedrin) ===
|
# === CHATZER — Full Access (Sanhedrin) ===
|
||||||
|
|
||||||
post "/chatzer/sanhedrin" do
|
post "/chatzer/sanhedrin" do
|
||||||
case get_req_header(conn, "authorization") do
|
through_the_gate(conn, :chatzer, &process_sanhedrin/1)
|
||||||
["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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# === KODESH — Operator Interface ===
|
# === KODESH — Operator Interface ===
|
||||||
@@ -954,6 +950,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
|
|||||||
defp error_type("CLOUD" <> _), do: "system_transition"
|
defp error_type("CLOUD" <> _), do: "system_transition"
|
||||||
defp error_type("HALT" <> _), do: "cardinal_sin_detected"
|
defp error_type("HALT" <> _), do: "cardinal_sin_detected"
|
||||||
defp error_type("UNAUTHORIZED"), do: "authentication_required"
|
defp error_type("UNAUTHORIZED"), do: "authentication_required"
|
||||||
|
defp error_type("FORBIDDEN"), do: "insufficient_tier"
|
||||||
defp error_type("TEIKU" <> _), do: "unresolvable"
|
defp error_type("TEIKU" <> _), do: "unresolvable"
|
||||||
defp error_type("LO_TOV"), do: "specification_mismatch"
|
defp error_type("LO_TOV"), do: "specification_mismatch"
|
||||||
defp error_type("CHATAT" <> _), do: "correction_needed"
|
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
|
# changes; the update is a deliberate operator commit. A bare
|
||||||
# hash-update commit (without a corresponding spec change in the
|
# hash-update commit (without a corresponding spec change in the
|
||||||
# same commit) is itself a drift signal.
|
# 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."
|
@doc "Return the list of files that constitute the category-grade specification."
|
||||||
@spec files() :: [String.t()]
|
@spec files() :: [String.t()]
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ defmodule VavPipeline.BedikahTest do
|
|||||||
|
|
||||||
defp post_bedikah(body, opts \\ []) do
|
defp post_bedikah(body, opts \\ []) do
|
||||||
conn = conn(:post, "/courtyard/bedikah", Jason.encode!(body)) |> put_req_header("content-type", "application/json")
|
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)
|
Router.call(conn, @opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ defmodule VavPipeline.BigdeiKodeshTest do
|
|||||||
conn =
|
conn =
|
||||||
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"}))
|
conn(:post, "/chatzer/sanhedrin", Jason.encode!(%{not_case: "wrong field"}))
|
||||||
|> put_req_header("content-type", "application/json")
|
|> 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)
|
|> Router.call(@opts)
|
||||||
|
|
||||||
assert conn.status == 422
|
assert conn.status == 422
|
||||||
@@ -306,7 +306,7 @@ defmodule VavPipeline.BigdeiKodeshTest do
|
|||||||
case: "Is cooking on Shabbat permitted for pikuach nefesh?"
|
case: "Is cooking on Shabbat permitted for pikuach nefesh?"
|
||||||
}))
|
}))
|
||||||
|> put_req_header("content-type", "application/json")
|
|> 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)
|
|> Router.call(@opts)
|
||||||
|
|
||||||
assert conn.status == 200
|
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