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:
25
pkg/types/apikey.go
Normal file
25
pkg/types/apikey.go
Normal 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
79
pkg/types/scopes.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user