Skip to main content
Version: 0.15 (unstable)

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:

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]. Default 50 when omitted or empty. Out-of-range or non-integer values return HTTP 400 invalid_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 400 invalid_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).

PermissionGrants
dashboard:readRead access to all /dashboard/* read endpoints.
accounts:pausePause/unpause accounts.
policies:writeReserved 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:read gates all read endpoints. accounts:pause gates the pause/unpause endpoints (see Account pausing below). policies:write is 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.

RoutePermissionBody
POST /dashboard/accounts/{id}/pauseaccounts:pause{ "reason": "<non-empty string>" } — required and validated.
POST /dashboard/accounts/{id}/unpauseaccounts: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_keys variable is typed list(string) and Terraform writes jsonencode(...) of the list verbatim (infra/operator_secrets.tf:12), so every entry implicitly gets dashboard:read only. To grant accounts:pause you must use the object form via a file (GUARDIAN_OPERATOR_PUBLIC_KEYS_FILE) or an externally managed secret referenced by guardian_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 varSource
GUARDIAN_OPERATOR_PUBLIC_KEYS_SECRET_IDSecrets Manager secret name or ARN (set by Terraform on the ECS task).
GUARDIAN_OPERATOR_PUBLIC_KEYS_FILELocal JSON file path — local development only.

Enrolling an operator

End-to-end procedure for adding operator Alice to a deployed Guardian.

  1. 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-web README has a UI for this).
  2. Alice gives the public key (hex 0x…) to the deploying operator.
  3. Deployer updates the allowlist:
    • Terraform-managed: append the bare key to guardian_operator_public_keys and redeploy (see Secrets runbook). This path grants dashboard:read only.
    • Externally-managed: aws secretsmanager update-secret with the new payload — no ECS restart required.
  4. 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:

  1. Update the secret payload to drop or change Alice's entry.
  2. 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:read before shipping — otherwise the dashboard is unreachable.
  • Set GUARDIAN_NETWORK_TYPE for the stack; the dashboard environment reported by GET /dashboard/info is derived from it.
  • If running ≥2 ECS tasks, pin GUARDIAN_DASHBOARD_CURSOR_SECRET to a shared 32-byte hex value.
  • Verify a fresh challenge → session round trip from the smoke example before considering the deploy live.