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:
85
internal/handler/apikeys.go
Normal file
85
internal/handler/apikeys.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-ops-api/internal/middleware"
|
||||
"github.com/gosec/gsc-ops-api/internal/service"
|
||||
"github.com/gosec/gsc-ops-api/pkg/types"
|
||||
)
|
||||
|
||||
// APIKeyHandler exposes dynamic API-key management. These routes sit behind the
|
||||
// same API-key middleware as the rest of /api/v1, so an existing valid key
|
||||
// bootstraps the first dynamic ones.
|
||||
type APIKeyHandler struct {
|
||||
svc *service.APIKeyService
|
||||
}
|
||||
|
||||
// NewAPIKeyHandler creates a new API-key handler.
|
||||
func NewAPIKeyHandler(svc *service.APIKeyService) *APIKeyHandler {
|
||||
return &APIKeyHandler{svc: svc}
|
||||
}
|
||||
|
||||
// Create handles POST /api/v1/admin/api-keys — mints a key and returns the
|
||||
// plaintext exactly once.
|
||||
func (h *APIKeyHandler) Create(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
var req types.APIKeyCreateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
apiErr := types.NewBadRequest("Invalid request body: " + err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if req.Name == "" {
|
||||
apiErr := types.NewValidation("name is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
if err := types.ValidateScopes(req.Scopes); err != nil {
|
||||
apiErr := types.NewValidation(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
createdBy := ""
|
||||
if claims := middleware.GetJWTClaims(c); claims != nil {
|
||||
createdBy = claims.Subject
|
||||
}
|
||||
|
||||
info, err := h.svc.Generate(c.Context(), req.Name, req.Scopes, createdBy)
|
||||
if err != nil {
|
||||
apiErr := types.NewConflict(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(types.NewDataResponse(info, reqID))
|
||||
}
|
||||
|
||||
// List handles GET /api/v1/admin/api-keys.
|
||||
func (h *APIKeyHandler) List(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
keys, err := h.svc.List(c.Context())
|
||||
if err != nil {
|
||||
apiErr := types.NewInternal(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.JSON(types.NewPagedResponse(keys, int64(len(keys)), len(keys), 0, reqID))
|
||||
}
|
||||
|
||||
// Revoke handles DELETE /api/v1/admin/api-keys/:id.
|
||||
func (h *APIKeyHandler) Revoke(c *fiber.Ctx) error {
|
||||
reqID := middleware.GetRequestID(c)
|
||||
|
||||
id := c.Params("id")
|
||||
if id == "" {
|
||||
apiErr := types.NewBadRequest("id is required")
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
if err := h.svc.Revoke(c.Context(), id); err != nil {
|
||||
apiErr := types.NewNotFound(err.Error())
|
||||
return c.Status(apiErr.Status).JSON(types.NewErrorResponse(apiErr, reqID))
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import (
|
||||
type Config struct {
|
||||
Logger zerolog.Logger
|
||||
APIKeys []string
|
||||
// APIKeyValidate backs the static APIKeys list with a dynamic, self-managed
|
||||
// key store so new consumers can be added at runtime without a rebuild.
|
||||
APIKeyValidate middleware.APIKeyValidator
|
||||
APIKeysAdmin *handler.APIKeyHandler
|
||||
Health *handler.HealthHandler
|
||||
LDAPUsers *handler.LDAPUserHandler
|
||||
LDAPGroups *handler.LDAPGroupHandler
|
||||
@@ -42,8 +46,20 @@ func Setup(app *fiber.App, cfg *Config) {
|
||||
app.Get("/health", cfg.Health.Liveness)
|
||||
app.Get("/ready", cfg.Health.Readiness)
|
||||
|
||||
// API v1 routes (API key required)
|
||||
api := app.Group("/api/v1", middleware.APIKey(cfg.APIKeys))
|
||||
// API v1 routes: authenticate (static keys + dynamic key store), then
|
||||
// enforce per-key scopes. Static keys carry the wildcard scope and bypass.
|
||||
api := app.Group("/api/v1",
|
||||
middleware.APIKeyWithValidator(cfg.APIKeys, cfg.APIKeyValidate),
|
||||
middleware.ScopeEnforce(),
|
||||
)
|
||||
|
||||
// Dynamic API-key management (bootstrapped by any existing valid key)
|
||||
if cfg.APIKeysAdmin != nil {
|
||||
apiKeys := api.Group("/admin/api-keys")
|
||||
apiKeys.Get("/", cfg.APIKeysAdmin.List)
|
||||
apiKeys.Post("/", cfg.APIKeysAdmin.Create)
|
||||
apiKeys.Delete("/:id", cfg.APIKeysAdmin.Revoke)
|
||||
}
|
||||
|
||||
// LDAP Users
|
||||
ldapUsers := api.Group("/ldap/users")
|
||||
|
||||
253
internal/service/apikey.go
Normal file
253
internal/service/apikey.go
Normal file
@@ -0,0 +1,253 @@
|
||||
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(®); 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
|
||||
}
|
||||
Reference in New Issue
Block a user