Skip to content

feat: admin service accounts via client_credentials grant#226

Draft
eugenioenko wants to merge 1 commit into
mainfrom
feat/admin-service-accounts
Draft

feat: admin service accounts via client_credentials grant#226
eugenioenko wants to merge 1 commit into
mainfrom
feat/admin-service-accounts

Conversation

@eugenioenko
Copy link
Copy Markdown
Owner

Summary

  • Adds an is_admin_service_account flag on clients. When set on a confidential client with the client_credentials grant and autentico-admin in its audience, tokens issued via client_credentials satisfy the admin-API check without a backing user or session.
  • Replaces the "fake admin user + --enable-admin-password-grant" pattern for headless CI/CD automation. Human admins still use the user-backed path; the two paths live side-by-side in the middleware.
  • See docs-web/src/content/docs/security/service-accounts.mdx for setup, rotation, and leak response.

Security properties

  • Flag is admin-gated at every write path (/oauth2/register, /admin/api/clients) — both wrapped in adminAPI(...) in pkg/cli/start.go.
  • Validator rejects the flag on public clients and on clients without the client_credentials grant, at both create and update time. A defense-in-depth check inside CreateClient/UpdateClient rejects the same combinations even if a caller bypasses the validator.
  • Partial updates preserve the existing flag — renaming a service-account client cannot silently disable it; editing a regular client cannot silently enable it.
  • Toggling the flag emits a WARN slog line and an audit_logs entry (detail={"is_admin_service_account":"true"}) with the acting admin's actor_id and request IP.
  • Middleware accepts service-account tokens only when the client is active, confidential, and has the flag set. Inactive or unflagged clients fall through to the user-path, which then fails because the token's sub doesn't match any user.
  • Crypto wire strength: identical to ROPC (plaintext client_secret over TLS). client_secret_jwt / private_key_jwt not introduced in this PR.

Test plan

  • go test ./... — all pass, including new unit + e2e coverage
  • make lint — 0 issues
  • admin-ui builds (TypeScript + Vite)
  • Unit: TestAdminAuthMiddleware_ServiceAccount_{Accepted,FlagMissing,InactiveClient,WrongAudience}
  • Unit: TestValidateClientCreateRequest_AdminServiceAccount (five scenarios including default client_type)
  • Unit: TestUpdateClient_{PreservesAdminServiceAccountFlag,CannotFlagPublicClient,FlagRequiresClientCredentialsGrant}
  • E2E: TestAdminServiceAccount_{FullFlow,WithoutFlag_Rejected,PublicClient_Rejected}
  • Migration 006 applied cleanly on a fresh in-memory DB (TestMigrate_RunSucceeds rewritten to use a bare DB rather than a pre-migrated one — ALTER TABLE ADD COLUMN is not idempotent in SQLite, and CLAUDE.md's claim of idempotent migration replay was incidental, not a design guarantee)
  • Manual browser check of the Admin UI checkbox + confirmation modal (not run in this PR — flagged for manual validation before merge)

Docs

  • README.md — admin-API auth note
  • CLAUDE.md — new "Admin API Authentication" subsection in the architecture doc
  • docs-web/.../security/service-accounts.mdx — new page (setup, validation, audit, rotation, leak response, comparison with ROPC)
  • rfc/rfc.md — implementation-defined extension under RFC 6749 §4.4
  • Swagger regenerated via make generate-docs

Not in this PR

  • client_secret_jwt / private_key_jwt client authentication
  • Scope-based granular admin permissions
  • Token revocation by client (bulk)
  • Admin UI Playwright tests

🤖 Generated with Claude Code

Adds is_admin_service_account flag on clients. When enabled on a confidential
client with the client_credentials grant and autentico-admin in its audience,
tokens issued via client_credentials satisfy the admin-API authorization check
without a backing user or session. This replaces the "fake admin user +
--enable-admin-password-grant" pattern for headless CI/CD automation.

The flag is admin-gated at every write path, preserved across partial updates,
rejected on public clients and clients missing the client_credentials grant,
and logged at WARN with an audit entry whenever toggled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

// Service-account elevation mints admin-API credentials. Always require an
// explicit confirmation before the form is submitted with the flag enabled.
const confirmServiceAccount = () =>
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this modal, we can just show a warning alert when toggle is enabled

}
}, [client, open, form]);

const confirmServiceAccount = () =>
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this modal, just an alert with a warning. also lets reuse the alert from create form

Comment thread pkg/client/handler.go
}
// Service-account elevation is equivalent to minting admin-API credentials.
// Log at WARN so leaks are traceable to the acting admin via audit + logs.
slog.Warn("client: admin service account created",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this slog warn? audit log when enabled will have the info

Comment thread pkg/client/handler.go
if actor != nil {
actorID = actor.GetID()
}
slog.Warn("client: admin service account flag toggled",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this slog warn? audit log when enabled will have the info

Comment thread pkg/client/update.go
if newIsAdminServiceAccount {
effectiveClientType := c.ClientType
if effectiveClientType == "" {
effectiveClientType = "confidential"
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the configuration is invalid instead of fixing it silently we should fail

azp := jwtutil.ExtractAzp(tokenString)
if azp != "" && azp == claims.UserID {
cli, cliErr := client.ClientByClientID(azp)
if cliErr == nil && cli != nil && cli.IsActive && cli.ClientType == "confidential" && cli.IsAdminServiceAccount {
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might get problematic: user table might have same id as clients table. and other issues that might happen here. More thoughts on this check are required

@eugenioenko eugenioenko marked this pull request as draft April 22, 2026 17:17
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