Skip to content

Commit 598e935

Browse files
committed
feat(service): add ServiceAccount and TrustedIssuer admin API
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
1 parent 5664cda commit 598e935

23 files changed

Lines changed: 6355 additions & 1121 deletions

internal/graph/generated/generated.go

Lines changed: 4862 additions & 1121 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/graph/model/models_gen.go

Lines changed: 93 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/graph/schema.graphqls

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,51 @@ type Webhooks {
300300
webhooks: [Webhook!]!
301301
}
302302

303+
type ServiceAccount {
304+
id: ID!
305+
name: String!
306+
description: String
307+
allowed_scopes: [String!]!
308+
is_active: Boolean!
309+
created_at: Int64
310+
updated_at: Int64
311+
# client_secret is NEVER returned here. It is returned exactly once in
312+
# CreateServiceAccountResponse (creation and rotation) and never again.
313+
}
314+
315+
type CreateServiceAccountResponse {
316+
service_account: ServiceAccount!
317+
# client_secret is returned ONCE at creation and ONCE at rotation. Store it
318+
# securely; it can never be retrieved again.
319+
client_secret: String!
320+
}
321+
322+
type ServiceAccounts {
323+
pagination: Pagination!
324+
service_accounts: [ServiceAccount!]!
325+
}
326+
327+
type TrustedIssuer {
328+
id: ID!
329+
service_account_id: String!
330+
name: String!
331+
issuer_url: String!
332+
key_source_type: String!
333+
jwks_url: String
334+
expected_aud: String!
335+
subject_claim: String!
336+
issuer_type: String!
337+
is_active: Boolean!
338+
spiffe_refresh_hint_seconds: Int64
339+
created_at: Int64
340+
updated_at: Int64
341+
}
342+
343+
type TrustedIssuers {
344+
pagination: Pagination!
345+
trusted_issuers: [TrustedIssuer!]!
346+
}
347+
303348
type WebhookLog {
304349
id: ID!
305350
http_status: Int64
@@ -669,6 +714,62 @@ input WebhookRequest {
669714
id: ID!
670715
}
671716

717+
input CreateServiceAccountRequest {
718+
name: String!
719+
description: String
720+
# allowed_scopes MUST contain at least one non-empty scope after trimming.
721+
allowed_scopes: [String!]!
722+
}
723+
724+
input UpdateServiceAccountRequest {
725+
id: ID!
726+
name: String
727+
description: String
728+
allowed_scopes: [String!]
729+
is_active: Boolean
730+
}
731+
732+
input ServiceAccountRequest {
733+
id: ID!
734+
}
735+
736+
input ListServiceAccountsRequest {
737+
pagination: PaginatedRequest
738+
}
739+
740+
input AddTrustedIssuerRequest {
741+
service_account_id: String!
742+
name: String!
743+
issuer_url: String!
744+
# key_source_type: "oidc_discovery" | "static_jwks_url" | "spiffe_bundle_endpoint"
745+
key_source_type: String!
746+
jwks_url: String
747+
expected_aud: String!
748+
# subject_claim defaults to "sub" if omitted
749+
subject_claim: String
750+
# issuer_type: "kubernetes_sa" | "spiffe_jwt" | "oidc" | "cloud_oidc"
751+
issuer_type: String!
752+
spiffe_refresh_hint_seconds: Int64
753+
}
754+
755+
input UpdateTrustedIssuerRequest {
756+
id: ID!
757+
name: String
758+
jwks_url: String
759+
expected_aud: String
760+
is_active: Boolean
761+
spiffe_refresh_hint_seconds: Int64
762+
}
763+
764+
input TrustedIssuerRequest {
765+
id: ID!
766+
}
767+
768+
input ListTrustedIssuersRequest {
769+
service_account_id: String
770+
pagination: PaginatedRequest
771+
}
772+
672773
input TestEndpointRequest {
673774
endpoint: String!
674775
event_name: String!
@@ -844,6 +945,15 @@ type Mutation {
844945
_add_webhook(params: AddWebhookRequest!): Response!
845946
_update_webhook(params: UpdateWebhookRequest!): Response!
846947
_delete_webhook(params: WebhookRequest!): Response!
948+
# Service accounts (machine/workload identity)
949+
_create_service_account(params: CreateServiceAccountRequest!): CreateServiceAccountResponse!
950+
_update_service_account(params: UpdateServiceAccountRequest!): ServiceAccount!
951+
_delete_service_account(params: ServiceAccountRequest!): Response!
952+
_rotate_service_account_secret(params: ServiceAccountRequest!): CreateServiceAccountResponse!
953+
# Trusted issuers (external JWT issuers bound to a service account)
954+
_add_trusted_issuer(params: AddTrustedIssuerRequest!): TrustedIssuer!
955+
_update_trusted_issuer(params: UpdateTrustedIssuerRequest!): TrustedIssuer!
956+
_delete_trusted_issuer(params: TrustedIssuerRequest!): Response!
847957
_test_endpoint(params: TestEndpointRequest!): TestEndpointResponse!
848958
_add_email_template(params: AddEmailTemplateRequest!): Response!
849959
_update_email_template(params: UpdateEmailTemplateRequest!): Response!
@@ -874,6 +984,12 @@ type Query {
874984
_webhook(params: WebhookRequest!): Webhook!
875985
_webhooks(params: PaginatedRequest): Webhooks!
876986
_webhook_logs(params: ListWebhookLogRequest): WebhookLogs!
987+
# Service accounts (machine/workload identity)
988+
_service_account(params: ServiceAccountRequest!): ServiceAccount!
989+
_service_accounts(params: ListServiceAccountsRequest): ServiceAccounts!
990+
# Trusted issuers
991+
_trusted_issuer(params: TrustedIssuerRequest!): TrustedIssuer!
992+
_trusted_issuers(params: ListTrustedIssuersRequest): TrustedIssuers!
877993
_email_templates(params: PaginatedRequest): EmailTemplates!
878994
_audit_logs(params: ListAuditLogRequest): AuditLogs!
879995
# FGA admin queries (super-admin only)

internal/graph/schema.resolvers.go

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
6+
"github.com/authorizerdev/authorizer/internal/graph/model"
7+
"github.com/authorizerdev/authorizer/internal/metrics"
8+
"github.com/authorizerdev/authorizer/internal/service"
9+
"github.com/authorizerdev/authorizer/internal/utils"
10+
)
11+
12+
// AddTrustedIssuer delegates to the transport-agnostic service layer. Resolver
13+
// is a thin transport adapter.
14+
//
15+
// Permissions: authorizer:admin
16+
func (g *graphqlProvider) AddTrustedIssuer(ctx context.Context, params *model.AddTrustedIssuerRequest) (*model.TrustedIssuer, error) {
17+
gc, err := utils.GinContextFromContext(ctx)
18+
if err != nil {
19+
g.Log.Debug().Err(err).Msg("failed to get gin context")
20+
metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql")
21+
return nil, err
22+
}
23+
res, _, err := g.adminService().AddTrustedIssuer(ctx, service.MetaFromGin(gc), params)
24+
return res, err
25+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
6+
"github.com/authorizerdev/authorizer/internal/graph/model"
7+
"github.com/authorizerdev/authorizer/internal/metrics"
8+
"github.com/authorizerdev/authorizer/internal/service"
9+
"github.com/authorizerdev/authorizer/internal/utils"
10+
)
11+
12+
// CreateServiceAccount delegates to the transport-agnostic service layer.
13+
// Resolver is a thin transport adapter.
14+
//
15+
// Permissions: authorizer:admin
16+
func (g *graphqlProvider) CreateServiceAccount(ctx context.Context, params *model.CreateServiceAccountRequest) (*model.CreateServiceAccountResponse, error) {
17+
gc, err := utils.GinContextFromContext(ctx)
18+
if err != nil {
19+
g.Log.Debug().Err(err).Msg("failed to get gin context")
20+
metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql")
21+
return nil, err
22+
}
23+
res, _, err := g.adminService().CreateServiceAccount(ctx, service.MetaFromGin(gc), params)
24+
return res, err
25+
}

0 commit comments

Comments
 (0)