Compare commits

...

2 Commits

Author SHA1 Message Date
2de3fb0ead Merge pull request 'Dynamic, scoped API keys (+ restore cmd/server entrypoint)' (#1) from feat/dynamic-scoped-api-keys into main 2026-06-01 09:33:16 +00:00
Claude (gsc-ops-api init)
9fd11afa00 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 <noreply@anthropic.com>
2026-06-01 11:13:33 +02:00
9 changed files with 912 additions and 11 deletions

4
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# Build output # Build output
bin/ bin/
gsc-ops-api /gsc-ops-api
server /server
*.bak *.bak
*.bak.* *.bak.*

338
cmd/server/main.go Normal file
View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -2,6 +2,7 @@ package middleware
import ( import (
"crypto/subtle" "crypto/subtle"
"strings"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -10,8 +11,40 @@ import (
const APIKeyHeader = "X-API-Key" 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 { 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 { return func(c *fiber.Ctx) error {
key := c.Get(APIKeyHeader) key := c.Get(APIKeyHeader)
if key == "" { if key == "" {
@@ -19,19 +52,64 @@ func APIKey(validKeys []string) fiber.Handler {
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c))) return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c)))
} }
valid := false
for _, vk := range validKeys { for _, vk := range validKeys {
if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 { if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 {
valid = true c.Locals(principalKey, &Principal{Name: "static", Scopes: []string{types.WildcardScope}})
break return c.Next()
} }
} }
if !valid { if validate != nil {
apiErr := types.NewUnauthorized("Invalid API key") 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.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)))
} }
} }

View File

@@ -13,6 +13,10 @@ import (
type Config struct { type Config struct {
Logger zerolog.Logger Logger zerolog.Logger
APIKeys []string 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 Health *handler.HealthHandler
LDAPUsers *handler.LDAPUserHandler LDAPUsers *handler.LDAPUserHandler
LDAPGroups *handler.LDAPGroupHandler LDAPGroups *handler.LDAPGroupHandler
@@ -42,8 +46,20 @@ func Setup(app *fiber.App, cfg *Config) {
app.Get("/health", cfg.Health.Liveness) app.Get("/health", cfg.Health.Liveness)
app.Get("/ready", cfg.Health.Readiness) app.Get("/ready", cfg.Health.Readiness)
// API v1 routes (API key required) // API v1 routes: authenticate (static keys + dynamic key store), then
api := app.Group("/api/v1", middleware.APIKey(cfg.APIKeys)) // 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 // LDAP Users
ldapUsers := api.Group("/ldap/users") ldapUsers := api.Group("/ldap/users")

253
internal/service/apikey.go Normal file
View File

@@ -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(&reg); 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
}

View File

@@ -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;

25
pkg/types/apikey.go Normal file
View File

@@ -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"`
}

79
pkg/types/scopes.go Normal file
View File

@@ -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 "<resource>:*",
// - a concrete "<resource>:read" / "<resource>: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
}