Markdown for LLMs
Authentication and access control
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# 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 <token>`, 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-<port>.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@<port>` 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 <env-file> <KEY>
#
# 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 <token>` (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/).