eda033ff9b
Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').
With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
299 lines
7.8 KiB
Go
299 lines
7.8 KiB
Go
// Package auth provides API key authentication and authorization for the
|
|
// notifier service, including key storage backends (in-memory, database,
|
|
// and a hybrid cache-plus-database store) and RBAC-style notifier
|
|
// authorization.
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Sentinel errors for key validation and lookup. Match with errors.Is.
|
|
var (
|
|
ErrInvalidKey = errors.New("invalid API key")
|
|
ErrKeyInactive = errors.New("API key is inactive")
|
|
ErrKeyExpired = errors.New("API key has expired")
|
|
ErrKeyNotFound = errors.New("API key not found")
|
|
)
|
|
|
|
// APIKeyStore manages API keys with rate limiting.
|
|
// Keys are stored indexed by SHA-256 digest; the raw key is never retained
|
|
// after creation.
|
|
type APIKeyStore struct {
|
|
mu sync.RWMutex
|
|
keys map[string]*APIKey // keyed by KeyHash
|
|
rateLimits map[string]*RateLimiter
|
|
}
|
|
|
|
// APIKey represents an API key with metadata.
|
|
// Key holds the raw secret only on the value returned from key creation;
|
|
// stored and persisted records carry only KeyHash and KeyPreview.
|
|
type APIKey struct {
|
|
Key string `json:"key,omitempty"`
|
|
KeyHash string `json:"-"`
|
|
KeyPreview string `json:"key_preview,omitempty"` // e.g. "nk_…ab12"
|
|
Name string `json:"name"`
|
|
ClientID string `json:"client_id"`
|
|
Roles []string `json:"roles"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
|
IsActive bool `json:"is_active"`
|
|
RateLimit int `json:"rate_limit"` // requests per minute, 0 = unlimited
|
|
}
|
|
|
|
// RateLimiter tracks rate limiting for a key
|
|
type RateLimiter struct {
|
|
maxRequests int
|
|
window time.Duration
|
|
resetTime time.Time
|
|
count int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// Context holds auth information attached to request context
|
|
type Context struct {
|
|
APIKey *APIKey
|
|
ClientID string
|
|
Roles []string
|
|
}
|
|
|
|
// HashKey returns the hex-encoded SHA-256 digest of a raw API key.
|
|
// All storage and lookups are keyed by this digest so a leaked store or
|
|
// database dump does not expose usable credentials.
|
|
func HashKey(rawKey string) string {
|
|
sum := sha256.Sum256([]byte(rawKey))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// keyPreview returns a non-sensitive display form of a raw key ("nk_…ab12").
|
|
func keyPreview(rawKey string) string {
|
|
if len(rawKey) < 4 {
|
|
return ""
|
|
}
|
|
return "nk_…" + rawKey[len(rawKey)-4:]
|
|
}
|
|
|
|
// generateAPIKey creates a new APIKey with a random secret without touching
|
|
// any store. The returned value carries the raw secret in Key; callers decide
|
|
// where (and whether) to register it.
|
|
func generateAPIKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
|
|
keyBytes := make([]byte, 32)
|
|
if _, err := rand.Read(keyBytes); err != nil {
|
|
return nil, fmt.Errorf("failed to generate key: %w", err)
|
|
}
|
|
key := "nk_" + hex.EncodeToString(keyBytes)
|
|
|
|
now := time.Now().UTC()
|
|
apiKey := &APIKey{
|
|
Key: key,
|
|
KeyHash: HashKey(key),
|
|
KeyPreview: keyPreview(key),
|
|
ClientID: clientID,
|
|
Roles: roles,
|
|
CreatedAt: now,
|
|
IsActive: true,
|
|
RateLimit: rateLimit,
|
|
Name: fmt.Sprintf("%s-%d", clientID, now.Unix()),
|
|
}
|
|
|
|
if expiresIn != nil {
|
|
expiresAt := now.Add(*expiresIn)
|
|
apiKey.ExpiresAt = &expiresAt
|
|
}
|
|
|
|
return apiKey, nil
|
|
}
|
|
|
|
// NewAPIKeyStore creates a new API key store
|
|
func NewAPIKeyStore() *APIKeyStore {
|
|
return &APIKeyStore{
|
|
keys: make(map[string]*APIKey),
|
|
rateLimits: make(map[string]*RateLimiter),
|
|
}
|
|
}
|
|
|
|
// CreateKey generates a new API key and registers it in the store.
|
|
// The returned APIKey carries the raw secret; the stored copy does not.
|
|
func (s *APIKeyStore) CreateKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
|
|
apiKey, err := generateAPIKey(clientID, roles, rateLimit, expiresIn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.RegisterKey(apiKey)
|
|
return apiKey, nil
|
|
}
|
|
|
|
// RegisterKey adds a key to the store, indexed by digest. The stored copy has
|
|
// the raw secret stripped. If KeyHash is unset it is computed from Key.
|
|
func (s *APIKeyStore) RegisterKey(apiKey *APIKey) {
|
|
stored := *apiKey
|
|
if stored.KeyHash == "" && stored.Key != "" {
|
|
stored.KeyHash = HashKey(stored.Key)
|
|
}
|
|
if stored.KeyPreview == "" && stored.Key != "" {
|
|
stored.KeyPreview = keyPreview(stored.Key)
|
|
}
|
|
stored.Key = "" // never retain the raw secret
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.keys[stored.KeyHash] = &stored
|
|
s.rateLimits[stored.KeyHash] = &RateLimiter{
|
|
maxRequests: stored.RateLimit,
|
|
window: time.Minute,
|
|
resetTime: time.Now().Add(time.Minute),
|
|
count: 0,
|
|
}
|
|
}
|
|
|
|
// RemoveKeyByHash deletes a key and its rate limiter from the store.
|
|
func (s *APIKeyStore) RemoveKeyByHash(keyHash string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.keys, keyHash)
|
|
delete(s.rateLimits, keyHash)
|
|
}
|
|
|
|
// ValidateKey checks if a raw API key is valid and returns the key metadata
|
|
func (s *APIKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
key, exists := s.keys[HashKey(keyStr)]
|
|
if !exists {
|
|
return nil, ErrInvalidKey
|
|
}
|
|
|
|
if !key.IsActive {
|
|
return nil, ErrKeyInactive
|
|
}
|
|
|
|
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
|
|
return nil, ErrKeyExpired
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// CheckRateLimit checks if a key has exceeded its rate limit
|
|
func (s *APIKeyStore) CheckRateLimit(keyStr string) (bool, error) {
|
|
keyHash := HashKey(keyStr)
|
|
|
|
// Look up key and limiter under the store lock, then release it
|
|
// before acquiring the per-key limiter lock to avoid nested locking.
|
|
s.mu.RLock()
|
|
key, exists := s.keys[keyHash]
|
|
if !exists {
|
|
s.mu.RUnlock()
|
|
return false, ErrInvalidKey
|
|
}
|
|
|
|
// Unlimited rate limit
|
|
if key.RateLimit <= 0 {
|
|
s.mu.RUnlock()
|
|
return true, nil
|
|
}
|
|
|
|
limiter, exists := s.rateLimits[keyHash]
|
|
if !exists {
|
|
s.mu.RUnlock()
|
|
return false, fmt.Errorf("rate limiter not found")
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
// Now lock only the per-key rate limiter
|
|
limiter.mu.Lock()
|
|
defer limiter.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
if now.After(limiter.resetTime) {
|
|
limiter.count = 0
|
|
limiter.resetTime = now.Add(limiter.window)
|
|
}
|
|
|
|
if limiter.count >= limiter.maxRequests {
|
|
return false, nil
|
|
}
|
|
|
|
limiter.count++
|
|
return true, nil
|
|
}
|
|
|
|
// UpdateLastUsed updates the last used timestamp for a key
|
|
func (s *APIKeyStore) UpdateLastUsed(keyStr string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
key, exists := s.keys[HashKey(keyStr)]
|
|
if !exists {
|
|
return ErrInvalidKey
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
key.LastUsedAt = &now
|
|
return nil
|
|
}
|
|
|
|
// DeactivateKey deactivates an API key by its raw value
|
|
func (s *APIKeyStore) DeactivateKey(keyStr string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
key, exists := s.keys[HashKey(keyStr)]
|
|
if !exists {
|
|
return ErrInvalidKey
|
|
}
|
|
|
|
key.IsActive = false
|
|
return nil
|
|
}
|
|
|
|
// GetKey retrieves key metadata by raw key (for management purposes)
|
|
func (s *APIKeyStore) GetKey(keyStr string) (*APIKey, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
key, exists := s.keys[HashKey(keyStr)]
|
|
if !exists {
|
|
return nil, ErrKeyNotFound
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// ListKeys lists all API keys for a client
|
|
func (s *APIKeyStore) ListKeys(clientID string) []*APIKey {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var keys []*APIKey
|
|
for _, key := range s.keys {
|
|
if key.ClientID == clientID {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// authContextKey is an unexported type for context keys to avoid collisions.
|
|
type authContextKey struct{}
|
|
|
|
// ContextWithAuth adds auth context to a request context
|
|
func ContextWithAuth(ctx context.Context, auth *Context) context.Context {
|
|
return context.WithValue(ctx, authContextKey{}, auth)
|
|
}
|
|
|
|
// GetAuthContext retrieves auth context from a request context
|
|
func GetAuthContext(ctx context.Context) (*Context, bool) {
|
|
auth, ok := ctx.Value(authContextKey{}).(*Context)
|
|
return auth, ok
|
|
}
|