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>
116 lines
3.7 KiB
Go
116 lines
3.7 KiB
Go
package middleware
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"strings"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
|
|
"github.com/gosec/gsc-ops-api/pkg/types"
|
|
)
|
|
|
|
const APIKeyHeader = "X-API-Key"
|
|
|
|
// 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 == "" {
|
|
apiErr := types.NewUnauthorized("Missing API key")
|
|
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, GetRequestID(c)))
|
|
}
|
|
|
|
for _, vk := range validKeys {
|
|
if subtle.ConstantTimeCompare([]byte(key), []byte(vk)) == 1 {
|
|
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()
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
}
|