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 }