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>
This commit is contained in:
Claude (gsc-ops-api init)
2026-06-01 11:13:33 +02:00
parent 3847eb2036
commit 9fd11afa00
9 changed files with 912 additions and 11 deletions

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
}