# Authentication and access control ## Goal Say exactly which access schemes the servers implement, who "the client" is at each layer, where secrets live and how they rotate — and name the integrations that do not exist, so no operator plans around them. ## Scope Components: `law-mcp-server --http` (`law-mcp/src/http.rs`, `src/main.rs`) and `law serve` (`law-serve-core/src/service.rs`, `law-bin-core/src/serve.rs`). Scenario: a private or public HTTP deployment. All tokens, users, and paths below are synthetic created-examples. "Implemented / described / verified / supported" follow the binding section vocabulary in [Verification and support matrix](/operate/verification-support-matrix/#section-vocabulary-binding); "verified absence" means a code search found no such mechanism. ## Applies to | Branch | Coverage in this article | |---|---| | MCP (`law-mcp-server --http`) | token check, public-bind refusal, rotation procedure | | `law serve` | token check via `--token-file`, same rotation shape (replace file, restart) | Both branches share the limits below (one token per process, no IdP, no per-user rights). ## Prerequisites - A running instance from [Deploy a private HTTP service](/operate/private-http-service/) (`law serve`) or [HTTP, TLS and reverse proxies](/operate/http-tls-reverse-proxies/) (MCP). - A token value the operator generated (created-example `REDACTED_EXAMPLE_TOKEN_32` — not a real secret). ## Actually supported schemes There are exactly three, and they are the same on both servers: | # | Scheme | MCP setting | `law serve` setting | Effect | |---|---|---|---|---| | 1 | Bearer token | `LAW_MCP_TOKEN` / `--token` | `--token` / `--token-file` | every request must carry `Authorization: Bearer `, exact match; otherwise 401 | | 2 | Loopback-implicit | bind `127.0.0.1`/`::1`/`localhost` with no token | same | no `Authorization` needed; the bind address is the control | | 3 | Explicit public | `LAW_MCP_PUBLIC=1` / `--public` | `--public` | deliberate unauthenticated bind on any address | Details verified in code: - MCP 401 responses carry `WWW-Authenticate: Bearer resource="law-mcp"`. `law serve` answers 401 with code `SERVE_UNAUTHORIZED` ("missing or wrong `Authorization: Bearer`"). - Scheme 1 vs 2 vs 3 is chosen at startup; a non-loopback bind with no token and no public flag refuses to start (exit 2) on both servers — an open evaluator never comes up by typo. - `law serve` forbids `--token` together with `--token-file`, and refuses an empty token file. MCP has no `--token-file`: the secret arrives via environment (see Storage below). - There is exactly one token per process: no per-client, per-tool, or per-case credentials exist in code. ## Client identity vs operation right vs case right | Layer | Question it answers | Mechanism | Strength | |---|---|---|---| | Client identity | *which caller is this?* | none. The journal records `MCP_CLIENT` (first `X-Forwarded-For` element, else socket peer) for observability only; it enforces nothing | not an identity — trusted-by-layout logging | | Operation right | *may this caller use the instance?* | the Bearer token (scheme 1), the loopback bind (scheme 2), or explicit public (scheme 3) | the only enforced check | | Case right | *may this caller see case X / predicate Y?* | none. Any caller passing the operation check may call any allowed-facet tool with arbitrary facts | not a boundary — do not rely on one | Consequences: two tenants sharing one token share everything the profile serves; callers are distinguishable only in logs, never in authorization. Tenant separation is per-process (separate ports, tokens, profiles), which is how the shipped inventory runs one instance per slice. ## Steps: token-protect a loopback instance MCP (real flags, synthetic values; one shell variable is the single source for both the server and the positive probe, so they cannot drift apart): ```bash TOKEN='REDACTED_EXAMPLE_TOKEN_32' LAW_MCP_TOKEN="$TOKEN" law-mcp-server --http --port 8722 & curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8722/mcp \ -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' curl -s http://127.0.0.1:8722/mcp -H 'content-type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' ``` `law serve` (real flags, synthetic paths): ```bash # Private directory first: a fixed /tmp file inherits umask (0644 at # umask 022), keeps the mode of a pre-existing file, and follows a # planted symlink. mktemp -d is atomic and 0700; the token file is # created fresh inside it with umask 077, so it is 0600. TOKDIR=$(mktemp -d /tmp/serve-token.XXXXXX) || exit 1 [ -n "$TOKDIR" ] && [ -d "$TOKDIR" ] || { echo "mktemp failed" >&2; exit 1; } (umask 077; printf '%s' 'REDACTED_EXAMPLE_TOKEN_32' > "$TOKDIR/token") ls -l "$TOKDIR/token" # expect: -rw------- (600) [ -f "$TOKDIR/token" ] || { echo "token file missing" >&2; exit 1; } law serve --world /srv/cases/kompaniya --journal /srv/cases/kompaniya-journal \ --token-file "$TOKDIR/token" & SERVE_PID=$! # Remove the directory only after the server stops: the running # process holds the path. The trap covers Ctrl-C and errors; a # normal stop path kills the server first, then cleans up. cleanup_tokdir() { kill "$SERVE_PID" 2>/dev/null; wait "$SERVE_PID" 2>/dev/null; rm -rf "$TOKDIR"; } trap cleanup_tokdir EXIT INT TERM ``` ## Expected result - The first MCP curl prints `401`; the second returns HTTP 200 with the `ping` result. - `law serve` starts only with `--token`/`--token-file` on a non-loopback bind; with the file in place it serves after workers warm up. ## Result check - Wrong token → 401; no token where one is configured → 401. - Restart the process without the token on loopback → requests pass (scheme 2); on `0.0.0.0` → startup refusal (exit 2). - Unit-level negative test (private template): point the private unit at an `EnvironmentFile` with the token line removed and start it — `ExecStartPre` fails and the service never listens (the binary alone would serve on loopback, so the guard, not the bind address, enforces "no token, no service"). Binary-level proof of the deeper rule (verified): `--host 0.0.0.0` with neither token nor public flag exits 2 with the refusal naming `LAW_MCP_TOKEN`. The unit adds no `PUBLIC` line that could downgrade this refusal into an open bind (`check-conformance.sh` asserts the absence statically). - The token value must never appear in `ps` output when passed via env/file; `--token` on a command line is visible — prefer the file. ## Secret storage and rotation Reference-setting (kit unit `kit/secure-deploy/law-mcp-private@.service` — the private template, which contains no `PUBLIC` line): - Per-instance secrets live in `/etc/arxo/mcp-.env` (the unit's `EnvironmentFile`, mode 600). Files are root-owned; the process runs as `User=arxo-mcp` with `NoNewPrivileges=true`. - Rotation is replace-then-restart: the token is read once at startup, so editing the file alone changes nothing. Restart is scoped by sudoers to exactly `/usr/bin/systemctl restart law-mcp-private@` for the deploy user. - Fail-closed rule: with no usable token in that file, the private unit must not serve — the process exits instead of opening up (negative test in Result check). The shipped public unit (with `LAW_MCP_PUBLIC=1`) is a separate deliberate choice, never the fallback for a missing secret. Rotation procedure (real commands, synthetic port and token). The env file holds more than the token, so update only the token line — never truncate the file. One helper does the whole edit so a read or write failure aborts before the live file is touched; the old token needed for the post-check is staged before activation (a staging failure aborts, never warns-and-continues); its only output names the changed key, never a value. The tested source is `kit/bin/arxo-rotate-token` next to this page (run `sh kit/tests/test-rotate-token.sh`); install that exact file once as `/usr/local/sbin/arxo-rotate-token` (root-owned, mode 755; Linux coreutils for `--reference`): Supported env shape (narrow, enforced by the helper): exactly one `KEY=value` line for the rotated key, with the value using only `A-Z a-z 0-9 . _ ~ -` — no quoting, no whitespace, no backslashes. A quoted systemd value such as `KEY="abc"` is valid for systemd but is refused here: normalize it by hand before rotating, or the helper aborts without touching the file. Every pre-activation check must exit successfully; a failed read is never treated as "no differences". ```sh #!/bin/sh # Usage: printf '%s\n' "$NEW_TOKEN" | sudo arxo-rotate-token # # Rotates one KEY=value line in a systemd EnvironmentFile, changing # nothing else. Supported file format (narrow, enforced below): # - exactly one line starting with "KEY="; # - the value uses only A-Z a-z 0-9 . _ ~ - (no quoting, no spaces, # no backslashes, no empty continuation); # - every other line is preserved byte-identically. # Anything outside this shape aborts before the live file is touched. # The previous value is staged for the post-check BEFORE activation; # a staging failure aborts the rotation (never warns-and-continues). # Needs Linux coreutils for chmod/chown --reference. set -u ENV=$1; KEY=$2 [ -f "$ENV" ] || { echo "rotate: not a file: $ENV" >&2; exit 2; } [ -r "$ENV" ] || { echo "rotate: cannot read $ENV" >&2; exit 2; } case $KEY in ''|*[!A-Za-z0-9_]*) echo "rotate: bad key name: $KEY" >&2; exit 2 ;; esac case $KEY in [0-9]*) echo "rotate: bad key name: $KEY" >&2; exit 2 ;; esac IFS= read -r NEW || { echo "rotate: no token on stdin" >&2; exit 2; } case $NEW in ''|*[!A-Za-z0-9._~-]*) echo "rotate: bad token (empty or outside A-Z a-z 0-9 . _ ~ -)" >&2 exit 2 ;; esac # Exactly one KEY= line; its value must already be in the narrow # alphabet (a quoted systemd value is valid for systemd but NOT # supported here — normalize it by hand first). OLD_LINE='' KEY_COUNT=0 while IFS= read -r line || [ -n "$line" ]; do case $line in "$KEY="*) OLD_LINE=$line; KEY_COUNT=$((KEY_COUNT + 1)) ;; esac done < "$ENV" [ "$KEY_COUNT" -eq 1 ] || { echo "rotate: want exactly one $KEY line, found $KEY_COUNT" >&2; exit 2; } OLD_VAL=${OLD_LINE#*=} case $OLD_VAL in ''|*[!A-Za-z0-9._~-]*) echo "rotate: unsupported $KEY value (quoting or outside A-Z a-z 0-9 . _ ~ -); normalize manually" >&2 exit 2 ;; esac NEW_LINE="$KEY=$NEW" [ "$NEW_LINE" != "$OLD_LINE" ] || { echo "rotate: new equals old; abort" >&2; exit 2; } # Stage the genuinely-previous token BEFORE activation: if this fails, # the rotation aborts with the live file untouched, instead of # reporting success without the value the post-check needs. # ARXO_ROTATE_STAGED overrides the path for tests only. STAGED="${ARXO_ROTATE_STAGED:-/run/arxo-rotated-out-$(basename "$ENV")}" TMP="" SUCCESS=0 cleanup() { if [ -n "$TMP" ]; then rm -f "$TMP"; fi if [ "$SUCCESS" != 1 ]; then rm -f "$STAGED"; fi } trap 'cleanup' EXIT INT TERM umask 077 # One terminated line: the post-check reads the staged value with # `mkheader` (`IFS= read -r`), which only succeeds on a completed # line. Bare bytes without the newline would fail the check, so the # writer — not the reader — owns the format. printf '%s\n' "$OLD_VAL" > "$STAGED" \ || { echo "rotate: cannot stage old token; abort" >&2; exit 2; } [ -s "$STAGED" ] || { echo "rotate: staged old token empty; abort" >&2; exit 2; } DIR=$(dirname "$ENV") TMP=$(mktemp "$DIR/.rotate.XXXXXX") || { echo "rotate: mktemp failed" >&2; exit 2; } while IFS= read -r line || [ -n "$line" ]; do case $line in "$KEY="*) ;; *) printf '%s\n' "$line" >> "$TMP" ;; esac done < "$ENV" printf '%s\n' "$NEW_LINE" >> "$TMP" || { echo "rotate: write failed" >&2; exit 2; } # Fail-closed backstop: everything except the rotated line must be # byte-identical. grep may exit 1 when nothing matches (env holding # only the key line); that is success with empty output. Any other # non-zero status is a failed check and aborts before mv. old_rest=$(grep -v "^$KEY=" "$ENV"); old_st=$? [ "$old_st" -eq 0 ] || [ "$old_st" -eq 1 ] || { echo "rotate: verify read failed ($old_st)" >&2; exit 2; } new_rest=$(grep -v "^$KEY=" "$TMP"); new_st=$? [ "$new_st" -eq 0 ] || [ "$new_st" -eq 1 ] || { echo "rotate: verify read failed ($new_st)" >&2; exit 2; } [ "$old_rest" = "$new_rest" ] || { echo "rotate: non-token content differs; abort" >&2; exit 2; } grep -q "^$KEY=" "$TMP" || { echo "rotate: new key line missing; abort" >&2; exit 2; } chmod --reference="$ENV" "$TMP" || { echo "rotate: chmod failed" >&2; exit 2; } chown --reference="$ENV" "$TMP" || { echo "rotate: chown failed" >&2; exit 2; } mv "$TMP" "$ENV" || { echo "rotate: activate failed" >&2; exit 2; } SUCCESS=1 trap - EXIT INT TERM echo "rotate: only-key-changed: $KEY" ``` Invoke it with the new token on stdin (never in argv, never echoed), then restart — the restart is the only step the deploy user performs with sudo: ```bash ENV=/etc/arxo/mcp-8722.env printf '%s\n' "$VAULT_ROTATED_TOKEN" | sudo /usr/local/sbin/arxo-rotate-token "$ENV" LAW_MCP_TOKEN \ && sudo /usr/bin/systemctl restart law-mcp-private@8722 # expect: rotate: only-key-changed: LAW_MCP_TOKEN, then a restart. # Any other outcome — including a staging failure — leaves the live # file untouched and removes the staged old token. # Any other helper outcome leaves the live file untouched AND skips # the restart — never restart after a failed rotation. ``` Verify the rotation itself, not just liveness — a green healthcheck says nothing about which token is enforced. Both checks pipe the `Authorization` header into `curl -K -` (stdin config), so no secret appears in argv, in output, or in any intermediate file; the old-token value is the pre-rotation secret the helper staged before activation, not an arbitrary string (the vault's previous version is the equivalent source). `pipefail` plus the non-empty guard make a missing token fail the probe loudly instead of earning a false 401; the explicit `|| exit 1` branches make a wrong HTTP status fail the whole procedure (a bare `&& echo` would skip the message and carry on to cleanup). The `%s` substitution is safe against config injection exactly because the rotated token alphabet excludes quotes and backslashes (see the supported shape above): ```bash HDR='Content-Type: application/json' ACC='Accept: application/json, text/event-stream' BODY='{"jsonrpc":"2.0","id":1,"method":"ping"}' set -o pipefail mkheader() { IFS= read -r t && [ -n "$t" ] && printf 'header = "Authorization: Bearer %s"\n' "$t"; } # new token works — any other status is a failed rotation, not a # skipped message: pipefail catches transport errors, the explicit # branch below catches wrong codes. CODE=$(printf '%s\n' "$VAULT_ROTATED_TOKEN" | mkheader \ | curl -s -o /dev/null -w '%{http_code}' -K - -H "$HDR" -H "$ACC" -d "$BODY" \ http://127.0.0.1:8722/mcp) || { echo "new-token probe failed" >&2; exit 1; } [ "$CODE" = 200 ] || { echo "new token was not accepted (HTTP $CODE)" >&2; exit 1; } echo NEW-TOKEN-OK # old token is dead CODE=$(sudo cat /run/arxo-rotated-out-mcp-8722.env | mkheader \ | curl -s -o /dev/null -w '%{http_code}' -K - -H "$HDR" -H "$ACC" -d "$BODY" \ http://127.0.0.1:8722/mcp) || { echo "old-token probe failed" >&2; exit 1; } [ "$CODE" = 401 ] || { echo "old token still accepted (HTTP $CODE)" >&2; exit 1; } echo OLD-TOKEN-DEAD # Cleanup runs only after both mandatory checks passed: a failed # rotation keeps the staged old token for diagnosis and retry. sudo rm -f /run/arxo-rotated-out-mcp-8722.env # expect: NEW-TOKEN-OK, then OLD-TOKEN-DEAD ``` `law serve --token-file` rotates the same way, except the file holds a single secret rather than `KEY=value` lines: stage the new value to a temp file, `chmod`/`chown --reference` it to the live file, `mv` it over, then restart the process. There is no overlap/grace period for two live tokens — clients must switch at restart (current limit). ## Current limits - One token per process; no expiry, no scopes, no per-tool keys. - No brute-force lockout in the servers; rate limiting lives at the edge ([HTTP, TLS and reverse proxies](/operate/http-tls-reverse-proxies/), `3r/s` reference-setting). - The token travels as a bearer string: without TLS in front it is readable on the wire. Bearer-behind-TLS is the verified shape. - Rotation is restart-based with a single live token (above). ## Verified external-IdP integration: none No OAuth, OIDC, SAML, LDAP, mTLS/peer-certificate, or API-gateway claim check exists in either server (verified absence: the only `authorization` read in both codebases is the Bearer comparison). Any "log in with SSO, then call the evaluator" design is therefore an operator-policy-example, not a supported integration, and must obey two hard constraints: 1. The backend consumes only `Authorization: Bearer ` (plus `Origin`, `X-Forwarded-For`, `X-Request-Id`/`traceparent` for allow-listing, logging, and tracing). A proxy-injected identity header (`X-Remote-User`, JWT claim header) is ignored by the backend — it must be translated to the Bearer token at the proxy, or it enforces nothing. 2. Identity-at-proxy never becomes case rights: once the proxy attaches the token, the backend still authorizes the whole instance, not the user. ## Failures and diagnostics | Symptom | Cause | Fix | |---|---|---| | 401 on every call incl. `ping` | token configured, header missing/wrong | send the exact `Authorization: Bearer` value | | Startup refusal naming `LAW_MCP_TOKEN`/`LAW_MCP_PUBLIC=1` | non-loopback bind, no token | bind loopback, set a token, or deliberate `--public` | | `--token and --token-file cannot be combined` | both passed to `law serve` | keep one | | `--token-file ...: empty token` | blank secret file | write the token without trailing newline surprises (`printf '%s'`) | | Token visible in `ps`/shell history | passed via `--token` / inline env in a shared shell | move to `EnvironmentFile` / `--token-file` | | Old token still works after file edit | process not restarted | restart; settings are startup-only | ## Support boundaries - "Secure" in this article means only: requests without the token are refused with 401, and an unauthenticated public bind requires an explicit flag. It does not mean encryption (TLS is the edge's job), user management, audit of who knew the token, or protection of a leaked token. - Per-user, per-case, and per-predicate access control are not supported. Do not present tenant separation on one token as isolation: the supported isolation unit is the process (own port, token, profile, OS user). - External IdP integration is not supported; proxy-side SSO wrapping is an unverified operator design (constraints above). ## Next step - To narrow what an authorized caller can invoke: [Tool profiles and input policy](/operate/tool-profiles-input-policy/). - For where calls land in logs and journals: [Logs, audit, decision journals](/operate/logs-audit-decision-journals/).