Compare commits
6 Commits
3847eb2036
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66bfb4ebfa | |||
|
|
90f98671fc | ||
| f6a9d5e312 | |||
|
|
30268db4be | ||
| 2de3fb0ead | |||
|
|
9fd11afa00 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# Build output
|
||||
bin/
|
||||
gsc-ops-api
|
||||
server
|
||||
/gsc-ops-api
|
||||
/server
|
||||
*.bak
|
||||
*.bak.*
|
||||
|
||||
|
||||
351
cmd/server/main.go
Normal file
351
cmd/server/main.go
Normal file
@@ -0,0 +1,351 @@
|
||||
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 FreeIPA management-API client (for user/group MUTATIONS).
|
||||
// Reads stay on direct LDAP; writes go through the IPA API so gid/uid
|
||||
// Numbers, ipaUniqueID and objectClasses are assigned by the framework.
|
||||
var ipaClient *client.FreeIPAClient
|
||||
if len(cfg.LDAP.Servers) > 0 {
|
||||
ipaClient, err = client.NewFreeIPAClient(cfg.LDAP.Servers, cfg.LDAP.BindDN, cfg.LDAP.BindPass, cfg.LDAP.CAFile, logger)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("Failed to create FreeIPA API client (LDAP writes disabled)")
|
||||
} else {
|
||||
logger.Info().Int("servers", len(cfg.LDAP.Servers)).Msg("FreeIPA management-API client initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// 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, ipaClient, 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
|
||||
}
|
||||
226
internal/client/freeipa.go
Normal file
226
internal/client/freeipa.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// FreeIPAClient talks to the FreeIPA JSON-RPC management API
|
||||
// (/ipa/session/json). It is used for MUTATIONS (user/group add, modify,
|
||||
// delete, membership, password, enable/disable) because the IPA framework
|
||||
// assigns uidNumber/gidNumber/ipaUniqueID, sets the correct objectClasses and
|
||||
// enforces IPA's own logic — none of which a raw LDAP add does. Reads stay on
|
||||
// direct LDAP (LDAPClient).
|
||||
//
|
||||
// Auth: form login (login_password) with the svc account uid + password →
|
||||
// ipa_session cookie. The cookie is reused and refreshed on 401. Requests fail
|
||||
// over across the configured IPA servers.
|
||||
type FreeIPAClient struct {
|
||||
servers []string // bare hostnames, e.g. fihelvid01.gosec.auth
|
||||
user string // uid (extracted from the bind DN)
|
||||
pass string
|
||||
http *http.Client
|
||||
logger zerolog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
cookie string // "ipa_session=..."
|
||||
active string // hostname the current cookie belongs to
|
||||
}
|
||||
|
||||
// NewFreeIPAClient builds the client from the LDAP config. `servers` are the
|
||||
// ldap(s):// URLs; the IPA API host is the same host on https/443. `bindDN` is
|
||||
// the full svc DN (uid=svc-ops-api,...); the uid is extracted for the API login.
|
||||
func NewFreeIPAClient(servers []string, bindDN, password, caFile string, logger zerolog.Logger) (*FreeIPAClient, error) {
|
||||
hosts := make([]string, 0, len(servers))
|
||||
for _, s := range servers {
|
||||
h := s
|
||||
if i := strings.Index(h, "://"); i >= 0 {
|
||||
h = h[i+3:]
|
||||
}
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 {
|
||||
h = h[:i]
|
||||
}
|
||||
if h != "" {
|
||||
hosts = append(hosts, h)
|
||||
}
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil, fmt.Errorf("no IPA servers configured")
|
||||
}
|
||||
|
||||
uid := bindDN
|
||||
for _, part := range strings.Split(bindDN, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if strings.HasPrefix(strings.ToLower(part), "uid=") {
|
||||
uid = part[4:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if caFile != "" {
|
||||
caCert, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read IPA CA file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caCert) {
|
||||
return nil, fmt.Errorf("parse IPA CA file %s", caFile)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
return &FreeIPAClient{
|
||||
servers: hosts,
|
||||
user: uid,
|
||||
pass: password,
|
||||
http: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg},
|
||||
},
|
||||
logger: logger.With().Str("component", "freeipa").Logger(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// login authenticates against one host and stores the session cookie.
|
||||
func (c *FreeIPAClient) login(host string) error {
|
||||
form := url.Values{"user": {c.user}, "password": {c.pass}}
|
||||
req, err := http.NewRequest("POST",
|
||||
fmt.Sprintf("https://%s/ipa/session/login_password", host),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "text/plain")
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", host))
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("IPA login failed on %s: HTTP %d %s", host, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck.Name == "ipa_session" {
|
||||
c.cookie = ck.Name + "=" + ck.Value
|
||||
c.active = host
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("IPA login on %s returned no ipa_session cookie", host)
|
||||
}
|
||||
|
||||
// ensureSession logs in (trying each server) if there is no current cookie.
|
||||
func (c *FreeIPAClient) ensureSession() error {
|
||||
if c.cookie != "" {
|
||||
return nil
|
||||
}
|
||||
var lastErr error
|
||||
for _, h := range c.servers {
|
||||
if err := c.login(h); err != nil {
|
||||
lastErr = err
|
||||
c.logger.Warn().Err(err).Str("host", h).Msg("IPA login attempt failed")
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("all IPA logins failed: %w", lastErr)
|
||||
}
|
||||
|
||||
type ipaError struct {
|
||||
Code int `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type ipaResponse struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *ipaError `json:"error"`
|
||||
}
|
||||
|
||||
// Command invokes an IPA method. `args` are the positional primary keys (e.g.
|
||||
// the cn or uid); `options` are the keyword options. Returns the raw `result`.
|
||||
// Re-authenticates once on 401. Surfaces IPA's structured error on failure.
|
||||
func (c *FreeIPAClient) Command(method string, args []interface{}, options map[string]interface{}) (json.RawMessage, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if options == nil {
|
||||
options = map[string]interface{}{}
|
||||
}
|
||||
payload, err := json.Marshal(map[string]interface{}{
|
||||
"method": method,
|
||||
"params": []interface{}{args, options},
|
||||
"id": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := c.ensureSession(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, status, err := c.post(payload)
|
||||
if (status == http.StatusUnauthorized) || (err == nil && status == http.StatusForbidden) {
|
||||
// Session expired — re-login once and retry.
|
||||
c.cookie = ""
|
||||
if err2 := c.ensureSession(); err2 != nil {
|
||||
return nil, err2
|
||||
}
|
||||
res, status, err = c.post(payload)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("IPA %s: HTTP %d: %s", method, status, strings.TrimSpace(string(res)))
|
||||
}
|
||||
|
||||
var parsed ipaResponse
|
||||
if err := json.Unmarshal(res, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("IPA %s: invalid response: %w", method, err)
|
||||
}
|
||||
if parsed.Error != nil {
|
||||
return nil, fmt.Errorf("IPA %s: %s (%s)", method, parsed.Error.Message, parsed.Error.Name)
|
||||
}
|
||||
return parsed.Result, nil
|
||||
}
|
||||
|
||||
// post sends one JSON-RPC request to the active server.
|
||||
func (c *FreeIPAClient) post(payload []byte) ([]byte, int, error) {
|
||||
req, err := http.NewRequest("POST",
|
||||
fmt.Sprintf("https://%s/ipa/session/json", c.active),
|
||||
bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Referer", fmt.Sprintf("https://%s/ipa", c.active))
|
||||
req.Header.Set("Cookie", c.cookie)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
return body, resp.StatusCode, err
|
||||
}
|
||||
85
internal/handler/apikeys.go
Normal file
85
internal/handler/apikeys.go
Normal 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)
|
||||
}
|
||||
@@ -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 validate != nil {
|
||||
if name, scopes, ok := validate(key); ok {
|
||||
c.Locals(principalKey, &Principal{Name: name, Scopes: scopes})
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
if !valid {
|
||||
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)))
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
253
internal/service/apikey.go
Normal file
253
internal/service/apikey.go
Normal 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(®); 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
|
||||
}
|
||||
@@ -38,6 +38,15 @@ func (s *CertificateService) ListCertificates(search string, limit int) ([]types
|
||||
Value: search,
|
||||
Operation: "LIKE",
|
||||
})
|
||||
} else {
|
||||
// EJBCA rejects an empty criteria list ("Invalid criteria value,
|
||||
// cannot be empty"). With no search term, default to listing active
|
||||
// certificates so GET /certs returns a useful result instead of 500.
|
||||
criteria = append(criteria, client.CertSearchCriterion{
|
||||
Property: "STATUS",
|
||||
Value: "CERT_ACTIVE",
|
||||
Operation: "EQUAL",
|
||||
})
|
||||
}
|
||||
|
||||
certs, err := s.client.SearchCertificates(&client.CertSearchRequest{
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -13,24 +12,59 @@ import (
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// LDAPService handles FreeIPA user and group operations
|
||||
// LDAPService handles FreeIPA user and group operations. Reads go over direct
|
||||
// LDAP (fast); MUTATIONS go through the FreeIPA management API (ipa) so the IPA
|
||||
// framework assigns uidNumber/gidNumber/ipaUniqueID, sets correct objectClasses
|
||||
// and enforces its own logic — a raw LDAP add cannot do this.
|
||||
type LDAPService struct {
|
||||
client *client.LDAPClient
|
||||
ipa *client.FreeIPAClient
|
||||
baseDN string
|
||||
logger zerolog.Logger
|
||||
registry *schema.Registry
|
||||
}
|
||||
|
||||
// NewLDAPService creates a new LDAP service
|
||||
func NewLDAPService(ldapClient *client.LDAPClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService {
|
||||
// NewLDAPService creates a new LDAP service. ipa may be nil (writes then error
|
||||
// cleanly instead of attempting a raw LDAP mutation).
|
||||
func NewLDAPService(ldapClient *client.LDAPClient, ipa *client.FreeIPAClient, baseDN string, logger zerolog.Logger, registry *schema.Registry) *LDAPService {
|
||||
return &LDAPService{
|
||||
client: ldapClient,
|
||||
ipa: ipa,
|
||||
baseDN: baseDN,
|
||||
logger: logger.With().Str("service", "ldap").Logger(),
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// requireIPA guards mutation methods that need the FreeIPA API.
|
||||
func (s *LDAPService) requireIPA() error {
|
||||
if s.ipa == nil {
|
||||
return fmt.Errorf("FreeIPA management API is not configured; writes are disabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipaServiceOptions converts a services map into IPA user_add/user_mod
|
||||
// addattr (objectclass=…) and setattr (attr=value) option slices.
|
||||
func (s *LDAPService) ipaServiceOptions(services map[string]map[string]interface{}) (addattr []string, setattr []string, err error) {
|
||||
if len(services) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
ocs, attrs, err := s.resolveServices(services)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, oc := range ocs {
|
||||
addattr = append(addattr, "objectclass="+oc)
|
||||
}
|
||||
for name, vals := range attrs {
|
||||
for _, v := range vals {
|
||||
setattr = append(setattr, name+"="+v)
|
||||
}
|
||||
}
|
||||
return addattr, setattr, nil
|
||||
}
|
||||
|
||||
func (s *LDAPService) userBaseDN() string {
|
||||
return "cn=users,cn=accounts," + s.baseDN
|
||||
}
|
||||
@@ -192,84 +226,68 @@ func (s *LDAPService) GetUserServices(uid string, domain string) (map[string]map
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new FreeIPA user
|
||||
// CreateUser creates a new FreeIPA user via the IPA API (user_add). IPA
|
||||
// assigns uidNumber/gidNumber/homeDirectory/ipaUniqueID and the default user
|
||||
// group; gsc* service attributes/objectClasses are applied via addattr/setattr.
|
||||
func (s *LDAPService) CreateUser(req *types.LDAPUserCreate) (*types.LDAPUser, error) {
|
||||
dn := s.userDN(req.UID)
|
||||
|
||||
objectClasses := []string{"top", "person", "organizationalPerson", "inetOrgPerson", "posixAccount", "krbPrincipalAux", "ipaObject"}
|
||||
|
||||
addReq := ldap.NewAddRequest(dn, nil)
|
||||
addReq.Attribute("uid", []string{req.UID})
|
||||
addReq.Attribute("givenName", []string{req.FirstName})
|
||||
addReq.Attribute("sn", []string{req.LastName})
|
||||
addReq.Attribute("cn", []string{req.FirstName + " " + req.LastName})
|
||||
addReq.Attribute("displayName", []string{req.FirstName + " " + req.LastName})
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"givenname": req.FirstName,
|
||||
"sn": req.LastName,
|
||||
}
|
||||
if req.Email != "" {
|
||||
addReq.Attribute("mail", []string{req.Email})
|
||||
opts["mail"] = req.Email
|
||||
}
|
||||
if req.Phone != "" {
|
||||
addReq.Attribute("telephoneNumber", []string{req.Phone})
|
||||
opts["telephonenumber"] = req.Phone
|
||||
}
|
||||
if req.Title != "" {
|
||||
addReq.Attribute("title", []string{req.Title})
|
||||
opts["title"] = req.Title
|
||||
}
|
||||
|
||||
shell := "/bin/bash"
|
||||
if req.Shell != "" {
|
||||
shell = req.Shell
|
||||
opts["loginshell"] = req.Shell
|
||||
}
|
||||
if req.Password != "" {
|
||||
opts["userpassword"] = req.Password
|
||||
}
|
||||
addReq.Attribute("loginShell", []string{shell})
|
||||
addReq.Attribute("homeDirectory", []string{"/home/" + req.UID})
|
||||
|
||||
// Process service attributes
|
||||
if len(req.Services) > 0 {
|
||||
svcOCs, svcAttrs, err := s.resolveServices(req.Services)
|
||||
addattr, setattr, err := s.ipaServiceOptions(req.Services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid services: %w", err)
|
||||
}
|
||||
objectClasses = append(objectClasses, svcOCs...)
|
||||
for attrName, vals := range svcAttrs {
|
||||
addReq.Attribute(attrName, vals)
|
||||
if len(addattr) > 0 {
|
||||
opts["addattr"] = addattr
|
||||
}
|
||||
// Add audit timestamps
|
||||
now := time.Now().UTC().Format("20060102150405Z")
|
||||
addReq.Attribute("gscCreatedAt", []string{now})
|
||||
addReq.Attribute("gscModifiedAt", []string{now})
|
||||
if len(setattr) > 0 {
|
||||
opts["setattr"] = setattr
|
||||
}
|
||||
|
||||
addReq.Attribute("objectClass", objectClasses)
|
||||
|
||||
if err := s.client.Add(addReq); err != nil {
|
||||
if _, err := s.ipa.Command("user_add", []interface{}{req.UID}, opts); err != nil {
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
// Set password if provided
|
||||
if req.Password != "" {
|
||||
if err := s.client.PasswordModify(dn, req.Password); err != nil {
|
||||
s.logger.Warn().Err(err).Str("uid", req.UID).Msg("user created but password set failed")
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetUser(req.UID)
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's attributes
|
||||
// UpdateUser updates a user's attributes via the IPA API (user_mod). The
|
||||
// enable/disable transition is applied through user_enable/user_disable rather
|
||||
// than a raw nsAccountLock write.
|
||||
func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.LDAPUser, error) {
|
||||
dn := s.userDN(uid)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
modified := false
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{}
|
||||
if req.FirstName != nil {
|
||||
modReq.Replace("givenName", []string{*req.FirstName})
|
||||
modified = true
|
||||
opts["givenname"] = *req.FirstName
|
||||
}
|
||||
if req.LastName != nil {
|
||||
modReq.Replace("sn", []string{*req.LastName})
|
||||
modified = true
|
||||
opts["sn"] = *req.LastName
|
||||
}
|
||||
if req.FirstName != nil || req.LastName != nil {
|
||||
// Update display name and cn
|
||||
first, last := "", ""
|
||||
if req.FirstName != nil {
|
||||
first = *req.FirstName
|
||||
@@ -277,101 +295,73 @@ func (s *LDAPService) UpdateUser(uid string, req *types.LDAPUserUpdate) (*types.
|
||||
if req.LastName != nil {
|
||||
last = *req.LastName
|
||||
}
|
||||
if first != "" || last != "" {
|
||||
display := strings.TrimSpace(first + " " + last)
|
||||
if display != "" {
|
||||
modReq.Replace("displayName", []string{display})
|
||||
modReq.Replace("cn", []string{display})
|
||||
}
|
||||
if display := strings.TrimSpace(first + " " + last); display != "" {
|
||||
opts["cn"] = display
|
||||
opts["displayname"] = display
|
||||
}
|
||||
}
|
||||
if req.Email != nil {
|
||||
modReq.Replace("mail", []string{*req.Email})
|
||||
modified = true
|
||||
opts["mail"] = *req.Email
|
||||
}
|
||||
if req.Phone != nil {
|
||||
modReq.Replace("telephoneNumber", []string{*req.Phone})
|
||||
modified = true
|
||||
opts["telephonenumber"] = *req.Phone
|
||||
}
|
||||
if req.Title != nil {
|
||||
modReq.Replace("title", []string{*req.Title})
|
||||
modified = true
|
||||
opts["title"] = *req.Title
|
||||
}
|
||||
if req.Shell != nil {
|
||||
modReq.Replace("loginShell", []string{*req.Shell})
|
||||
modified = true
|
||||
}
|
||||
if req.Disabled != nil {
|
||||
if *req.Disabled {
|
||||
modReq.Replace("nsAccountLock", []string{"TRUE"})
|
||||
} else {
|
||||
modReq.Replace("nsAccountLock", []string{"FALSE"})
|
||||
}
|
||||
modified = true
|
||||
opts["loginshell"] = *req.Shell
|
||||
}
|
||||
|
||||
// Process service attributes
|
||||
if len(req.Services) > 0 {
|
||||
svcOCs, svcAttrs, err := s.resolveServices(req.Services)
|
||||
addattr, setattr, err := s.ipaServiceOptions(req.Services)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid services: %w", err)
|
||||
}
|
||||
|
||||
// Fetch current objectClasses to determine which to add
|
||||
currentOCs, err := s.getCurrentObjectClasses(uid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read current objectClasses: %w", err)
|
||||
if len(addattr) > 0 {
|
||||
opts["addattr"] = addattr
|
||||
}
|
||||
if len(setattr) > 0 {
|
||||
opts["setattr"] = setattr
|
||||
}
|
||||
|
||||
currentOCSet := make(map[string]bool, len(currentOCs))
|
||||
for _, oc := range currentOCs {
|
||||
currentOCSet[oc] = true
|
||||
}
|
||||
|
||||
newOCs := make([]string, 0)
|
||||
for _, oc := range svcOCs {
|
||||
if !currentOCSet[oc] {
|
||||
newOCs = append(newOCs, oc)
|
||||
}
|
||||
}
|
||||
if len(newOCs) > 0 {
|
||||
modReq.Add("objectClass", newOCs)
|
||||
}
|
||||
|
||||
for attrName, vals := range svcAttrs {
|
||||
modReq.Replace(attrName, vals)
|
||||
}
|
||||
|
||||
// Update audit timestamp
|
||||
now := time.Now().UTC().Format("20060102150405Z")
|
||||
modReq.Replace("gscModifiedAt", []string{now})
|
||||
|
||||
modified = true
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return s.GetUser(uid)
|
||||
}
|
||||
|
||||
if err := s.client.Modify(modReq); err != nil {
|
||||
if len(opts) > 0 {
|
||||
if _, err := s.ipa.Command("user_mod", []interface{}{uid}, opts); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable/disable is a separate IPA verb.
|
||||
if req.Disabled != nil {
|
||||
verb := "user_enable"
|
||||
if *req.Disabled {
|
||||
verb = "user_disable"
|
||||
}
|
||||
if _, err := s.ipa.Command(verb, []interface{}{uid}, nil); err != nil {
|
||||
return nil, fmt.Errorf("failed to %s user: %w", strings.TrimPrefix(verb, "user_"), err)
|
||||
}
|
||||
}
|
||||
|
||||
return s.GetUser(uid)
|
||||
}
|
||||
|
||||
// DisableUser disables a user account
|
||||
// DisableUser disables a user account via the IPA API (user_disable).
|
||||
func (s *LDAPService) DisableUser(uid string) error {
|
||||
dn := s.userDN(uid)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
modReq.Replace("nsAccountLock", []string{"TRUE"})
|
||||
return s.client.Modify(modReq)
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.ipa.Command("user_disable", []interface{}{uid}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResetPassword resets a user's password
|
||||
// ResetPassword resets a user's password via the IPA API (passwd). Note: an
|
||||
// admin-set password is marked expired by IPA (the user must change it at next
|
||||
// login) — standard FreeIPA behaviour.
|
||||
func (s *LDAPService) ResetPassword(uid, newPassword string) error {
|
||||
dn := s.userDN(uid)
|
||||
return s.client.PasswordModify(dn, newPassword)
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.ipa.Command("passwd", []interface{}{uid}, map[string]interface{}{"password": newPassword})
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserGroups lists groups a user belongs to
|
||||
@@ -431,44 +421,46 @@ func (s *LDAPService) GetGroup(cn string) (*types.LDAPGroup, error) {
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
// CreateGroup creates a new group
|
||||
// CreateGroup creates a new group via the IPA API (group_add). IPA assigns the
|
||||
// gidNumber and ipaUniqueID and sets the correct objectClasses.
|
||||
func (s *LDAPService) CreateGroup(req *types.LDAPGroupCreate) (*types.LDAPGroup, error) {
|
||||
dn := s.groupDN(req.CN)
|
||||
|
||||
addReq := ldap.NewAddRequest(dn, nil)
|
||||
addReq.Attribute("objectClass", []string{"top", "groupOfNames", "posixGroup", "ipaObject"})
|
||||
addReq.Attribute("cn", []string{req.CN})
|
||||
if req.Description != "" {
|
||||
addReq.Attribute("description", []string{req.Description})
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.client.Add(addReq); err != nil {
|
||||
opts := map[string]interface{}{}
|
||||
if req.Description != "" {
|
||||
opts["description"] = req.Description
|
||||
}
|
||||
if _, err := s.ipa.Command("group_add", []interface{}{req.CN}, opts); err != nil {
|
||||
return nil, fmt.Errorf("failed to create group: %w", err)
|
||||
}
|
||||
|
||||
return s.GetGroup(req.CN)
|
||||
}
|
||||
|
||||
// UpdateGroup updates a group's attributes
|
||||
// UpdateGroup updates a group's attributes via the IPA API (group_mod).
|
||||
func (s *LDAPService) UpdateGroup(cn string, req *types.LDAPGroupUpdate) (*types.LDAPGroup, error) {
|
||||
dn := s.groupDN(cn)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
|
||||
if req.Description != nil {
|
||||
modReq.Replace("description", []string{*req.Description})
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.client.Modify(modReq); err != nil {
|
||||
opts := map[string]interface{}{}
|
||||
if req.Description != nil {
|
||||
opts["description"] = *req.Description
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
if _, err := s.ipa.Command("group_mod", []interface{}{cn}, opts); err != nil {
|
||||
return nil, fmt.Errorf("failed to update group: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
return s.GetGroup(cn)
|
||||
}
|
||||
|
||||
// DeleteGroup deletes a group
|
||||
// DeleteGroup deletes a group via the IPA API (group_del).
|
||||
func (s *LDAPService) DeleteGroup(cn string) error {
|
||||
dn := s.groupDN(cn)
|
||||
return s.client.Delete(dn)
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.ipa.Command("group_del", []interface{}{cn}, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetGroupMembers lists members of a group
|
||||
@@ -483,28 +475,24 @@ func (s *LDAPService) GetGroupMembers(cn string) ([]string, error) {
|
||||
return group.Members, nil
|
||||
}
|
||||
|
||||
// AddGroupMembers adds members to a group
|
||||
// AddGroupMembers adds user members to a group via the IPA API
|
||||
// (group_add_member). IPA resolves uids to member DNs itself.
|
||||
func (s *LDAPService) AddGroupMembers(cn string, uids []string) error {
|
||||
dn := s.groupDN(cn)
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
|
||||
for _, uid := range uids {
|
||||
memberDN := s.userDN(uid)
|
||||
modReq.Add("member", []string{memberDN})
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.client.Modify(modReq)
|
||||
_, err := s.ipa.Command("group_add_member", []interface{}{cn}, map[string]interface{}{"user": uids})
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveGroupMember removes a member from a group
|
||||
// RemoveGroupMember removes a user member from a group via the IPA API
|
||||
// (group_remove_member).
|
||||
func (s *LDAPService) RemoveGroupMember(cn, uid string) error {
|
||||
dn := s.groupDN(cn)
|
||||
memberDN := s.userDN(uid)
|
||||
|
||||
modReq := ldap.NewModifyRequest(dn, nil)
|
||||
modReq.Delete("member", []string{memberDN})
|
||||
|
||||
return s.client.Modify(modReq)
|
||||
if err := s.requireIPA(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.ipa.Command("group_remove_member", []interface{}{cn}, map[string]interface{}{"user": []string{uid}})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *LDAPService) entryToUser(entry *ldap.Entry, includeServices bool) types.LDAPUser {
|
||||
|
||||
27
migrations/002_api_keys.sql
Normal file
27
migrations/002_api_keys.sql
Normal 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
25
pkg/types/apikey.go
Normal 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
79
pkg/types/scopes.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user