Using hort-cli for admin operations
This guide is for operators who hold admin authority on an hort deployment and need to invoke admin commands (e.g. hort-cli admin task invoke, service-account-token issuance, policy edits) from the command line. It covers the hort-cli auth login --admin flow, the HORT_TOKEN_ALLOW_ADMIN deployment gate, the ≤15 min admin-session lifetime invariant, and what the operator-facing error messages mean.
For the design rationale see ADR 0013 — IdP-authoritative CLI sessions.
1. The shape of an admin CLI session
CLI sessions originally carried [Read, Write, Delete] for 30 days under a hard "a CLI session never carries admin" invariant. Admin operations had to be invoked via a hand-curled PAT against /api/v1/users/me/tokens — a long-lived credential on a laptop with worse ergonomics than the documented flow. The current design retires the "admin-forbidden" half of that invariant by shortening the lifetime: admin-cap CliSession tokens are now allowed, bounded to ≤15 min by clamp_lifetime. The blast radius of a stolen 15 min admin token is small enough that the trade-off has flipped versus a 30 d non-admin one.
The new defaults:
| Cap | Default lifetime | Max lifetime |
|---|---|---|
read, write, delete | 15 min | 15 min |
Includes admin | 15 min | 15 min |
Below 5 min (300 s) is rejected with invalid_request; above the per-cap max is clamped silently and hort-cli surfaces a note: line.
2. The flow
Prerequisites
- The server is started with
HORT_TOKEN_ALLOW_ADMIN=true. Without this flag the exchange handler returns400 invalid_request/"admin tokens disabled by composition-root config"and hort-cli surfaces the message verbatim. - Your IdP user holds the
adminrole (resolved through `kind: ClaimMapping). Theprincipal_is_admincheck at issuance fires the same gate Pat uses; non-admin callers get403 access_deniedwith"admin authority required to declare admin permission"`. - You have an existing OIDC login flow that already works for non-admin CLI use. The admin opt-in does not change the IdP handshake — only the
scopeandrequested_token_lifetimefields on the/exchangeform body.
Issue an admin session
$ hort-cli auth login --admin
✓ Logged in to https://hort.example.com (token expires in 15m).
Optionally specify a shorter lifetime:
$ hort-cli auth login --admin --expires-in 10m
✓ Logged in to https://hort.example.com (token expires in 10m).
If you ask for longer than 15 min with --admin, hort-cli surfaces the server-side clamp explicitly:
$ hort-cli auth login --admin --expires-in 4h
✓ Logged in to https://hort.example.com (token expires in 15m).
note: requested 4h but server issued 15m (per-cap-shape clamp —
admin sessions are bounded to ≤15 min)
The note: line is rendered whenever the server-issued lifetime differs from the requested value. For non-admin sessions the cap is also 15 min, so --expires-in 48h (without --admin) produces a similar clamp note pointing at the 15 min limit.
Use the admin session
Any subsequent hort-cli invocation in the same shell uses the freshly minted token. The session lifetime is the upper bound; you can always re-login earlier if you finish the admin work and want to drop the authority deliberately.
$ hort-cli admin task invoke staging-sweep
…
Inspect scanner workers
hort-cli admin workers list shows every worker that has registered in the scanner_registry — the scanner backends it advertises and whether it is currently heartbeating. A worker that has stopped heartbeating stays in the listing as LIVE=NO (it is not filtered out), so you can tell "my trivy worker died" apart from "I never had one".
$ hort-cli admin workers list
WORKER_ID BACKENDS LIVE LAST_SEEN REGISTERED
worker-abc123 trivy,osv yes 12s ago 3d ago
worker-def456 trivy NO 47m ago 3d ago
- LIVE is
yeswhen the last heartbeat is within ~5 minutes (the worker heartbeats every 60 s, so this tolerates four missed ticks);NOflags a stale/dead worker to investigate. - LAST_SEEN is the age of the last heartbeat; REGISTERED is how long ago the worker first registered.
--output jsonemits the raw rows (worker_id,backends,registered_at,last_heartbeat,live,last_seen_secs_ago) for scripting.
This is a read-only admin endpoint (GET /api/v1/admin/workers) and needs the admin claim like the other admin subcommands.
Rows for workers that stopped heartbeating are garbage-collected automatically: the default-enabled scanner-registry-prune CronJob deletes any worker not seen for 7 days, so the listing shows roughly the last week of churn and the table never grows without bound. (The read is also capped at the 1000 most-recently-seen workers as a safety bound.)
Inspect the effective config for a repository
GET /api/v1/admin/repositories/{key}/effective-config returns the resolved, merged config a running server holds for one repository — the repository row, its upstream mapping(s), and the scan policy actually bound to it (repo-scoped > global > built-in default), as one projection. This is the answer to "what config is this repository actually running", which the gitops YAML alone cannot show since the YAML is the input to resolution, not the resolved result.
$ curl -sS -H "Authorization: Bearer $TOKEN" \
https://hort.example.com/api/v1/admin/repositories/npm-proxy/effective-config
The response is cross-repository (any admin can read any repository's effective config, not only ones they hold a grant on) and secrets are hard omitted: no secret_ref, mtls_cert_ref, mtls_key_ref, or ca_bundle_ref field is ever present on an upstream mapping. Credential presence is surfaced only as has_credentials: bool.
This is a read-only admin endpoint and needs the admin claim like the other admin subcommands. Unknown repository key returns 404.
Inspect what this process applied at boot
GET /api/v1/admin/gitops/apply-status reports the gitops apply this running process performed at boot — in-memory only, not persisted. During a rolling upgrade, different pods can legitimately report different results until the rollout converges; this endpoint answers "what did this process apply", not "what was last applied cluster-wide".
$ curl -sS -H "Authorization: Bearer $TOKEN" \
https://hort.example.com/api/v1/admin/gitops/apply-status
{
"status": "applied",
"applied_at": "2026-08-23T09:14:02Z",
"generation": "3f9a1c... (64 hex chars)",
"created": 1,
"updated": 2,
"deleted": 3,
"unchanged": 4,
"retro_warn_count": 5,
"retro_block_count": 6,
"per_kind": {
"repositories": { "created": 0, "updated": 0, "deleted": 0, "unchanged": 0 },
"upstream_mappings": { "created": 0, "updated": 0, "deleted": 0, "unchanged": 0 },
"claim_mappings": { "created": 0, "updated": 0, "deleted": 0, "unchanged": 0 },
"permission_grants": { "created": 0, "updated": 0, "deleted": 0, "unchanged": 0 },
"curation_rules": { "created": 0, "updated": 0, "deleted": 0, "unchanged": 0 }
}
}
generationis a 64-hex-character fingerprint of the applied config. Two processes reporting the samegenerationapplied the same config; different values mean the config differs between them (e.g. a rollout that has not converged yet). It is a fingerprint, not a counter — it does not order two generations against each other.per_kindcovers only the five CRUD kinds listed above — it does not sum to the aggregatecreated/updated/deleted/unchangedcounts, which also span event-sourced kinds (scan policies, retention policies, exclusions) and machine-identity kinds (OIDC issuers, service accounts).- If no gitops apply has run this boot (e.g. a DSN-only boot, or no gitops config directory configured), the response is exactly
{"status":"no_apply_recorded"}— every other field is omitted, not zeroed.
This is a read-only admin endpoint and needs the admin claim like the other admin subcommands.
Both of the above endpoints are gated by the same AdminPrincipal check as GET /api/v1/admin/workers; see ADR 0058 for the design rationale behind the admin-only scope, the in-memory apply-status semantics, and the secrets-exclusion mechanism.
Re-login after expiry ("session expired")
A 15 min admin session expires quickly. Once the token crosses expires_at, any hort-cli invocation returns HTTP 401 with the body {"error":"invalid_token","error_description":"token expired"} — operators commonly describe this as their session expired. The remediation is the same as the original login: re-run hort-cli auth login --admin to mint a fresh 15 min session. A planned rotation-based refresh-token phase (not yet shipped at the time of writing) will suppress most of these prompts; see ADR 0013 for the direction.
3. Troubleshooting
403 access_denied: admin authority required to declare admin permission
The IdP token validated cleanly but the resolved user is not an admin. Check kind: ClaimMapping resolves your IdP group to a role with Permission::Admin granted (per declare-gitops-config.md). The mapping is applied JIT on each /exchange, so a freshly added admin grant takes effect on the next login.
400 invalid_request: admin tokens disabled by composition-root config
The server is running with HORT_TOKEN_ALLOW_ADMIN=false (or unset — the default is off). This is a deployment-level decision; coordinate with whoever runs the server.
400 invalid_request: requested_token_lifetime below 300-second minimum
--expires-in resolved to fewer than 300 s. Use at least 5m.
403 access_denied from a non-admin operation, hint mentions hort-cli auth login --admin
The authorization layer (crates/hort-http-core/src/authz/extractors.rs) surfaces a --admin re-login hint in the audit log when a cli_session principal without admin cap is denied. The hint is a heuristic — it fires for any deny on a cli_session principal whose cap lacks Permission::Admin, including denials that are actually about per-repo grant gaps. Try hort-cli auth login --admin first; if the operation still fails, the underlying problem is a missing PermissionGrant rather than a missing scope.
Active sessions never expire on a single laptop
Admin authority revocation propagates to the user's grants but not to already-issued tokens until they expire. Bounded 15 min lifetimes make this near-instant: a revoked admin's existing session is gone within 15 minutes. If you need immediate revocation (e.g. laptop theft), use DELETE /api/v1/users/me/tokens/:id — both the access token and (once Phase 2 lands) the refresh token are in the same api_tokens table.
See also
- ADR 0013 — CLI-session design: lifetime bounds, the ≤15 min admin cap, and the refresh-token direction.
- Workload identity: k8s flux, GitHub Actions, and GitLab CI use OIDC federation rather than long-lived CLI sessions. The
--adminflag is a human-CLI concept; workloads exchange their platform-minted JWT against the same/api/v1/auth/exchangeendpoint withsubject_token_type=urn:ietf:params:oauth:token-type:jwtand the resulting bearer is short-lived without needing a refresh token. Seefederate-ci-oidc.mdandfederate-k8s-workload-identity.md.