Operator Dashboard
The operator dashboard is Guardian's read-and-administer surface for
humans. It lives in the same Guardian server process as the gRPC API but
uses a separate auth domain and a separate set of HTTP routes under
/dashboard/*. This doc explains the trust model, how operators enroll,
how the local smoke example wires it up, and how the permission vocabulary
is structured.
Companion docs:
What it is
A small HTTP API + browser UI that lets a known set of operators:
- list and inspect accounts known to this Guardian
- read per-account and global delta / proposal feeds
- read account snapshots and metadata
- (gated by permission) pause/unpause accounts
It is not part of the per-account gRPC contract — clients of Guardian don't talk to it. Only operators do.
Trust model
Two auth domains coexist in the same server process:
Code references:
- Allowlist loader:
crates/server/src/dashboard/allowlist.rs - Challenge/session issuance:
crates/server/src/dashboard/authz.rs - Middleware that gates
/dashboard/*:crates/server/src/dashboard/middleware.rs - Permission vocabulary:
crates/server/src/dashboard/permissions.rs
The dashboard never sees an account's key material and an account's client never sees an operator's. They are separate by construction.
Session flow
Defaults
(crates/server/src/dashboard/config.rs):
- Challenge TTL: 5 minutes
- Session TTL: 8 hours
- Max outstanding challenges per operator: 8
- Cookie name:
guardian_operator_session - Pubkey-endpoint rate limit: 5 burst / 30 per minute
Sessions are in-memory per task and there is no ALB session stickiness — so on multi-task deployments an operator may be routed to a task that did not mint their session and be asked to re-authenticate. The cookie is signed and can be validated cryptographically, but the corresponding session record only lives in the task that minted it. A task restart drops all sessions held by that task.
For multi-replica deployments where you want cursors to validate across
replicas, set GUARDIAN_DASHBOARD_CURSOR_SECRET to a 32-byte hex value
shared by every task (config.rs:38).
Pagination
All list endpoints
(services/dashboard_pagination.rs)
share the same query-parameter shape:
limit— integer in[1, 500]. Default50when omitted or empty. Out-of-range or non-integer values return HTTP 400invalid_limit.cursor— opaque, HMAC-signed token returned by the previous page. Signed with the cursor secret (see above); tampered, expired, or wrong-kind cursors return HTTP 400invalid_cursor. Omit to start from the first page.
When GUARDIAN_DASHBOARD_CURSOR_SECRET is unset, each task generates a
random secret at startup. Cursors then become invalid the moment a
client is routed to a different task — set the env var on any
multi-replica deployment.
The global-delta feed
(GET /dashboard/deltas)
also accepts a status filter
(services/dashboard_global_deltas.rs):
- Allowed values:
candidate,canonical,discarded(comma-separated to combine, e.g.?status=candidate,canonical). - Omitted or empty → all statuses.
- Duplicates within the filter are silently coalesced.
- Any other token returns HTTP 400
invalid_status_filter.
Permission vocabulary
Permissions are server-defined; unknown strings are rejected at allowlist
load time so a typo surfaces explicitly
(permissions.rs).
| Permission | Grants |
|---|---|
dashboard:read | Read access to all /dashboard/* read endpoints. |
accounts:pause | Pause/unpause accounts. |
policies:write | Reserved for future policy writes — no endpoint currently requires it. |
Wire strings are case-sensitive and must not contain whitespace — the parser rejects both.
Current scope:
dashboard:readgates all read endpoints.accounts:pausegates the pause/unpause endpoints (see Account pausing below).policies:writeis reserved vocabulary — accepted by the allowlist parser but no endpoint requires it yet.
Account pausing
Operators holding accounts:pause can halt and resume an account's
state-changing operations. While paused, the server rejects calls on
the state-transition, proposal, and EVM mutation paths
(PushDelta, PushDeltaProposal, SignDeltaProposal, and the
matching EVM proposal/session operations) with 409 GUARDIAN_ACCOUNT_PAUSED
and gRPC FailedPrecondition
(error.rs:97-101). Read endpoints
and ConfigureAccount remain available so an account can be
reconfigured while paused.
| Route | Permission | Body |
|---|---|---|
POST /dashboard/accounts/{id}/pause | accounts:pause | { "reason": "<non-empty string>" } — required and validated. |
POST /dashboard/accounts/{id}/unpause | accounts:pause | { "reason": "<optional string>" } — optional. |
Both endpoints are idempotent: pausing an already-paused account or
unpausing a not-paused account succeeds without state change. Each
transition is recorded in the audit log with the operator's commitment,
the timestamp, and the supplied reason
(services/pause_account.rs,
services/unpause_account.rs).
Pause state lives in the account metadata (paused_at,
paused_reason) — survives task restarts and follows the account across
multi-stack deploys that share metadata storage.
When a paused account is touched by a write path (PushDelta,
SignDeltaProposal, PushDeltaProposal, …), the server returns
GUARDIAN_ACCOUNT_PAUSED with the original paused_reason in the
response body. See
TROUBLESHOOTING.md.
Allowlist payload
The operator allowlist is a Secrets Manager entry whose payload is one of:
Legacy array form — every key implicitly gets dashboard:read:
["0x<falcon-pubkey-a>", "0x<falcon-pubkey-b>"]
Object array form (recommended) — explicit permission sets:
[
{
"public_key": "0x<falcon-pubkey-a>",
"permissions": ["dashboard:read", "accounts:pause"]
},
{
"public_key": "0x<falcon-pubkey-b>",
"permissions": ["dashboard:read"]
}
]
Mixed arrays of bare strings and objects are accepted; duplicate
public_key entries across the file are rejected.
Terraform-managed allowlists are limited to the legacy array form. The
guardian_operator_public_keysvariable is typedlist(string)and Terraform writesjsonencode(...)of the list verbatim (infra/operator_secrets.tf:12), so every entry implicitly getsdashboard:readonly. To grantaccounts:pauseyou must use the object form via a file (GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE) or an externally managed secret referenced byguardian_operator_public_keys_secret_arn.
The server resolves the allowlist source from one of these env vars
at startup (allowlist.rs:70);
the contents are re-read per authenticated request, so adding or
removing operators does not require a task restart:
| Env var | Source |
|---|---|
GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_ID | Secrets Manager secret name or ARN (set by Terraform on the ECS task). |
GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE | Local JSON file path — local development only. |
Enrolling an operator
End-to-end procedure for adding operator Alice to a deployed Guardian.
- Alice generates a Falcon keypair on a trusted device (the same
keypair format the multisig SDK and the smoke example use; the
examples/operator-smoke-webREADME has a UI for this). - Alice gives the public key (hex
0x…) to the deploying operator. - Deployer updates the allowlist:
- Terraform-managed: append the bare key to
guardian_operator_public_keysand redeploy (see Secrets runbook). This path grantsdashboard:readonly. - Externally-managed:
aws secretsmanager update-secretwith the new payload — no ECS restart required.
- Terraform-managed: append the bare key to
- Alice logs in — challenge → sign → session. The change takes
effect on her next request; the server refreshes the allowlist on
every challenge issuance and every authenticated
/dashboard/*call (dashboard/state.rs:103-108,dashboard/state.rs:284-324).
Removing or revoking an operator
Same shape, no restart:
- Update the secret payload to drop or change Alice's entry.
- Effect is immediate — the next challenge or authenticated request from any task reloads the allowlist and rejects the removed key. Currently-active sessions for the revoked operator are rejected at their next authenticated call (the per-request reload catches them).
Local development
To run the real operator UI (0xMiden/guardian-dashboard)
against a local server with Docker Compose, follow
guides/miden-dashboard.
For a lightweight check of the API itself, use
examples/operator-smoke-web — it
runs a browser harness that exercises challenge issuance, signed-session
issuance, and the account listing endpoints against either a local server
or a remote Guardian.
Run a local Guardian with a file-based allowlist:
cat > /tmp/operators.json <<'EOF'
[{ "public_key": "0x<your-falcon-pubkey>",
"permissions": ["dashboard:read", "accounts:pause"] }]
EOF
GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE=/tmp/operators.json \
GUARDIAN_STORAGE_PATH=.guardian/storage \
GUARDIAN_METADATA_PATH=.guardian/metadata \
cargo run --bin server
Then in another shell, follow the
examples/operator-smoke-web README to
point the harness at http://localhost:3000. The
smoke-test-operator-dashboard skill drives this end-to-end.
Storage-mode caveats
The dashboard surfaces a few aggregates (account counts, global feeds)
that are cheap on Postgres but expensive on the filesystem backend. The
server has a defensive cap: above
DEFAULT_FILESYSTEM_AGGREGATE_THRESHOLD (1,000 accounts by default,
config.rs:16),
cross-account aggregates on filesystem deployments may return a degraded
marker rather than a count. This is intentional — filesystem mode is a
dev convenience, not a production backend. See
Storage modes.
Operations checklist
When standing up the dashboard for a new stack:
- Decide Terraform-managed or externally-managed allowlist.
- Add at least one operator with
dashboard:readbefore shipping — otherwise the dashboard is unreachable. - Set
GUARDIAN_NETWORK_TYPEfor the stack; the dashboard environment reported byGET /dashboard/infois derived from it. - If running ≥2 ECS tasks, pin
GUARDIAN_DASHBOARD_CURSOR_SECRETto a shared 32-byte hex value. - Verify a fresh challenge → session round trip from the smoke example before considering the deploy live.