Operator actions on session-gated endpoints without an IdP

This guide is for operators of a deployment that runs with no human IdP wired (hort_dex_enabled: false — no Dex/OIDC, HORT_AUTH_PROVIDER=disabled) who still need to drive endpoints that gate on token kind: the self-service prefetch trigger and the curator decision surface. It covers the token-kind contract those endpoints enforce, the capture-safe token mint, the opt-in authority preflight, --rotate semantics, and the write-token blast-radius trade-off.

For the design rationale behind claimless native tokens and IdP-backed CLI sessions, see ADR 0012 and ADR 0013.


1. Why there is no IdP here

A deployment that hosts no general user activity and maintains no human accounts has nothing for an IdP to authenticate — wiring Dex/OIDC would add a dependency and an attack surface with no corresponding need. The designed operator path instead is a pair of PAT-only, non-admin ServiceAccounts minted host-side by the deploy tooling: their tokens are the operator's only credential, standing in for the CliSession a human admin would otherwise mint through an IdP login.

2. The token-kind contract

Every native token carries a TokenKind: CliSession (IdP-backed, human, ≤15 min — see using-hort-cli-with-admin-ops.md), ServiceAccount (gitops-declared, minted by issue-svc-token), Pat (self-service, minted by an already-authenticated human principal via /api/v1/users/me/tokens), and Refresh. With no IdP, no CliSession is obtainable in steady state, and no Pat is either — minting a Pat requires an existing human principal to already be logged in, and this deployment has none. (The one exception is hort-server admin bootstrap-session, a DSN- and HORT_TOKEN_ALLOW_ADMIN-gated, ≤1 h admin-capable Pat reserved for first-wiring / break-glass, not a standing credential — see the module doc in crates/hort-server/src/cli/admin.rs.) That leaves the ServiceAccount token as the only practical session-free credential.

The two gated endpoints enforce this differently:

3. The mint: capture-safe form

issue-svc-token prints the plaintext token to stdout by default — convenient for a Helm hook piping into kubectl create secret, a trap on an interactive host shell where stdout is easy to scroll-capture into a log or a terminal multiplexer's history. Use --output=file:<path> instead and read the file, never the process output:

TF=$(mktemp)
hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch --output=file:"$TF"
TOK=$(cat "$TF"); rm -f "$TF"

--name selects the target ServiceAccount by its gitops metadata.name — the command requires the envelope to already exist (applied via gitops) and never fabricates one (resolve_svc_user in crates/hort-server/src/cli/admin.rs). This deployment declares two operator SAs:

SAGrants (see §5)Used for
maintainer-devglobal read, prefetch, writepulls, the self-service prefetch endpoint, the manual per-artifact rescan
maintainer-curatorglobal curatethe curator decision endpoints

--permission may repeat; issue-svc-token always rejects --permission=admin (service accounts are strictly non-admin — ADR 0038).

Upgrading a host provisioned before maintainer-dev carried write: the Ansible gitops role's mint task is idempotent by name (§5) — it only reaches the SA's full authority on a host where no maintainer-dev token exists yet. A host provisioned before this table's grant set widened to include write keeps its narrower token forever; the playbook keeps reporting success because idempotent-skip and freshly-issued are the same exit code. Bring such a host's token up to date once, by hand:

hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch --permission=write \
  --rotate --output=file:/run/secrets/hort-dev.token

(Podman flavor: prefix with podman exec hort-server.) Confirm it worked with hort-cli admin rescan <artifact-id> against the refreshed token — 202 means the upgrade landed, 403 means the old token is still live.

4. The authority preflight: --require-authority

A bare mint performs no grant check — it happily mints a token for a declared permission the SA has no backing grant for, and every request that token later makes fails at runtime RBAC instead. Pass --require-authority to catch that at mint time instead:

hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch \
  --require-authority --output=file:"$TF"

Each declared --permission must be backed by a live PermissionGrant on the SA at the checked scope — and a global grant satisfies a repo-scoped check too (the same global-⊇-repo evaluator semantics runtime RBAC uses; see check_require_authority in crates/hort-server/src/cli/admin.rs). An unbacked permission fails the mint with a message naming every unbacked permission and a copy-paste PermissionGrant YAML block per permission.

--require-authority takes an optional value naming that scope:

--repository separately scopes the minted token's own capability: with the flag, the token's cap carries repository_ids = [<resolved id>] instead of the default global None. The two flags are independent knobs — an identity can carry a global cap while its authority is checked at a specific repository, which is exactly the shape a repo-scoped-only SA needs. Pick the combination matching the job:

# Global identity: usable against any repository the underlying
# grants cover, checked against a global grant.
hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch \
  --require-authority --output=file:"$TF"

# Repository-scoped identity: the token itself cannot be used outside
# npm-proxy, even if the SA holds broader grants elsewhere. The bare
# flag's preflight follows --repository here too.
hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch --repository=npm-proxy \
  --require-authority --output=file:"$TF"

# Global-cap identity whose grants are only repo-scoped: the cap stays
# global (no --repository), but the preflight checks npm-proxy's scope
# explicitly via --require-authority's own value.
hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch \
  --require-authority=npm-proxy --output=file:"$TF"

issue-svc-token is strictly non-admin end to end: it rejects --permission=admin regardless of --require-authority, and never mints for a user whose is_admin bit is set.

Grant files backing the operator SAs

The preflight above checks against these standalone serviceAccount-subject grants (deploy/ansible/files/gitops/auth/grants/):

All four are global (no repository: field) — see declare-gitops-config.md kind: PermissionGrant for the omit-the-field-for-global convention.

5. --rotate and the row-without-Secret trap

Re-running the mint command for a token name that already exists is idempotent by default: issue-svc-token exits 0, logs an info: line on stderr, and — because it never re-emits a plaintext it cannot un-hash — writes nothing to the output file. If a provisioning script blindly reads that file next, it either reuses a stale empty file or silently proceeds with no token at all.

Always check the output file is non-empty before storing or exporting it as a credential:

TF=$(mktemp)
hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --output=file:"$TF"
if [ ! -s "$TF" ]; then
  echo "no new token minted (row already exists) — pass --rotate to replace" >&2
fi

Pass --rotate to force replacement: the existing token is revoked first, then a fresh one is minted and written.

hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=prefetch --rotate --output=file:"$TF"

Rotation changes which token is valid; it does not change what the token can do at request time. Permission::Read ∧ Permission::Prefetch on the resolved repository is still evaluated on every prefetch call against the SA's live grants, independent of which token happened to present them.

6. Blast radius: mint write-capable tokens per action

maintainer-dev-write.yaml grants global write — deliberately, to avoid per-repository grant upkeep for a single trusted operator identity, but with an unavoidable trade-off: a leaked write-capable maintainer-dev token can push to every hosted repository this instance serves. Bound the exposure by minting write capability only for the action at hand, not as a standing credential:

TF=$(mktemp)
hort-server admin issue-svc-token --name=maintainer-dev \
  --permission=read --permission=write --rotate \
  --expires-in-days=1 --output=file:"$TF"
TOK=$(cat "$TF"); rm -f "$TF"
# ... perform the write-gated action (e.g. a manual per-artifact
# rescan, POST /api/v1/artifacts/:id/rescan) ...

Prefer the day-to-day read (+ prefetch where needed) mint from §3 for routine operator work, and reserve a write-capable mint for the specific action that needs it.

See also