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

View File

@@ -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 !valid {
apiErr := types.NewUnauthorized("Invalid API key")
if validate != nil {
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.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)))
}
}