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:
- Self-service prefetch —
POST /api/v1/repositories/{repo_key}/prefetch.SelfServicePrefetchUseCaseruns an explicit token-kind gate before any RBAC check: the caller'stoken_kindmust beCliSessionorServiceAccount, or the request is denied403 Forbiddenregardless of what permissions it carries. APatis rejected here by construction — seecrates/hort-app/src/use_cases/self_service_prefetch_use_case.rs(module doc, "Gate order", anddocs/auth-catalog.mdEntry 4's self-service prefetch note). Passing the gate grants no authority by itself —Permission::Read ∧ Permission::Prefetchon the resolved repository is still required (Gate 2, same use case). - Curator decisions — everything under
/api/v1/admin/curation/(waive, block, block-versions, exclusions, the queue listing). These routes are gated byCurateOrAdminPrincipal(crates/hort-http-core/src/authz/extractors.rs), which authorizesPermission::Curate ∨ Permission::Adminand does not inspecttoken_kind— see curator-workflow.md for the full authority model. AServiceAccounttoken is still the operator's path here, not because the endpoint rejectsPattokens by kind, but because none exists to present: this deployment mints no human principal that could self-service one.
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:
| SA | Grants (see §5) | Used for |
|---|---|---|
maintainer-dev | global read, prefetch, write | pulls, the self-service prefetch endpoint, the manual per-artifact rescan |
maintainer-curator | global curate | the 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:
- bare
--require-authority: checks global scope, unless--repositoryis also given, in which case it follows--repository's scope — this is the byte-compatible form, unchanged from before the flag could carry a value. --require-authority=<repo-key>: checks<repo-key>'s scope, independent of--repository. There is no value that disables the check — an empty--require-authority=is treated the same as the bare form, never as "skip it".
--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/):
maintainer-dev-read.yaml— globalread.maintainer-dev-prefetch.yaml— globalprefetch. Enables the self-service prefetch mint shown in §3; the endpoint's own RBAC gate (§2, Gate 2) still applies per resolved repository.maintainer-dev-write.yaml— globalwrite. See §6 before minting a write-capable token.maintainer-curator-curate.yaml— globalcurate, on the separatemaintainer-curatorSA.
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
- curator-workflow.md — the
Permission::Curateauthority model the curation endpoints enforce. - using-hort-cli-with-admin-ops.md — the IdP-backed CliSession admin path, for deployments that do wire an IdP.
- rotating-service-account-tokens.md — the worker-driven periodic rotation reconciler for workloads that can't federate; a different mechanism from the one-shot
issue-svc-tokenmint documented here (see that guide's "See also" section for how the two paths relate). - declare-gitops-config.md — `kind: ServiceAccount
andkind: PermissionGrant` envelope reference. docs/auth-catalog.md— the canonical auth-surface catalog (Entry 4,ServiceAccount).