From 9fd11afa002b2eba5ee939e29b11b8b5a650ca67 Mon Sep 17 00:00:00 2001 From: "Claude (gsc-ops-api init)" Date: Mon, 1 Jun 2026 11:13:33 +0200 Subject: [PATCH] feat: dynamic, scoped API keys (+ restore cmd/server entrypoint) Validate X-API-Key against a DB-backed, self-managed key store in addition to the static Infisical keys, so new consumers (e.g. gsc_admin) no longer require a rebuild. Keys carry scopes (e.g. {ldap:read}); the required scope is derived per-request from path + method and enforced by ScopeEnforce. Static Infisical keys keep an implicit wildcard scope (no regression). - service/apikey.go: DB store (admin.api_keys, SHA-256 hashes only), 30s validation cache, generate/list/revoke. EnsureSchema is existence-first (to_regclass) so a least-privilege DB role starts cleanly when the table is provisioned out-of-band; startup is non-fatal if the store is absent. - handler/apikeys.go + routes: POST/GET/DELETE /api/v1/admin/api-keys. - middleware/apikey.go: APIKeyWithValidator + Principal + ScopeEnforce. - pkg/types/scopes.go: scope vocabulary + matching. - migrations/002_api_keys.sql. Also restore cmd/server/main.go, which the `.gitignore` `server` pattern was silently excluding (it matched cmd/server/); anchored that pattern and `gsc-ops-api` to the repo root so only the built binaries are ignored. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 +- cmd/server/main.go | 338 ++++++++++++++++++++++++++++++++++ internal/handler/apikeys.go | 85 +++++++++ internal/middleware/apikey.go | 92 ++++++++- internal/router/router.go | 20 +- internal/service/apikey.go | 253 +++++++++++++++++++++++++ migrations/002_api_keys.sql | 27 +++ pkg/types/apikey.go | 25 +++ pkg/types/scopes.go | 79 ++++++++ 9 files changed, 912 insertions(+), 11 deletions(-) create mode 100644 cmd/server/main.go create mode 100644 internal/handler/apikeys.go create mode 100644 internal/service/apikey.go create mode 100644 migrations/002_api_keys.sql create mode 100644 pkg/types/apikey.go create mode 100644 pkg/types/scopes.go diff --git a/.gitignore b/.gitignore index 02ae518..86ae608 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Build output bin/ -gsc-ops-api -server +/gsc-ops-api +/server *.bak *.bak.* diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..d16aa10 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,338 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog" + + "github.com/gosec/gsc-ops-api/internal/client" + "github.com/gosec/gsc-ops-api/internal/config" + "github.com/gosec/gsc-ops-api/internal/database" + "github.com/gosec/gsc-ops-api/internal/handler" + "github.com/gosec/gsc-ops-api/internal/router" + "github.com/gosec/gsc-ops-api/internal/schema" + "github.com/gosec/gsc-ops-api/internal/service" +) + +const ( + appName = "gsc-ops-api" + appVersion = "1.0.0" +) + +func main() { + // Logger + logger := zerolog.New(os.Stdout).With(). + Timestamp(). + Str("app", appName). + Str("version", appVersion). + Logger() + + // Load configuration + configPath := os.Getenv("GSC_OPS_API_CONFIG") + if configPath == "" { + configPath = "/etc/gsc-ops-api/config.yaml" + } + + cfg, err := config.Load(configPath) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to load config") + } + + // Set log level + level, err := zerolog.ParseLevel(cfg.Logging.Level) + if err == nil { + zerolog.SetGlobalLevel(level) + } + + // Load secrets from Infisical + if err := cfg.LoadSecretsFromInfisical(); err != nil { + logger.Warn().Err(err).Msg("Failed to load secrets from Infisical") + } + + // Connect to databases + var coreDB, pbxDB, voiceAgentDB, personaDB *database.DB + + if cfg.HasDatabase("core") { + coreDB, err = database.NewNamed(cfg, "core") + if err != nil { + logger.Fatal().Err(err).Str("db", "core").Msg("Failed to connect to database") + } + defer coreDB.Close() + logger.Info().Str("db", "gsc_core").Msg("Connected to database") + } + + if cfg.HasDatabase("pbx") { + pbxDB, err = database.NewNamed(cfg, "pbx") + if err != nil { + logger.Fatal().Err(err).Str("db", "pbx").Msg("Failed to connect to database") + } + defer pbxDB.Close() + logger.Info().Str("db", "gsc_pbx").Msg("Connected to database") + } + + if cfg.HasDatabase("voice_agent") { + voiceAgentDB, err = database.NewNamed(cfg, "voice_agent") + if err != nil { + logger.Fatal().Err(err).Str("db", "voice_agent").Msg("Failed to connect to database") + } + defer voiceAgentDB.Close() + logger.Info().Str("db", "gsc_voice_agent").Msg("Connected to database") + } + + if cfg.HasDatabase("persona") { + personaDB, err = database.NewNamed(cfg, "persona") + if err != nil { + logger.Fatal().Err(err).Str("db", "persona").Msg("Failed to connect to database") + } + defer personaDB.Close() + logger.Info().Str("db", "gsc_persona").Msg("Connected to database") + } + + // Fallback to legacy single database if named databases not configured + var db *database.DB + if coreDB == nil || pbxDB == nil || voiceAgentDB == nil { + db, err = database.New(cfg) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to connect to legacy database") + } + defer db.Close() + logger.Info().Msg("Connected to legacy database (gsc_admin)") + if coreDB == nil { + coreDB = db + } + if pbxDB == nil { + pbxDB = db + } + if voiceAgentDB == nil { + voiceAgentDB = db + } + } else { + // Use coreDB as the primary for health checks + db = coreDB + } + + // Initialize LDAP client + var ldapClient *client.LDAPClient + if len(cfg.LDAP.Servers) > 0 { + ldapClient, err = client.NewLDAPClient(cfg.LDAP, logger) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to create LDAP client") + } + defer ldapClient.Close() + logger.Info().Int("poolSize", cfg.LDAP.PoolSize).Msg("Connected to LDAP") + } + + // Initialize PowerDNS client + var pdnsClient *client.PowerDNSClient + if cfg.PowerDNS.BaseURL != "" { + pdnsClient = client.NewPowerDNSClient(cfg.PowerDNS, logger) + logger.Info().Str("url", cfg.PowerDNS.BaseURL).Msg("PowerDNS client initialized") + } + + // Initialize EJBCA client + var ejbcaClient *client.EJBCAClient + if cfg.EJBCA.BaseURL != "" { + ejbcaClient, err = client.NewEJBCAClient(cfg.EJBCA, logger) + if err != nil { + logger.Warn().Err(err).Msg("Failed to create EJBCA client (certificates disabled)") + } else { + logger.Info().Str("url", cfg.EJBCA.BaseURL).Msg("EJBCA client initialized") + } + } + + // Initialize Hockeypuck client + var hkpClient *client.HockeypuckClient + if len(cfg.Hockeypuck.Servers) > 0 { + hkpClient = client.NewHockeypuckClient(cfg.Hockeypuck, logger) + logger.Info().Int("servers", len(cfg.Hockeypuck.Servers)).Msg("Hockeypuck client initialized") + } + + // Initialize CardDAV client + var carddavClient *client.CardDAVClient + if cfg.CardDAV.Host != "" { + carddavClient, err = client.NewCardDAVClient(cfg.CardDAV, cfg.CardDAVDSN(), logger) + if err != nil { + logger.Warn().Err(err).Msg("Failed to create CardDAV client (carddav disabled)") + } else { + defer carddavClient.Close() + logger.Info().Str("host", cfg.CardDAV.Host).Str("database", cfg.CardDAV.Database).Msg("CardDAV client initialized") + } + } + + // Initialize Asterisk AMI client + var asteriskClient *client.AsteriskClient + if len(cfg.Asterisk.Servers) > 0 { + astServers := make([]client.AsteriskServer, len(cfg.Asterisk.Servers)) + for i, s := range cfg.Asterisk.Servers { + astServers[i] = client.AsteriskServer{Host: s.Host, AMIPort: s.AMIPort} + } + asteriskClient = client.NewAsteriskClient(astServers, cfg.Asterisk.AMIUser, cfg.Asterisk.AMISecret, logger) + logger.Info().Int("servers", len(cfg.Asterisk.Servers)).Msg("Asterisk AMI client initialized") + } + + // Initialize Kamailio client + var kamailioClient *client.KamailioClient + if len(cfg.Kamailio.Servers) > 0 { + var sshKey []byte + if cfg.Kamailio.SSHKey != "" { + sshKey, _ = os.ReadFile(cfg.Kamailio.SSHKey) + } + kamailioClient = client.NewKamailioClient(cfg.Kamailio.Servers, cfg.Kamailio.SSHUser, sshKey, logger) + logger.Info().Int("servers", len(cfg.Kamailio.Servers)).Msg("Kamailio SSH client initialized") + } + + // Create schema registry + registry := schema.NewRegistry() + logger.Info().Int("attrs", len(registry.AllUserAttrs())).Int("entities", len(registry.AllEntityTypes())).Msg("Schema registry initialized") + + // Create services + ldapSvc := service.NewLDAPService(ldapClient, cfg.LDAP.BaseDN, logger, registry) + ldapEntitySvc := service.NewLDAPEntityService(ldapClient, cfg.LDAP.BaseDN, registry, logger) + dnsSvc := service.NewDNSService(pdnsClient, logger) + dbSvc := service.NewDatabaseService(coreDB.Pool(), logger) + certSvc := service.NewCertificateService(ejbcaClient, logger) + pgpSvc := service.NewPGPService(hkpClient, logger) + + var carddavSvc *service.CardDAVService + if carddavClient != nil { + carddavSvc = service.NewCardDAVService(carddavClient.Pool(), logger) + } + + pbxSvc := service.NewPBXService(pbxDB.Pool(), asteriskClient, kamailioClient, logger) + voiceAgentSvc := service.NewVoiceAgentService(voiceAgentDB.Pool(), logger) + personalAgentSvc := service.NewPersonalAgentService(coreDB.Pool(), logger) + + var personaSvc *service.PersonaService + if personaDB != nil { + personaSvc = service.NewPersonaService(personaDB.Pool(), logger) + } + + // Dynamic API-key store (self-managed keys, validated alongside the static + // keys loaded from Infisical). Backed by the core DB. A failure here is + // non-fatal: the service stays up on the static keys and picks up dynamic + // keys once the table is reachable (the cache reloads periodically). + apiKeySvc := service.NewAPIKeyService(coreDB.Pool(), logger) + if err := apiKeySvc.EnsureSchema(context.Background()); err != nil { + logger.Error().Err(err).Msg("API-key store unavailable — dynamic keys disabled until the table is provisioned") + } else { + logger.Info().Msg("Dynamic API-key store initialized") + } + + // Create handlers + healthHandler := handler.NewHealthHandler(db, ldapClient, pdnsClient, carddavClient) + ldapUserHandler := handler.NewLDAPUserHandler(ldapSvc) + ldapGroupHandler := handler.NewLDAPGroupHandler(ldapSvc) + ldapEntityHandler := handler.NewLDAPEntityHandler(ldapEntitySvc, registry) + dnsZoneHandler := handler.NewDNSZoneHandler(dnsSvc) + dnsRecordHandler := handler.NewDNSRecordHandler(dnsSvc) + dbTenantHandler := handler.NewDBTenantHandler(dbSvc) + dbUserHandler := handler.NewDBUserHandler(dbSvc) + certHandler := handler.NewCertHandler(certSvc) + pgpHandler := handler.NewPGPHandler(pgpSvc) + + var carddavHandler *handler.CardDAVHandler + if carddavSvc != nil { + carddavHandler = handler.NewCardDAVHandler(carddavSvc) + } + + pbxHandler := handler.NewPBXHandler(pbxSvc) + voiceAgentHandler := handler.NewVoiceAgentHandler(voiceAgentSvc) + personalAgentHandler := handler.NewPersonalAgentHandler(personalAgentSvc) + + var personaHandler *handler.PersonaHandler + if personaSvc != nil { + personaHandler = handler.NewPersonaHandler(personaSvc) + } + + apiKeyHandler := handler.NewAPIKeyHandler(apiKeySvc) + + // Create Fiber app + app := fiber.New(fiber.Config{ + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + AppName: appName, + }) + + // Setup routes + router.Setup(app, &router.Config{ + Logger: logger, + APIKeys: cfg.Auth.APIKeys, + APIKeyValidate: apiKeySvc.Validate, + APIKeysAdmin: apiKeyHandler, + Health: healthHandler, + LDAPUsers: ldapUserHandler, + LDAPGroups: ldapGroupHandler, + LDAPEntities: ldapEntityHandler, + DNSZones: dnsZoneHandler, + DNSRecords: dnsRecordHandler, + DBTenants: dbTenantHandler, + DBUsers: dbUserHandler, + Certs: certHandler, + PGP: pgpHandler, + CardDAV: carddavHandler, + PBX: pbxHandler, + VoiceAgent: voiceAgentHandler, + PersonalAgent: personalAgentHandler, + Persona: personaHandler, + }) + + // Load TLS config + tlsConfig, err := loadTLSConfig(cfg) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to load TLS config") + } + + // Start server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + logger.Info().Str("addr", addr).Msg("Starting server") + + // Graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + logger.Info().Str("signal", sig.String()).Msg("Shutting down") + app.Shutdown() + }() + + // Start HTTPS with mTLS + ln, err := tls.Listen("tcp", addr, tlsConfig) + if err != nil { + logger.Fatal().Err(err).Msg("Failed to create TLS listener") + } + + if err := app.Listener(ln); err != nil { + logger.Fatal().Err(err).Msg("Server error") + } +} + +func loadTLSConfig(cfg *config.Config) (*tls.Config, error) { + cert, err := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load server certificate: %w", err) + } + + caCert, err := os.ReadFile(cfg.TLS.CAFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse CA certificate") + } + + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }, nil +} diff --git a/internal/handler/apikeys.go b/internal/handler/apikeys.go new file mode 100644 index 0000000..fb7523d --- /dev/null +++ b/internal/handler/apikeys.go @@ -0,0 +1,85 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/gosec/gsc-ops-api/internal/middleware" + "github.com/gosec/gsc-ops-api/internal/service" + "github.com/gosec/gsc-ops-api/pkg/types" +) + +// APIKeyHandler exposes dynamic API-key management. These routes sit behind the +// same API-key middleware as the rest of /api/v1, so an existing valid key +// bootstraps the first dynamic ones. +type APIKeyHandler struct { + svc *service.APIKeyService +} + +// NewAPIKeyHandler creates a new API-key handler. +func NewAPIKeyHandler(svc *service.APIKeyService) *APIKeyHandler { + return &APIKeyHandler{svc: svc} +} + +// Create handles POST /api/v1/admin/api-keys — mints a key and returns the +// plaintext exactly once. +func (h *APIKeyHandler) Create(c *fiber.Ctx) error { + reqID := middleware.GetRequestID(c) + + var req types.APIKeyCreateRequest + if err := c.BodyParser(&req); err != nil { + apiErr := types.NewBadRequest("Invalid request body: " + err.Error()) + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + if req.Name == "" { + apiErr := types.NewValidation("name is required") + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + if err := types.ValidateScopes(req.Scopes); err != nil { + apiErr := types.NewValidation(err.Error()) + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + + createdBy := "" + if claims := middleware.GetJWTClaims(c); claims != nil { + createdBy = claims.Subject + } + + info, err := h.svc.Generate(c.Context(), req.Name, req.Scopes, createdBy) + if err != nil { + apiErr := types.NewConflict(err.Error()) + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + + return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(info, reqID)) +} + +// List handles GET /api/v1/admin/api-keys. +func (h *APIKeyHandler) List(c *fiber.Ctx) error { + reqID := middleware.GetRequestID(c) + + keys, err := h.svc.List(c.Context()) + if err != nil { + apiErr := types.NewInternal(err.Error()) + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + + return c.JSON(types.NewPagedResponse(keys, int64(len(keys)), len(keys), 0, reqID)) +} + +// Revoke handles DELETE /api/v1/admin/api-keys/:id. +func (h *APIKeyHandler) Revoke(c *fiber.Ctx) error { + reqID := middleware.GetRequestID(c) + + id := c.Params("id") + if id == "" { + apiErr := types.NewBadRequest("id is required") + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + + if err := h.svc.Revoke(c.Context(), id); err != nil { + apiErr := types.NewNotFound(err.Error()) + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID)) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go index 584b3ff..612f7d7 100644 --- a/internal/middleware/apikey.go +++ b/internal/middleware/apikey.go @@ -2,6 +2,7 @@ package middleware import ( "crypto/subtle" + "strings" "github.com/gofiber/fiber/v2" @@ -10,8 +11,40 @@ import ( const APIKeyHeader = "X-API-Key" -// APIKey validates the X-API-Key header against configured keys +// principalKey is the Locals key under which the authenticated principal is +// stored after a successful API-key check. +const principalKey = "principal" + +// Principal is the authenticated caller behind an API key. +type Principal struct { + Name string + Scopes []string +} + +// GetPrincipal returns the authenticated principal, or nil. +func GetPrincipal(c *fiber.Ctx) *Principal { + if p, ok := c.Locals(principalKey).(*Principal); ok { + return p + } + return nil +} + +// APIKeyValidator reports whether a presented key is valid and, if so, the +// name and scopes it was issued under. Used to back the static key list with a +// dynamic, self-managed key store. +type APIKeyValidator func(key string) (name string, scopes []string, ok bool) + +// APIKey validates the X-API-Key header against a fixed list of keys. func APIKey(validKeys []string) fiber.Handler { + return APIKeyWithValidator(validKeys, nil) +} + +// APIKeyWithValidator validates the X-API-Key header against a fixed list of +// keys first (constant-time), then — if no static key matched — against an +// optional dynamic validator (e.g. the DB-backed APIKeyService). Static keys +// are trusted fully (wildcard scope); dynamic keys carry their own scopes, +// enforced downstream by ScopeEnforce. +func APIKeyWithValidator(validKeys []string, validate APIKeyValidator) fiber.Handler { return func(c *fiber.Ctx) error { key := c.Get(APIKeyHeader) if key == "" { @@ -19,19 +52,64 @@ func APIKey(validKeys []string) fiber.Handler { return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } - valid := false for _, vk := range validKeys { if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 { - valid = true - break + c.Locals(principalKey, &Principal{Name: "static", Scopes: []string{types.WildcardScope}}) + return c.Next() } } - if !valid { - apiErr := types.NewUnauthorized("Invalid API key") + if validate != nil { + if name, scopes, ok := validate(key); ok { + c.Locals(principalKey, &Principal{Name: name, Scopes: scopes}) + return c.Next() + } + } + + apiErr := types.NewUnauthorized("Invalid API key") + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) + } +} + +// ScopeFor derives the scope a request requires from its method and path. +// GET => read, any other method => write. The resource is the first path +// segment after /api/v1; the API-key management routes map to apikeys:admin. +// Returns "" when no scope can be derived (caller decides how to treat that). +func ScopeFor(method, path string) string { + p := strings.TrimPrefix(path, "/api/v1/") + p = strings.TrimPrefix(p, "/") + if p == "" { + return "" + } + segs := strings.Split(p, "/") + resource := segs[0] + if resource == "admin" && len(segs) > 1 && segs[1] == "api-keys" { + return types.ManageScope + } + action := "write" + if method == fiber.MethodGet { + action = "read" + } + return resource + ":" + action +} + +// ScopeEnforce authorises the authenticated principal for the requested route. +// Principals with the wildcard scope (the static Infisical keys) bypass; scoped +// dynamic keys must hold the scope ScopeFor derives, else 403. +func ScopeEnforce() fiber.Handler { + return func(c *fiber.Ctx) error { + p := GetPrincipal(c) + if p == nil { + apiErr := types.NewUnauthorized("Not authenticated") return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } - return c.Next() + required := ScopeFor(c.Method(), c.Path()) + if required == "" || types.ScopeSatisfied(p.Scopes, required) { + return c.Next() + } + + apiErr := types.NewForbidden("API key lacks the required scope: " + required) + return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) } } diff --git a/internal/router/router.go b/internal/router/router.go index 29c0d4d..93bb325 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,6 +13,10 @@ import ( type Config struct { Logger zerolog.Logger APIKeys []string + // APIKeyValidate backs the static APIKeys list with a dynamic, self-managed + // key store so new consumers can be added at runtime without a rebuild. + APIKeyValidate middleware.APIKeyValidator + APIKeysAdmin *handler.APIKeyHandler Health *handler.HealthHandler LDAPUsers *handler.LDAPUserHandler LDAPGroups *handler.LDAPGroupHandler @@ -42,8 +46,20 @@ func Setup(app *fiber.App, cfg *Config) { app.Get("/health", cfg.Health.Liveness) app.Get("/ready", cfg.Health.Readiness) - // API v1 routes (API key required) - api := app.Group("/api/v1", middleware.APIKey(cfg.APIKeys)) + // API v1 routes: authenticate (static keys + dynamic key store), then + // enforce per-key scopes. Static keys carry the wildcard scope and bypass. + api := app.Group("/api/v1", + middleware.APIKeyWithValidator(cfg.APIKeys, cfg.APIKeyValidate), + middleware.ScopeEnforce(), + ) + + // Dynamic API-key management (bootstrapped by any existing valid key) + if cfg.APIKeysAdmin != nil { + apiKeys := api.Group("/admin/api-keys") + apiKeys.Get("/", cfg.APIKeysAdmin.List) + apiKeys.Post("/", cfg.APIKeysAdmin.Create) + apiKeys.Delete("/:id", cfg.APIKeysAdmin.Revoke) + } // LDAP Users ldapUsers := api.Group("/ldap/users") diff --git a/internal/service/apikey.go b/internal/service/apikey.go new file mode 100644 index 0000000..935d213 --- /dev/null +++ b/internal/service/apikey.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/rs/zerolog" + + "github.com/gosec/gsc-ops-api/pkg/types" +) + +// keyPrefix is the human-readable prefix on every generated key. It lets +// operators recognise an ops-api key at a glance and is stored (with a few +// more chars) for identification without revealing the secret. +const keyPrefix = "gops_" + +// cacheTTL bounds how stale the in-memory validation set may be. A newly +// minted or revoked key takes effect immediately for the node that served the +// request (the cache is refreshed inline on Generate/Revoke) and within one +// TTL on every other node. +const cacheTTL = 30 * time.Second + +// keyMeta is the cached metadata for an active key. +type keyMeta struct { + name string + scopes []string +} + +// APIKeyService manages dynamically-issued, scoped API keys: generation, +// revocation and validation. Validation is served from an in-memory cache of +// active key hashes so the request hot path never blocks on the database. +type APIKeyService struct { + pool *pgxpool.Pool + logger zerolog.Logger + + mu sync.RWMutex + byHash map[string]keyMeta // sha256(key) -> metadata + loadedAt time.Time +} + +// NewAPIKeyService creates the service. Call EnsureSchema before serving. +func NewAPIKeyService(pool *pgxpool.Pool, logger zerolog.Logger) *APIKeyService { + return &APIKeyService{ + pool: pool, + logger: logger.With().Str("service", "apikey").Logger(), + byHash: make(map[string]keyMeta), + } +} + +// EnsureSchema makes the backing table available, then loads the cache. +// +// It first checks for the table with a privilege-light to_regclass lookup so a +// least-privilege DB user (no CREATE on the admin schema) starts cleanly when +// the table has been provisioned out-of-band. Only if the table is absent does +// it attempt the DDL (which requires CREATE). Idempotent. +func (s *APIKeyService) EnsureSchema(ctx context.Context) error { + var reg *string + if err := s.pool.QueryRow(ctx, `SELECT to_regclass('admin.api_keys')::text`).Scan(®); err == nil && reg != nil { + return s.reload(ctx) + } + + const ddl = ` +CREATE TABLE IF NOT EXISTS admin.api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL UNIQUE, + key_hash text NOT NULL UNIQUE, + key_prefix text NOT NULL, + scopes text[] NOT NULL DEFAULT '{}', + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + created_by text +); +CREATE INDEX IF NOT EXISTS idx_api_keys_active ON admin.api_keys (active) WHERE active;` + if _, err := s.pool.Exec(ctx, ddl); err != nil { + return fmt.Errorf("ensure api_keys schema: %w", err) + } + return s.reload(ctx) +} + +func hashKey(key string) string { + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +// reload refreshes the in-memory hash set from the database. +func (s *APIKeyService) reload(ctx context.Context) error { + rows, err := s.pool.Query(ctx, `SELECT key_hash, name, scopes FROM admin.api_keys WHERE active`) + if err != nil { + return fmt.Errorf("load api keys: %w", err) + } + defer rows.Close() + + next := make(map[string]keyMeta) + for rows.Next() { + var h, name string + var scopes []string + if err := rows.Scan(&h, &name, &scopes); err != nil { + return fmt.Errorf("scan api key: %w", err) + } + next[h] = keyMeta{name: name, scopes: scopes} + } + if err := rows.Err(); err != nil { + return err + } + + s.mu.Lock() + s.byHash = next + s.loadedAt = time.Now() + s.mu.Unlock() + return nil +} + +// Validate reports whether the presented key is an active, dynamically-issued +// key, returning the key's name and scopes. It refreshes the cache when stale. +// The comparison is constant-time per candidate to avoid leaking which hash (if +// any) matched via timing. +func (s *APIKeyService) Validate(key string) (string, []string, bool) { + if key == "" { + return "", nil, false + } + + s.mu.RLock() + stale := time.Since(s.loadedAt) > cacheTTL + s.mu.RUnlock() + if stale { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if err := s.reload(ctx); err != nil { + s.logger.Warn().Err(err).Msg("api key cache refresh failed; serving stale set") + } + cancel() + } + + h := hashKey(key) + s.mu.RLock() + meta, ok := s.byHash[h] + // Touch every entry with a constant-time compare so total work does not + // depend on whether/where a match occurred. + matched := false + for candidate := range s.byHash { + if subtle.ConstantTimeCompare([]byte(h), []byte(candidate)) == 1 { + matched = true + } + } + s.mu.RUnlock() + + if !ok || !matched { + return "", nil, false + } + s.touch(h) + return meta.name, meta.scopes, true +} + +// touch updates last_used_at best-effort, without blocking the caller. +func (s *APIKeyService) touch(hash string) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _, _ = s.pool.Exec(ctx, + `UPDATE admin.api_keys SET last_used_at = now() + WHERE key_hash = $1 AND (last_used_at IS NULL OR last_used_at < now() - interval '1 minute')`, + hash) + }() +} + +// Generate mints a new scoped key under the given name, stores its hash, and +// returns the plaintext exactly once. Names are unique; reusing one is a +// conflict. Scopes are validated by the caller (handler) via ValidateScopes. +func (s *APIKeyService) Generate(ctx context.Context, name string, scopes []string, createdBy string) (*types.APIKeyInfo, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("name is required") + } + + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return nil, fmt.Errorf("generate random key: %w", err) + } + plaintext := keyPrefix + hex.EncodeToString(buf) + prefix := plaintext[:len(keyPrefix)+8] + hash := hashKey(plaintext) + + var info types.APIKeyInfo + err := s.pool.QueryRow(ctx, + `INSERT INTO admin.api_keys (name, key_hash, key_prefix, scopes, created_by) + VALUES ($1, $2, $3, $4, NULLIF($5, '')) + RETURNING id, name, key_prefix, scopes, active, created_at, created_by`, + name, hash, prefix, scopes, createdBy). + Scan(&info.ID, &info.Name, &info.Prefix, &info.Scopes, &info.Active, &info.CreatedAt, &info.CreatedBy) + if err != nil { + if strings.Contains(err.Error(), "api_keys_name_key") { + return nil, fmt.Errorf("an API key named %q already exists", name) + } + return nil, fmt.Errorf("insert api key: %w", err) + } + + if err := s.reload(ctx); err != nil { + s.logger.Warn().Err(err).Msg("cache refresh after generate failed") + } + info.Plaintext = plaintext + s.logger.Info().Str("name", name).Str("prefix", prefix).Strs("scopes", scopes).Str("by", createdBy).Msg("Generated API key") + return &info, nil +} + +// List returns all keys (without plaintext or hash). +func (s *APIKeyService) List(ctx context.Context) ([]types.APIKeyInfo, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, name, key_prefix, scopes, active, created_at, created_by, last_used_at + FROM admin.api_keys ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("list api keys: %w", err) + } + defer rows.Close() + + out := []types.APIKeyInfo{} + for rows.Next() { + var info types.APIKeyInfo + var createdBy *string + if err := rows.Scan(&info.ID, &info.Name, &info.Prefix, &info.Scopes, &info.Active, &info.CreatedAt, &createdBy, &info.LastUsedAt); err != nil { + return nil, fmt.Errorf("scan api key: %w", err) + } + if createdBy != nil { + info.CreatedBy = *createdBy + } + out = append(out, info) + } + return out, rows.Err() +} + +// Revoke deactivates a key by id. The change takes effect immediately on this +// node and within one cache TTL elsewhere. +func (s *APIKeyService) Revoke(ctx context.Context, id string) error { + tag, err := s.pool.Exec(ctx, `UPDATE admin.api_keys SET active = false WHERE id = $1 AND active`, id) + if err != nil { + return fmt.Errorf("revoke api key: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("no active API key with id %q", id) + } + if err := s.reload(ctx); err != nil { + s.logger.Warn().Err(err).Msg("cache refresh after revoke failed") + } + s.logger.Info().Str("id", id).Msg("Revoked API key") + return nil +} diff --git a/migrations/002_api_keys.sql b/migrations/002_api_keys.sql new file mode 100644 index 0000000..2aaca7c --- /dev/null +++ b/migrations/002_api_keys.sql @@ -0,0 +1,27 @@ +-- 002_api_keys.sql +-- Dynamic, self-managed API keys for ops-api consumers. +-- +-- ops-api validates the X-API-Key header against (a) the static keys loaded +-- from Infisical and (b) the active rows in this table. New consumers can be +-- minted at runtime via POST /api/v1/admin/api-keys — no rebuild required. +-- +-- Applied automatically at startup by APIKeyService.EnsureSchema(); kept here +-- as the canonical record. Only the SHA-256 hash of each key is stored; the +-- plaintext is returned exactly once at creation time. + +-- `scopes` limits which calls a key may make (e.g. {ldap:read}); the static +-- Infisical keys carry an implicit wildcard. See pkg/types/scopes.go. + +CREATE TABLE IF NOT EXISTS admin.api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL UNIQUE, + key_hash text NOT NULL UNIQUE, + key_prefix text NOT NULL, + scopes text[] NOT NULL DEFAULT '{}', + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + last_used_at timestamptz, + created_by text +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_active ON admin.api_keys (active) WHERE active; diff --git a/pkg/types/apikey.go b/pkg/types/apikey.go new file mode 100644 index 0000000..1ad2a62 --- /dev/null +++ b/pkg/types/apikey.go @@ -0,0 +1,25 @@ +package types + +import "time" + +// APIKeyInfo describes a dynamically-managed API key. The plaintext key value +// is NEVER stored or returned except once, at creation time, via Plaintext. +type APIKeyInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Scopes []string `json:"scopes"` + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + // Plaintext is populated only in the response to a create call. + Plaintext string `json:"key,omitempty"` +} + +// APIKeyCreateRequest is the body for minting a new key. Scopes limit which +// calls the key may make (see ValidateScopes). +type APIKeyCreateRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` +} diff --git a/pkg/types/scopes.go b/pkg/types/scopes.go new file mode 100644 index 0000000..b0c3907 --- /dev/null +++ b/pkg/types/scopes.go @@ -0,0 +1,79 @@ +package types + +import ( + "fmt" + "strings" +) + +// Scopes limit what a dynamically-issued API key may call. A scope is either: +// - the wildcard "*" (all access — held only by the static Infisical keys), +// - a resource wildcard ":*", +// - a concrete ":read" / ":write", or +// - the management scope "apikeys:admin". +// +// Required scopes are derived per-request from the path's first segment and the +// HTTP method (GET => read, everything else => write); see middleware.ScopeFor. + +// ScopeResources is the set of resource prefixes that map 1:1 to the /api/v1 +// route groups. Keep in sync with router.Setup. +var ScopeResources = []string{ + "ldap", "dns", "db", "certs", "pgp", "carddav", + "pbx", "voice-agents", "agents", "personas", +} + +// ManageScope is required to create/list/revoke API keys. +const ManageScope = "apikeys:admin" + +// WildcardScope grants every scope. +const WildcardScope = "*" + +func resourceKnown(r string) bool { + for _, k := range ScopeResources { + if k == r { + return true + } + } + return false +} + +// ValidateScopes checks that every requested scope is well-formed and known. +// An empty list is rejected — a key with no scopes can call nothing and is +// almost certainly a mistake. +func ValidateScopes(scopes []string) error { + if len(scopes) == 0 { + return fmt.Errorf("at least one scope is required") + } + for _, s := range scopes { + if s == WildcardScope || s == ManageScope { + continue + } + res, action, ok := strings.Cut(s, ":") + if !ok || !resourceKnown(res) { + return fmt.Errorf("unknown scope %q", s) + } + switch action { + case "read", "write", "*": + default: + return fmt.Errorf("unknown scope %q (action must be read, write or *)", s) + } + } + return nil +} + +// ScopeSatisfied reports whether the held scopes grant the required scope. +// required is a concrete scope like "ldap:read" or "apikeys:admin". +func ScopeSatisfied(held []string, required string) bool { + if required == "" { + return false + } + reqRes, _, _ := strings.Cut(required, ":") + for _, h := range held { + if h == WildcardScope || h == required { + return true + } + if hRes, hAct, ok := strings.Cut(h, ":"); ok && hAct == "*" && hRes == reqRes { + return true + } + } + return false +} -- 2.49.1