feat(service-accounts): user-managed SAs + API keys#78
Merged
Conversation
Adds the missing piece between "ServiceAccount model exists in the DB"
and "humans can actually use it" — HTTP endpoints, an API-key system
with opaque sk_ tokens, auth-middleware support, and a tab on the
application details page so app owners can manage everything in one
place.
Backend:
- model/api_key: new api_key table. The raw token is sk_<key_id>_<secret>
where key_id is plain (lookup column) and secret is sha256-hashed.
Auto-migrate on core boot.
- service/api_key: GenerateAPIKey returns the row plus the raw sk_
string ONCE; ValidateAPIKey parses + constant-time-compares the
hash + checks expiry. MarkAPIKeyUsed is fire-and-forget. Format
rejection lands before any DB lookup so we don't leak an
existence oracle.
- service/service_account: CreateServiceAccountForApp wraps the
entity + SA creation pair the HTTP API needs.
- service/entity: DeleteEntity (used when an SA is deleted —
cascade is intentionally not done here, callers decide).
- api/service_account: GET/POST /applications/:id/service-accounts,
DELETE /service-accounts/:id. requireAppOwnerOrAdmin gates write
access exactly like the existing app-edit endpoints. SA delete
also revokes every key on the SA so an outstanding key can't
survive its owner row.
- api/api_key: GET/POST/DELETE /service-accounts/:id/api-keys. POST
returns {key, token} — the token field is the only place the raw
sk_ string ever appears.
- api/api (AuthChecker): bearer tokens with the sk_ prefix take the
API-key validation path; everything else stays JWT. Both paths
populate the same Auth-EntityID / Auth-Audience / Auth-Scope
context so downstream gates don't need to care. Successful API
key auth fires a background MarkAPIKeyUsed.
Web:
- lib/service-accounts: types + react-query hooks (list/create/
delete SAs, list/create/revoke keys) and TTL_PRESETS for the UI.
- pages/applications/ServiceAccountsCard: full editor surface —
SA list with inline expand showing keys, create-SA dialog,
create-key dialog with name + TTL dropdown (30/90/365/never,
defaults to 90) + scope input, and a one-time-reveal dialog with
copy button for the freshly-minted sk_ token.
- ApplicationDetailsPage: renders the card between Linked groups
and Metadata. Gated on owner-or-admin so non-owners don't see a
section that would just 403 on every action.
The new /service-accounts/:id/api-keys endpoints were unreachable — kerbecs had no rule for them, so requests fell through to the web upstream and returned index.html. The JSON client then tried to .map() over HTML and crashed the page.
Rewrote the SA card to mirror the patterns elsewhere in the app:
- CardTitle gets an icon prefix + CardDescription explaining the
feature, like DiscordSyncCard / ConditionalSyncCard
- "Add" buttons live at the bottom of the card content, not the
header (matches every other create surface)
- Each SA renders as a bordered sub-section (border-border/60
bg-muted/40 p-3) with its keys inside — same row/card chrome as
the linked-groups / discord-bindings lists
- API key rows use the same border-border/60 bg-background/60
treatment as CopyableMono
- Every confirm() banished — delete-SA and revoke-key go through
proper Dialogs with the destructive icon header (bg-destructive/10
text-destructive) matching GroupEditPage's delete dialog
- Create dialogs get the gradient gr-pink → gr-purple icon header
matching GroupPickerDialog / DiscordRolePickerDialog
- Primary submit actions use OutlineButton (size="sm") to match the
rest of the app's save/create buttons; destructive uses
variant="destructive" Button; everything else stays ghost/outline
- Reveal dialog displays the raw sk_ token in the same CopyableMono
treatment so the visual language carries through
Major pivot from the multi-key opaque-token system: each service account
now has exactly one bearer JWT, minted at create time and re-mintable
via a single rotate endpoint. The token is a regular Sentinel JWT
(same auth_token row, same RS256 validation), so every existing
RequestTokenHasScope / RequestTokenHasEntityID / groups-claim gate
works on an SA token with zero special-casing.
Backend
- Drop model/api_key.go, service/api_key.go, api/api_key.go entirely.
- Drop authenticateWithAPIKey + HasAPIKeyPrefix from AuthChecker —
single JWT validation path again.
- ServiceAccount gains Scope + TTLDays columns (persist the admin's
issuance choice across rotations) and an ActiveToken populated by
PopulateServiceAccount.
- service.ValidateServiceAccountScope enforces the closed set
{user:read, groups:read, applications:read}. *:write scopes route
through human-authed flows; openid/profile/email/offline_access
are user-identity scopes meaningless for SAs; sentinel:all stays
first-party only.
- service.MintServiceAccountToken delete-and-mints: revokes every
auth_token row for the SA's entity, generates a fresh JWT with the
SA's stored scope + TTL, returns the raw signed string. Used by
both create and rotate.
- service.DeleteTokensForEntity + GetLatestTokenForEntity helpers in
jwt.go.
- POST /applications/:id/service-accounts now takes
{name, scope, ttl_days}; returns {service_account, token} with the
raw JWT shown ONCE. Rolls back the SA + entity on a token-mint
failure so we don't leave a tokenless SA on the books.
- POST /service-accounts/:id/rotate is the only way to swap the
token. Reuses the SA's stored scope/TTL.
- DELETE /service-accounts/:id revokes the active token before
deleting the SA so an outstanding token can't outlive its row.
- TTL allow-list: {30, 90, 365, 0}. Never expires (0) is implemented
as a ~100-year JWT exp; the frontend renders dates that far out
as "Never".
Web
- Drop the per-SA api-keys list + all the related hooks/dialogs.
- Single-row SA UI: name + id, scope chips, token expiry summary,
rotate + delete actions. Create dialog has scope checkboxes (with
descriptions) + TTL dropdown defaulting to 365 days. Reveal dialog
shows the raw JWT once after create or rotate.
- lib/service-accounts: SA_ALLOWED_SCOPES + descriptions + TTL_PRESETS
+ isNeverExpires helper for the "render as Never" check.
The honest tradeoff vs opaque keys: JWT claims are frozen at issue
time, so a scope/group change after the token is minted only takes
effect after rotation. For internal automation tokens where rotation
is rare and intentional, "rotate on permission change" is the right
operational habit; the alternative (introspection per request) was
the wrong place to spend complexity here.
Pivots from the standard "show secret once" pattern to "creator/admin
can re-reveal anytime." Trades the unrecoverable-secret property for
better admin UX — fine for an internal tool, but worth saying out loud:
a DB read leaks every SA token.
- ServiceAccount gains a SignedToken column (json:"-" so it never
appears in list responses). MintServiceAccountToken writes the
raw JWT to the row; rotate overwrites it; delete drops it with
the SA. Persist failure is logged but doesn't unwind the mint —
the token is still valid, we just lose the re-reveal ability.
- GET /service-accounts/:id/token returns the signed JWT. Gate:
requester is the SA's creator OR a global admin. App owners who
didn't create a given SA can still rotate to get a fresh token.
- Web: Eye icon on the SA row (visible only when canViewToken
resolves true and the SA has an active token). Click lazy-fetches
via useViewServiceAccountToken and reuses the existing reveal
dialog. Rotate now flows through the same reveal dialog path,
keeping a single visual treatment for "here's a JWT."
Renamed JWT claim sa_id → service_account_id along the way for
consistency with the column naming.
This was referenced Jun 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Decisions baked in
What you need to test it
Test plan