Skip to main content
Version: 0.17 (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
  • Per-commitment auth budget: 6 burst / 30 per minute, partitioned by GUARDIAN_MAX_REPLICAS in multi-replica deployments

Session records use the configured coordination backend. Postgres-backed deployments share sessions across replicas, so ALB stickiness is not required and task replacement does not invalidate an unexpired session. Filesystem-backed development uses the in-memory coordination backend and remains single-process.

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, retained, 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.

Present retained rows as "Unresolved / account unlocked", never as failed: the guardian stopped actively verifying and released the account slot, but the on-chain outcome is still uncertain and background reconciliation may promote the row to canonical until its retention TTL expires. The triage fields:

  • status_reason (feed + detail): why the row left the active candidate path — retry_exhausted / diverged on retained rows (a diverged row that later reconciles is direct evidence the divergence verdict was spurious), client_abandoned on discarded rows.
  • retained_expires_at (detail): when the recovery net gives up for good. Retained age is now − status_timestamp.
  • base_matches_stored_state (detail): whether the row still chains from the stored account state; false means it is structurally obsolete and can only age out.
  • The latest reconciliation activity is in the worker logs as stable event=reconcile_* records (see TROUBLESHOOTING.md).

GET /dashboard/info exposes the reconciliation settings (retained_ttl_seconds, reconcile_interval_seconds, reconcile_page_size) so operators can tell why retained rows are or are not being reconsidered — note that individual accounts back off as their recoverable rows age, so a retained row being probed less often than the configured interval is expected.

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_NETWORK_TYPE=MidenLocal \
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.