Skip to content

feat(service-accounts): user-managed SAs + API keys#78

Merged
BK1031 merged 5 commits into
mainfrom
bk1031/feat-service-accounts
Jun 17, 2026
Merged

feat(service-accounts): user-managed SAs + API keys#78
BK1031 merged 5 commits into
mainfrom
bk1031/feat-service-accounts

Conversation

@BK1031

@BK1031 BK1031 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New `api_key` table holds opaque `sk_<key_id>_` tokens. `key_id` is plain (lookup column), `secret` is sha256-hashed. The raw token is returned exactly once on create.
  • New endpoints: `{GET,POST} /applications/:id/service-accounts`, `DELETE /service-accounts/:id`, `{GET,POST} /service-accounts/:id/api-keys`, `DELETE /service-accounts/:id/api-keys/:keyID`. Gated to app-owner OR admin.
  • `AuthChecker` now recognizes `sk_` bearer tokens: validate against the api_key table, set the same Auth-* context the JWT path uses, fire a background `last_used_at` update.
  • Service Accounts card on `ApplicationDetailsPage` with inline-expand showing keys + create dialog (name / TTL dropdown 30/90/365/never, defaults to 90 / scope input) + one-time reveal dialog with copy button.

Decisions baked in

  • Opaque keys, not JWT extension. Familiar UX (Stripe/GitHub style), single DB lookup per validation, easy revocation.
  • Default TTL 90 days, dropdown supports 30/90/365/never.
  • App owner OR admin can manage SAs (matches existing app-edit gate).
  • Per-key scope, user-entered on create. Inheritance from SA's groups was the alternative but per-key scope lets you mint narrower 'read-only' keys.

What you need to test it

  1. Recreate `core` to run the new automigration: `docker compose up -d --force-recreate core`
  2. Open an application you own → scroll to "Service accounts" → "New service account"
  3. Expand the SA → "New API key" → copy the `sk_` token from the reveal dialog
  4. Hit any authed endpoint with `Authorization: Bearer sk_xxx` — should succeed and update last_used_at in the DB

Test plan

  • Create SA, mint key, key works for an authed endpoint
  • Revoke key → next request 401s
  • Delete SA → all its keys revoked, 401s start
  • Non-owner non-admin: card is hidden (and direct API calls 403)
  • Expired key (TTL 30, set the row to past) → 401
  • last_used_at updates after a successful auth

BK1031 added 5 commits June 16, 2026 04:06
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.
@BK1031 BK1031 merged commit e5e366d into main Jun 17, 2026
15 checks passed
@BK1031 BK1031 deleted the bk1031/feat-service-accounts branch June 17, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant