Files
gsc-ops-api/internal/service/apikey.go
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

254 lines
8.0 KiB
Go

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
}