diff --git a/deploy/mint/README.md b/deploy/mint/README.md index 331d1fb..404b0eb 100644 --- a/deploy/mint/README.md +++ b/deploy/mint/README.md @@ -230,6 +230,12 @@ newer `anchors/anchors-*.txt` files aside before the next backup, or it will qua 2026-10-08, v8 and v9 on 2026-10-10). V8 has no down-migration, so the way back across it is the named backup restored under the `pre-v8v9` images. V9 alone does have one: run `app.pg_schema.V9_ROLLBACK` as the migration role in one transaction and deploy the V8 code (`docs/ledger_search.md`), which keeps every write made since. +### Per-tenant API keys (`jarvisctl keys`) + +`jarvisctl keys pepper-init|pepper-install-app|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status` issues and manages per-tenant API keys (schema v10; `docs/tenant_keys.md`). Tokens are written to a +mode-600 file under `secrets/issued-keys/` and never printed; the pepper (`secrets/key-pepper`) is created once and never overwritten or shown. Key authentication stays off until `JARVIS_KEY_TENANTS=1` is set +for the app (see `docs/tenant_keys.md`); issuing a key does not enable it. + ### Signatures (schema v7) Backup sets carry `.signatures.json` (the attestations, trust statements and blocks, for the PC witness) and the anchors carry diff --git a/deploy/mint/bin/jarvisctl b/deploy/mint/bin/jarvisctl index 28d5f7b..7efa339 100755 --- a/deploy/mint/bin/jarvisctl +++ b/deploy/mint/bin/jarvisctl @@ -22,6 +22,7 @@ jarvisctl seal [--force|--status] seal new history into Continuity Blocks now (or show the unsealed tail) attest status|sign|init-key|install-roots|verify signatures: the host-side signer, the trust roots, verification tenants list|seal|sign|status|verify per-tenant versions of seal / attest sign / verify (new; the timers still run the operator-only ones) + keys pepper-init|pepper-install-app|tenant-create|key-create|key-revoke|tenant-status|tenants|keys|status per-tenant API keys (RFC phase 1): issue, revoke, list; tokens go to a mode-600 file, never the screen replay state|receipt|receipts|check|verify Replay Contracts: replay the ledger at a point, receipts at sealed points, re-derive them backup take a backup set now prune [--dry-run] apply the retention policy @@ -62,6 +63,7 @@ case "$cmd" in seal) exec "$here/seal.sh" "$@" ;; attest) exec "$here/attest.sh" "$@" ;; tenants) exec "$here/tenants.sh" "$@" ;; + keys) exec "$here/keys.sh" "$@" ;; replay) exec "$here/replay.sh" "$@" ;; backup) exec "$here/backup.sh" "$@" ;; prune) exec "$here/prune.sh" "$@" ;; diff --git a/deploy/mint/bin/keys.sh b/deploy/mint/bin/keys.sh new file mode 100755 index 0000000..e6a9953 --- /dev/null +++ b/deploy/mint/bin/keys.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Per-tenant API keys (RFC phase 1) from the box: create tenants and keys, revoke, suspend, list. A thin, validating wrapper over `python -m app.tenant_keys`, which runs in the +# migrate container as the migrator role (the application role has no privilege on the control schema, and the public API cannot create or list keys or tenants). +# +# keys.sh pepper-init create secrets/key-pepper (random, mode 600); never overwrites; prints no value +# keys.sh tenant-create --label NAME a new tenant (prints its t_... id) +# keys.sh key-create --tenant t_<32 hex> --out NAME [--label L] [--scopes memory.read,memory.write] [--expires-days N] +# a new key; the TOKEN is written to secrets/issued-keys/NAME.token (mode 600, never overwritten) and is NEVER printed +# keys.sh key-revoke KEY_ID revoke one key (16 hex characters) +# keys.sh tenant-status --tenant t_ --status active|suspended|quiesced|restoring|deleting|deleted +# keys.sh tenants list tenants +# keys.sh keys [--tenant t_] list keys (never a hash) +# keys.sh pepper-install-app make the app's own copy of the pepper (secrets/key-pepper.app, owned by the app's uid 10001, mode 400); the operator's copy stays yours +# keys.sh status pepper present? app copy present and identical? is JARVIS_KEY_TENANTS set in the app env? how many tenants and keys +# +# Issuing a key does NOT turn key authentication on: the app only honours keys with JARVIS_KEY_TENANTS=1 and a pepper mounted for it (docs/tenant_keys.md). Every argument is +# validated before Docker is touched; a rejected combination exits 2. The pepper and the tokens are never printed, logged or put on a command line. +set -Eeuo pipefail +export LOG_NAME=keys +# shellcheck source=lib.sh +source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +usage() { sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' >&2; exit 2; } +bad() { echo "$*" >&2; exit 2; } +[ $# -ge 1 ] || usage +cmd="$1"; shift + +PEPPER_FILE="$SECRETS_DIR/key-pepper" +APP_PEPPER_FILE="$SECRETS_DIR/key-pepper.app" # what the APP mounts: its own copy, owned by uid 10001. This CLI always uses the operator copy above, which never changes owner. +KEYS_DIR="$SECRETS_DIR/issued-keys" +TENANT_RE='^t_[0-9a-f]{32}$' +KEYID_RE='^[0-9a-f]{16}$' +STATUSES=" active suspended quiesced restoring deleting deleted " + +tenant="" label="" scopes="" expires="" out="" status="" key_id="" have_label=0 +parse_opts() { + while [ $# -gt 0 ]; do + case "$1" in + --tenant) [ $# -ge 2 ] || bad "--tenant needs a value"; tenant="$2"; shift ;; + --label) [ $# -ge 2 ] || bad "--label needs a value"; label="$2"; have_label=1; shift ;; + --scopes) [ $# -ge 2 ] || bad "--scopes needs a value"; scopes="$2"; shift ;; + --expires-days) [ $# -ge 2 ] || bad "--expires-days needs a value"; expires="$2"; shift ;; + --out) [ $# -ge 2 ] || bad "--out needs a value"; out="$2"; shift ;; + --status) [ $# -ge 2 ] || bad "--status needs a value"; status="$2"; shift ;; + *) bad "unknown argument: $1" ;; + esac + shift + done +} +need_tenant() { [[ "$tenant" =~ $TENANT_RE ]] || bad "--tenant must be t_ followed by 32 lowercase hex characters"; } +no_label_newline() { case "$label" in *$'\n'*|*$'\r'*) bad "--label must be a single line" ;; esac; [ "${#label}" -le 80 ] || bad "--label is at most 80 characters"; } + +# the command inside the migrate container, as the host user so a token file belongs to the operator and not to uid 10001; the pepper is a read-only bind mount, never an env value +tk() { + "${COMPOSE[@]}" run --rm --no-deps -T --user "$(id -u):$(id -g)" \ + -v "$PEPPER_FILE:/run/key-pepper:ro" -v "$KEYS_DIR:/keys" -e JARVIS_KEY_PEPPER_FILE=/run/key-pepper \ + migrate python -m app.tenant_keys "$@" +} +need_pepper() { [ -f "$PEPPER_FILE" ] || die "no pepper yet: run jarvisctl keys pepper-init first"; [ "$(stat -c %a "$PEPPER_FILE")" = 600 ] || die "$PEPPER_FILE must be mode 600"; [ "$(stat -c %u "$PEPPER_FILE")" = "$(id -u)" ] || die "$PEPPER_FILE must belong to you (the app gets its own copy: jarvisctl keys pepper-install-app); never chown it"; [ -r "$PEPPER_FILE" ] || die "$PEPPER_FILE is not readable by you"; mkdir -p "$KEYS_DIR"; chmod 700 "$KEYS_DIR"; } + +case "$cmd" in + pepper-init) + parse_opts "$@"; [ -z "$tenant$label$scopes$expires$out$status" ] || bad "pepper-init takes no options" + [ ! -e "$PEPPER_FILE" ] || die "$PEPPER_FILE already exists: keys issued under it would stop verifying if it changed, so it is never overwritten" + mkdir -p "$SECRETS_DIR" + ( umask 077; head -c 48 /dev/urandom | base64 -w0 > "$PEPPER_FILE.tmp.$$" && echo >> "$PEPPER_FILE.tmp.$$" && mv -n "$PEPPER_FILE.tmp.$$" "$PEPPER_FILE" ) + rm -f "$PEPPER_FILE.tmp.$$" + chmod 600 "$PEPPER_FILE" + log INFO "pepper written to $PEPPER_FILE (mode 600, $(head -c -1 "$PEPPER_FILE" | wc -c) characters; the value is not shown). Back it up with the secrets: keys issued under it verify only with it." + ;; + pepper-install-app) + parse_opts "$@"; [ -z "$tenant$label$scopes$expires$out$status" ] || bad "pepper-install-app takes no options" + need_pepper + [ ! -e "$APP_PEPPER_FILE" ] || die "$APP_PEPPER_FILE already exists: it is never overwritten (delete it yourself to replace it; keys verify only under the pepper they were issued with)" + # a root container (the default) can read your file and chown the copy; the copy is the only thing that changes owner. Nothing about the value is passed on a command line. + docker run --rm --user 0 --read-only --cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER --network none --entrypoint sh \ + -v "$SECRETS_DIR:/s" "$APP_IMAGE" -c 'set -e; umask 077; cp /s/key-pepper /s/key-pepper.app; chown 10001:10001 /s/key-pepper.app; chmod 400 /s/key-pepper.app' \ + || die "could not create the app's copy of the pepper" + log INFO "app copy written to $APP_PEPPER_FILE (owner 10001, mode 400; the value is not shown). Mount THIS file into the app as JARVIS_KEY_PEPPER_FILE; never the operator copy." + ;; + tenant-create) + parse_opts "$@"; [ -z "$tenant$scopes$expires$out$status" ] || bad "tenant-create takes only --label" + [ "$have_label" -eq 1 ] && [ -n "$label" ] || bad "tenant-create needs --label NAME" + no_label_newline; need_pepper + tk create-tenant --label "$label" + ;; + key-create) + parse_opts "$@"; [ -z "$status" ] || bad "key-create has no --status" + need_tenant; no_label_newline + [[ "$out" =~ ^[A-Za-z0-9._-]{1,64}$ ]] && [ "$out" != . ] && [ "$out" != .. ] || bad "--out NAME is required (letters, digits, . _ - only; the token is never printed)" + if [ -n "$scopes" ]; then [[ "$scopes" =~ ^(memory\.read|memory\.write)(,(memory\.read|memory\.write))*$ ]] || bad "--scopes must be memory.read and/or memory.write, comma-separated"; fi + if [ -n "$expires" ]; then [[ "$expires" =~ ^[0-9]+(\.[0-9]+)?$ ]] || bad "--expires-days must be a non-negative number (0 expires the key now)"; fi + need_pepper + [ ! -e "$KEYS_DIR/$out.token" ] || die "$KEYS_DIR/$out.token already exists: a token file is never overwritten" + args=(create-key --tenant "$tenant" --token-file "/keys/$out.token") + [ "$have_label" -eq 0 ] || args+=(--label "$label") + [ -z "$scopes" ] || args+=(--scopes "$scopes") + [ -z "$expires" ] || args+=(--expires-days "$expires") + tk "${args[@]}" + ;; + key-revoke) + [ $# -eq 1 ] || bad "key-revoke takes exactly one KEY_ID" + [[ "$1" =~ $KEYID_RE ]] || bad "KEY_ID must be 16 lowercase hex characters (the part after jlk_, never the whole token)" + need_pepper; tk revoke-key "$1" + ;; + tenant-status) + parse_opts "$@"; [ -z "$label$scopes$expires$out" ] || bad "tenant-status takes only --tenant and --status" + need_tenant + case "$STATUSES" in *" $status "*) ;; *) bad "--status must be one of:${STATUSES}" ;; esac + need_pepper; tk set-status --tenant "$tenant" --status "$status" + ;; + tenants) + [ $# -eq 0 ] || bad "tenants takes no options" + need_pepper; tk list-tenants + ;; + keys) + parse_opts "$@"; [ -z "$label$scopes$expires$out$status" ] || bad "keys takes only --tenant" + [ -z "$tenant" ] || need_tenant + need_pepper + if [ -n "$tenant" ]; then tk list-keys --tenant "$tenant"; else tk list-keys; fi + ;; + status) + [ $# -eq 0 ] || bad "status takes no options" + if [ -f "$PEPPER_FILE" ]; then echo "pepper: present ($(stat -c %a "$PEPPER_FILE"))"; else echo "pepper: absent (jarvisctl keys pepper-init)"; fi + if [ -f "$APP_PEPPER_FILE" ]; then + if [ -f "$PEPPER_FILE" ] && [ "$(docker run --rm --user 0 --read-only --cap-drop ALL --cap-add DAC_OVERRIDE --network none --entrypoint sh -v "$SECRETS_DIR:/s:ro" "$APP_IMAGE" -c 'cmp -s /s/key-pepper /s/key-pepper.app && echo same || echo different' 2>/dev/null)" = same ]; then echo "app pepper copy: present, identical to the operator copy" + else echo "app pepper copy: present, but NOT confirmed identical to the operator copy (keys would not verify in the app)"; fi + else echo "app pepper copy: absent (jarvisctl keys pepper-install-app)"; fi + # as the app reads it: the LAST assignment wins (compose env_file); a quoted value is what is inside the quotes (anything after the closing quote is a comment); an unquoted value ends + # at a space-prefixed "#" (compose inline comment); key_tenants_enabled() lowercases (1/true/yes/on) + flag="$(grep -E '^[[:space:]]*JARVIS_KEY_TENANTS[[:space:]]*=' "$SECRETS_DIR/app.env" 2>/dev/null | tail -1 | sed -E 's/^[^=]*=//; s/^[[:space:]]+//; /^"/{s/^"([^"]*)".*$/\1/;b done}; /^'"'"'/{s/^'"'"'([^'"'"']*)'"'"'.*$/\1/;b done}; s/[[:space:]]+#.*$//; :done; s/^[[:space:]]+//; s/[[:space:]]+$//' | tr '[:upper:]' '[:lower:]' || true)" + case "$flag" in 1|true|yes|on) echo "key authentication: ON in secrets/app.env" ;; *) echo "key authentication: off (JARVIS_KEY_TENANTS not enabled in secrets/app.env)" ;; esac + if [ -f "$PEPPER_FILE" ]; then + # a failed query is reported as unavailable (and fails the command), never as an empty deployment + rc=0 + if t="$(tk list-tenants 2>/dev/null)"; then echo "tenants: $(printf '%s\n' "$t" | grep -c '^t_' || true)"; else echo "tenants: unavailable (the control schema could not be queried)"; rc=1; fi + if k="$(tk list-keys 2>/dev/null)"; then echo "keys: $(printf '%s\n' "$k" | grep -c '^[0-9a-f]\{16\}' || true)"; else echo "keys: unavailable (the control schema could not be queried)"; rc=1; fi + exit "$rc" + fi + ;; + *) usage ;; +esac diff --git a/docs/tenant_keys.md b/docs/tenant_keys.md index 2558e9f..ee430ac 100644 --- a/docs/tenant_keys.md +++ b/docs/tenant_keys.md @@ -46,6 +46,31 @@ python -m app.tenant_keys set-status --tenant t_ --status suspended python -m app.tenant_keys list-tenants | list-keys [--tenant t_] ``` +### On the box: `jarvisctl keys` + +`jarvisctl keys ...` wraps the commands above for the box. It runs the same CLI in the migrate container as the migrator role, as the host user, with the pepper mounted read-only (never passed as a value), and +**never prints a token**: `key-create` requires `--out NAME` and writes `secrets/issued-keys/NAME.token` (mode 600, directory 700; an existing file is never overwritten). Every argument is validated before Docker is touched. + +``` +jarvisctl keys pepper-init # once: secrets/key-pepper, random, mode 600, never overwritten, value not shown. Back it up with the secrets: keys verify only with it. +jarvisctl keys pepper-install-app # the app's own copy (owner 10001, mode 400) for step 1 of enabling; the operator copy never changes owner +jarvisctl keys tenant-create --label alice # prints the new t_... id +jarvisctl keys key-create --tenant t_... --out alice-agent [--label L] [--scopes memory.read,memory.write] [--expires-days N] +jarvisctl keys key-revoke KEY_ID # the 16 hex characters after jlk_ +jarvisctl keys tenant-status --tenant t_... --status suspended +jarvisctl keys tenants | keys [--tenant t_...] | status +``` + +Issuing a key does **not** turn key authentication on. To enable it: + +1. `jarvisctl keys pepper-init` (once), then `jarvisctl keys pepper-install-app`. The operator's pepper (`secrets/key-pepper`) stays yours, mode 600, and is the one `jarvisctl keys` always uses, so + `key-revoke` keeps working after keys are on. The app gets its **own copy** (`secrets/key-pepper.app`, owner uid 10001, mode 400, made in a throwaway container; the value is never shown). Never `chown` + the operator copy: the management commands run as you and could no longer read it. `jarvisctl keys status` says whether the two copies are identical; they must be, or issued keys will not verify. +2. Mount **the app copy** (only that file) into the app container as `JARVIS_KEY_PEPPER_FILE`, set `JARVIS_KEY_TENANTS=1` in `secrets/app.env`, recreate the app only, and probe a key before announcing + it (the 2026-10-17 drill is the template). + +Until then a `jlk_` token is an ordinary wrong key (401). `tests/test_deploy_keys.py` pins the wrapper. + The token exists in exactly one place: the output of `create-key`. Prefer `--token-file` (mode 600, never overwrites an existing file) so it never appears on a terminal. Listing never shows a hash. Pinned by `tests/test_tenant_keys.py` (privileges, the constraints, round trip, uniform failure, expiry, revocation, the comparison on unknown ids, rate-limited `last_used_at`, the CLI, rollback and forward again). @@ -75,4 +100,4 @@ request parameter. The operator's ledger key keeps working exactly as before (th properties are proven for both kinds of tenant identity, and a route added without a matrix classification fails there too. The few routes that answer a request with no credential in a shared-key deployment (they carry no ledger data and behave the same on the box today) are named in that file, not hidden. -Not in this slice: `jarvisctl` wrappers for issuing keys, per-tenant call-log chains, and recording the credential in the history hash. +Not in this slice: per-tenant call-log chains, and recording the credential in the history hash. diff --git a/tests/test_deploy_keys.py b/tests/test_deploy_keys.py new file mode 100644 index 0000000..c299ece --- /dev/null +++ b/tests/test_deploy_keys.py @@ -0,0 +1,223 @@ +"""jarvisctl keys: the box-side wrapper for per-tenant API keys (RFC phase 1). What it validates before Docker is touched, that the pepper and the tokens are never printed or put on a +command line, that a pepper or a token file is never overwritten, and that issuing a key does not switch key authentication on.""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +MINT = ROOT / "deploy" / "mint" +TENANT = "t_" + "ab" * 16 +KEY_ID = "0123456789abcdef" + + +@pytest.fixture +def box(tmp_path): + d = tmp_path / "mint" + shutil.copytree(MINT, d, ignore=shutil.ignore_patterns(".env", "secrets")) + (d / "secrets").mkdir() + fake = tmp_path / "bin" + fake.mkdir() + log = tmp_path / "docker.log" + # a docker that records every argument (one line per call) and what the container would see in the environment, and answers like the CLI does + (fake / "docker").write_text('#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_DOCKER_LOG"\ncase "$*" in *create-key*) echo "key 0123456789abcdef issued; token written to /keys/x.token" ;; *list-tenants*) echo "t_x active a" ;; *"cmp -s"*) echo same ;; esac\nexit ${FAKE_DOCKER_RC:-0}\n') + (fake / "docker").chmod(0o755) + env = dict(os.environ, PATH=f"{fake}:{os.environ['PATH']}", JARVIS_HOME=str(tmp_path / "home"), FAKE_DOCKER_LOG=str(log), HOME=str(tmp_path)) + + class Box: + dir, secrets, env_, log_ = d, d / "secrets", env, log + + def run(self, *args): + return subprocess.run([str(d / "bin" / "keys.sh"), *args], env=env, capture_output=True, text=True, timeout=60) + + def docker_calls(self): + return log.read_text().splitlines() if log.exists() else [] + + return Box() + + +def with_pepper(box): + r = box.run("pepper-init") + assert r.returncode == 0, r.stderr + return (box.secrets / "key-pepper").read_text().strip() + + +BAD = [ + ("tenant-create",), ("tenant-create", "--label"), ("tenant-create", "--label", "x", "--tenant", TENANT), + ("key-create", "--tenant", TENANT), ("key-create", "--tenant", "operator", "--out", "a"), ("key-create", "--tenant", TENANT.upper(), "--out", "a"), + ("key-create", "--tenant", "t_" + "ab" * 15, "--out", "a"), ("key-create", "--tenant", TENANT, "--out", "../x"), ("key-create", "--tenant", TENANT, "--out", "a/b"), + ("key-create", "--tenant", TENANT, "--out", ".."), ("key-create", "--tenant", TENANT, "--out", "a b"), + ("key-create", "--tenant", TENANT, "--out", "a", "--scopes", "admin"), ("key-create", "--tenant", TENANT, "--out", "a", "--scopes", "memory.read,"), + ("key-create", "--tenant", TENANT, "--out", "a", "--expires-days", "-1"), ("key-create", "--tenant", TENANT, "--out", "a", "--expires-days", "soon"), + ("key-create", "--tenant", TENANT, "--out", "a", "--status", "active"), ("key-create", "--tenant", TENANT, "--out", "a", "--label", "x" * 81), + ("key-create", "--tenant", TENANT, "--out", "a", "--label", "two\nlines"), + ("key-revoke",), ("key-revoke", "jlk_" + KEY_ID + "_" + "A" * 43), ("key-revoke", KEY_ID.upper()), ("key-revoke", KEY_ID, "extra"), + ("tenant-status", "--tenant", TENANT), ("tenant-status", "--tenant", TENANT, "--status", "gone"), ("tenant-status", "--tenant", "x", "--status", "active"), + ("tenants", "--all"), ("keys", "--tenant", "nope"), ("status", "x"), ("pepper-init", "--out", "x"), ("nonsense",), ("keys", "--bogus"), +] + + +@pytest.mark.parametrize("args", BAD, ids=lambda a: " ".join(a)[:70].replace("\n", "\\n")) +def test_a_bad_argument_is_refused_before_docker_is_touched(box, args): + with_pepper(box) + r = box.run(*args) + assert r.returncode == 2, (r.returncode, r.stderr) + assert box.docker_calls() == [] + + +def test_pepper_init_writes_a_strong_mode_600_secret_and_prints_none_of_it(box): + r = box.run("pepper-init") + assert r.returncode == 0 + p = box.secrets / "key-pepper" + value = p.read_text().strip() + assert stat.S_IMODE(p.stat().st_mode) == 0o600 and len(value) >= 32 + assert value not in r.stdout + r.stderr + assert not list(box.secrets.glob("key-pepper.tmp*")) + + +def test_the_pepper_is_never_overwritten(box): + first = with_pepper(box) + r = box.run("pepper-init") + assert r.returncode == 1 and "already exists" in r.stderr + assert (box.secrets / "key-pepper").read_text().strip() == first + + +@pytest.mark.parametrize("args", [("tenant-create", "--label", "alice"), ("tenants",), ("keys",), ("key-revoke", KEY_ID), + ("key-create", "--tenant", TENANT, "--out", "alice")]) +def test_nothing_runs_without_a_pepper(box, args): + r = box.run(*args) + assert r.returncode == 1 and "pepper-init" in r.stderr + assert box.docker_calls() == [] + + +def test_the_pepper_must_be_mode_600(box): + with_pepper(box) + (box.secrets / "key-pepper").chmod(0o640) + r = box.run("tenants") + assert r.returncode == 1 and "600" in r.stderr and box.docker_calls() == [] + + +def test_key_create_hands_the_cli_a_token_file_and_a_read_only_pepper_mount_and_never_the_values(box): + pepper = with_pepper(box) + r = box.run("key-create", "--tenant", TENANT, "--out", "alice", "--label", "alice's agent", "--scopes", "memory.read", "--expires-days", "30") + assert r.returncode == 0, r.stderr + (call,) = box.docker_calls() + for needle in ("create-key", f"--tenant {TENANT}", "--token-file /keys/alice.token", "--scopes memory.read", "--expires-days 30", "migrate python -m app.tenant_keys", + "-e JARVIS_KEY_PEPPER_FILE=/run/key-pepper", f"{box.secrets}/key-pepper:/run/key-pepper:ro", f"--user {os.getuid()}:{os.getgid()}"): + assert needle in call, needle + assert pepper not in call and pepper not in r.stdout + r.stderr + assert "jlk_" not in call + r.stdout + r.stderr + assert (box.secrets / "issued-keys").is_dir() and stat.S_IMODE((box.secrets / "issued-keys").stat().st_mode) == 0o700 + + +def test_an_existing_token_file_is_never_overwritten_and_the_cli_is_not_even_called(box): + with_pepper(box) + keys = box.secrets / "issued-keys" + keys.mkdir() + (keys / "alice.token").write_text("precious\n") + r = box.run("key-create", "--tenant", TENANT, "--out", "alice") + assert r.returncode == 1 and "never overwritten" in r.stderr + assert (keys / "alice.token").read_text() == "precious\n" and box.docker_calls() == [] + + +def test_the_other_commands_pass_exactly_their_arguments(box): + with_pepper(box) + assert box.run("tenant-create", "--label", "alice").returncode == 0 + assert box.run("key-revoke", KEY_ID).returncode == 0 + assert box.run("tenant-status", "--tenant", TENANT, "--status", "suspended").returncode == 0 + assert box.run("tenants").returncode == 0 + assert box.run("keys", "--tenant", TENANT).returncode == 0 + calls = box.docker_calls() + assert [c.split("app.tenant_keys ", 1)[1] for c in calls] == ["create-tenant --label alice", f"revoke-key {KEY_ID}", f"set-status --tenant {TENANT} --status suspended", + "list-tenants", f"list-keys --tenant {TENANT}"] + + +def test_status_reports_whether_key_authentication_is_on_and_issuing_a_key_does_not_turn_it_on(box): + r = box.run("status") + assert r.returncode == 0 and "pepper: absent" in r.stdout and "key authentication: off" in r.stdout + with_pepper(box) + (box.secrets / "app.env").write_text("JARVIS_API_KEY=secret-value-that-must-not-be-echoed\n") + box.run("key-create", "--tenant", TENANT, "--out", "alice") + assert "JARVIS_KEY_TENANTS" not in (box.secrets / "app.env").read_text() + r = box.run("status") + assert "key authentication: off" in r.stdout and "secret-value" not in r.stdout + r.stderr + (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=1\n") + assert "key authentication: ON" in box.run("status").stdout + + +def test_jarvisctl_dispatches_keys_and_lists_it_in_help(box): + ctl = str(box.dir / "bin" / "jarvisctl") + help_text = subprocess.run([ctl, "help"], env=box.env_, capture_output=True, text=True).stdout + assert "keys pepper-init|pepper-install-app|tenant-create|key-create" in help_text + r = subprocess.run([ctl, "keys", "nonsense"], env=box.env_, capture_output=True, text=True) + assert r.returncode == 2 and "keys.sh" in r.stderr + + +@pytest.mark.parametrize("line,expected", [("JARVIS_KEY_TENANTS=1", "ON"), ("JARVIS_KEY_TENANTS=TRUE", "ON"), ("JARVIS_KEY_TENANTS=Yes", "ON"), ("JARVIS_KEY_TENANTS=on", "ON"), + ("JARVIS_KEY_TENANTS= 1 ", "ON"), ('JARVIS_KEY_TENANTS="true"', "ON"), ("JARVIS_KEY_TENANTS='ON'", "ON"), + ("JARVIS_KEY_TENANTS=0", "off"), ("JARVIS_KEY_TENANTS=false", "off"), ("JARVIS_KEY_TENANTS=", "off"), ("JARVIS_KEY_TENANTS=maybe", "off"), + ("JARVIS_KEY_TENANTS=1 # enable tenant keys", "ON"), ('JARVIS_KEY_TENANTS="true" # enable', "ON"), ("JARVIS_KEY_TENANTS=0 # was 1", "off"), ("JARVIS_KEY_TENANTS=1#not-a-comment", "off"), + ("JARVIS_KEY_TENANTS='on' # x # y", "ON"), ("# JARVIS_KEY_TENANTS=1", "off"), ("XJARVIS_KEY_TENANTS=1", "off")]) +def test_status_reads_the_flag_the_way_the_application_does(box, line, expected): + """Codex on #115: key_tenants_enabled() strips and lowercases, so status must not report 'off' for a value the app accepts (nor 'ON' for one it rejects).""" + (box.secrets / "app.env").write_text("JARVIS_API_KEY=x\n" + line + "\n") + out = box.run("status").stdout + assert f"key authentication: {expected}" in out, out + + +def test_status_uses_the_last_assignment_like_compose(box): + (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=1\nJARVIS_KEY_TENANTS=0\n") + assert "key authentication: off" in box.run("status").stdout + (box.secrets / "app.env").write_text("JARVIS_KEY_TENANTS=0\nJARVIS_KEY_TENANTS=true\n") + assert "key authentication: ON" in box.run("status").stdout + + +def test_status_does_not_turn_a_failed_query_into_an_empty_deployment(box): + """Codex on #115: an unavailable control plane must not read as 'tenants: 0 / keys: 0'.""" + with_pepper(box) + box.env_["FAKE_DOCKER_RC"] = "1" + r = box.run("status") + assert r.returncode == 1 and "tenants: unavailable" in r.stdout and "keys: unavailable" in r.stdout + assert "tenants: 0" not in r.stdout and "keys: 0" not in r.stdout + box.env_["FAKE_DOCKER_RC"] = "0" + r = box.run("status") + assert r.returncode == 0 and "tenants: 1" in r.stdout + + +def test_pepper_install_app_makes_a_separate_app_owned_copy_and_never_touches_the_operator_copy(box): + """Codex (P1) on #115: the app needs the pepper owned by uid 10001, and the operator CLI must keep reading ITS copy as the host user (key-revoke must work once keys are on).""" + pepper = with_pepper(box) + r = box.run("pepper-install-app") + assert r.returncode == 0, r.stderr + (call,) = box.docker_calls() + for needle in ("run --rm --user 0", "--network none", "--read-only", f"-v {box.secrets}:/s", "chown 10001:10001 /s/key-pepper.app", "chmod 400 /s/key-pepper.app", "cp /s/key-pepper /s/key-pepper.app"): + assert needle in call, needle + assert pepper not in call + r.stdout + r.stderr + assert (box.secrets / "key-pepper").read_text().strip() == pepper and stat.S_IMODE((box.secrets / "key-pepper").stat().st_mode) == 0o600 + # and the management commands still run as the host user against the operator copy, never the app copy + box.run("key-revoke", KEY_ID) + revoke = box.docker_calls()[-1] + assert f"--user {os.getuid()}:{os.getgid()}" in revoke and f"{box.secrets}/key-pepper:/run/key-pepper:ro" in revoke and "key-pepper.app" not in revoke + + +def test_the_app_copy_is_never_overwritten_and_needs_the_operator_pepper_first(box): + r = box.run("pepper-install-app") + assert r.returncode == 1 and "pepper-init" in r.stderr and box.docker_calls() == [] + with_pepper(box) + (box.secrets / "key-pepper.app").write_text("existing\n") + r = box.run("pepper-install-app") + assert r.returncode == 1 and "already exists" in r.stderr and box.docker_calls() == [] + assert (box.secrets / "key-pepper.app").read_text() == "existing\n" + + +def test_status_reports_the_app_copy(box): + with_pepper(box) + assert "app pepper copy: absent" in box.run("status").stdout + (box.secrets / "key-pepper.app").write_text("x\n") + assert "app pepper copy: present, identical" in box.run("status").stdout # the fake docker answers `same`