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

25
pkg/types/apikey.go Normal file
View 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
View 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
}