Skip to content

feat(m2m): service layer + admin GraphQL API for ServiceAccount + TrustedIssuer [Phase 1 — PR 3/5]#644

Merged
lakhansamani merged 2 commits into
feat/workload-identity-phase1from
feat/workload-identity-phase1-service-graphql
Jul 6, 2026
Merged

feat(m2m): service layer + admin GraphQL API for ServiceAccount + TrustedIssuer [Phase 1 — PR 3/5]#644
lakhansamani merged 2 commits into
feat/workload-identity-phase1from
feat/workload-identity-phase1-service-graphql

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

Adds the transport-agnostic service layer and admin GraphQL API for ServiceAccount and TrustedIssuer, on top of the storage CRUD landed in #641/#642. Follows the repo's admin_webhooks.go / add_webhook.go idiom exactly — thin GraphQL resolvers delegating to internal/service/.

Chains on: #642 (merged into this integration branch)

What's in this PR

Service layer

  • internal/service/admin_service_accounts.go — Create/Update/Delete/Rotate/Get/List, plus generateServiceAccountSecret/normalizeScopes helpers
  • internal/service/admin_trusted_issuers.go — Add/Update/Delete/Get/List

GraphQL (internal/graph/schema.graphqls, regenerated via make generate-graphql)

  • Types: ServiceAccount (no client_secret field), CreateServiceAccountResponse (the only place client_secret appears — reused by rotate), ServiceAccounts, TrustedIssuer, TrustedIssuers
  • Mutations: _create_service_account, _update_service_account, _delete_service_account, _rotate_service_account_secret, _add_trusted_issuer, _update_trusted_issuer, _delete_trusted_issuer
  • Queries: _service_account, _service_accounts, _trusted_issuer, _trusted_issuers
  • 11 thin resolver files in internal/graphql/

Schema conversions: AsAPIServiceAccount() / AsAPITrustedIssuer() on the storage schemas (deferred from #641/#642 since the GraphQL model types didn't exist yet).

Security-critical invariants

  • Client secret: 32 random bytes (crypto/rand) → base64url, hashed with bcrypt cost 12 (not the package default of 10) before storage.
  • One-time reveal: plaintext returned only from create and rotate; model.ServiceAccount has no client_secret field, so no read path can leak it.
  • IsActive set explicitly true on create — does not rely on the GORM column default.
  • Empty/whitespace-only allowed_scopes rejected on create and update (normalized: trimmed, deduped, empty segments dropped).
  • Update is load-then-mutate (satisfies the storage layer's partial-struct guard).
  • Delete cascades to trusted issuers (verified by test).
  • AddTrustedIssuer defaults subject_claim to "sub" and rejects binding to a non-existent service account.

PR chain

PR 1 — schema + interface + SQL + constants     merged → feat/workload-identity-phase1  ✅ #641
PR 2 — NoSQL provider implementations           merged → feat/workload-identity-phase1  ✅ #642
PR 3 — service layer + admin GraphQL API        🔄 this PR
PR 4 — gRPC admin RPCs
PR 5 — client_credentials grant + tests

Test plan

  • make generate-graphql
  • make fmt (Go only — reverted an unrelated frontend reformat side effect)
  • make lint — lint-go: 0 issues (lint-ts fails on pre-existing frontend prettier drift unrelated to this PR)
  • New integration tests: TestServiceAccountAdmin (8 subtests), TestTrustedIssuerAdmin (7 subtests) — cover secret one-time-reveal, bcrypt cost 12, cascade delete, empty-scope rejection, non-admin rejection
  • Full make test-sqlite — pass

Wire the Phase 1 workload-identity service layer and admin GraphQL surface
on top of the existing storage CRUD.

- service layer: CreateServiceAccount/Update/Delete/RotateSecret/get/list and
  AddTrustedIssuer/Update/Delete/get/list, each gated by requireSuperAdmin and
  audited via the existing admin.* audit constants
- secrets: 32-byte crypto/rand plaintext, bcrypt cost 12, hash-only storage,
  one-time reveal via CreateServiceAccountResponse; reads never surface it
- allowed_scopes normalized (trim, drop-empty, dedupe) and rejected when empty
  on both create and update
- IsActive set explicitly on create (never rely on the GORM default)
- GraphQL schema types/inputs/queries/mutations + regenerated gqlgen output
- AsAPIServiceAccount/AsAPITrustedIssuer converters (no client_secret field)
- integration tests: one-time secret reveal, rotation invalidates old secret,
  service-account delete cascades to trusted issuers, empty-scope rejection,
  partial-update preservation, super-admin enforcement
UpdateTrustedIssuer let a caller blank expected_aud even though
AddTrustedIssuer already rejects it empty. expected_aud backs the
audience-binding invariant the token endpoint will enforce later, so
the update path shouldn't be the hole that lets it go empty.
@lakhansamani lakhansamani merged commit 40fe319 into feat/workload-identity-phase1 Jul 6, 2026
4 checks passed
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