Refactor auth and authz

This commit is contained in:
2025-10-26 02:25:24 -07:00
parent 9ff782f7b6
commit abe7b6beee
22 changed files with 8018 additions and 62 deletions
+98
View File
@@ -0,0 +1,98 @@
package auth
import (
"context"
"fmt"
"os"
"time"
"github.com/igodwin/notifier/internal/logging"
)
// BootstrapConfig holds configuration for bootstrap operations
type BootstrapConfig struct {
// Enabled triggers automatic bootstrap key creation on first startup
Enabled bool
// AdminKeyFileName is where to store the generated admin key
AdminKeyFileName string
// PrintToStdout prints the admin key to stdout (DANGEROUS - only for setup)
PrintToStdout bool
}
// BootstrapAdminKey creates an initial admin API key on first startup
// This should be called once per deployment
func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *BootstrapConfig, logger *logging.Logger) (*APIKey, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("bootstrap is disabled")
}
// Check if bootstrap has already been done
if cfg.AdminKeyFileName != "" {
if _, err := os.Stat(cfg.AdminKeyFileName); err == nil {
// File exists, bootstrap already done
logger.Infof("Bootstrap key file exists at %s, skipping bootstrap", cfg.AdminKeyFileName)
return nil, fmt.Errorf("bootstrap already completed")
}
}
// Create admin key with all roles
adminRoles := []string{"admin", "notify-email", "notify-slack", "notify-ntfy"}
apiKey, err := keyStore.CreateKey(
ctx,
"admin-bootstrap",
adminRoles,
0, // Unlimited rate limit
nil, // No expiration
"system",
)
if err != nil {
return nil, fmt.Errorf("failed to create bootstrap admin key: %w", err)
}
// Save key to file if configured
if cfg.AdminKeyFileName != "" {
keyContent := fmt.Sprintf(`# Notifier Admin Key
# Created: %s
# This key has full admin permissions
# KEEP THIS SECRET!
%s
`, time.Now().Format(time.RFC3339), apiKey.Key)
if err := os.WriteFile(cfg.AdminKeyFileName, []byte(keyContent), 0600); err != nil {
logger.Warnf("Failed to save admin key to file: %v", err)
} else {
logger.Infof("Admin key saved to %s", cfg.AdminKeyFileName)
}
}
// Print to stdout if configured (DANGEROUS - only for interactive setup)
if cfg.PrintToStdout {
fmt.Println("\n" + "="*60)
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
fmt.Println("="*60)
fmt.Printf("Key: %s\n", apiKey.Key)
fmt.Println("\nSave this key in a secure location. You will not be able to see it again.")
fmt.Println("Use this key to create additional API keys via the key management API.")
fmt.Println("="*60 + "\n")
}
logger.Infof("Bootstrap admin key created successfully")
return apiKey, nil
}
// LoadBootstrapKeyFromEnv checks if a bootstrap key was provided via environment variable
// This allows injecting a pre-generated key via CI/CD
func LoadBootstrapKeyFromEnv(ctx context.Context, keyStore *HybridKeyStore, logger *logging.Logger) error {
bootstrapKey := os.Getenv("NOTIFIER_BOOTSTRAP_ADMIN_KEY")
if bootstrapKey == "" {
return nil // Not set, skip
}
// Check if key already exists in database
// For now, we skip if environment variable is set
// In production, you'd want to verify the key is already in the database
logger.Infof("Bootstrap key detected from environment variable")
return nil
}
+340
View File
@@ -0,0 +1,340 @@
package auth
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/lib/pq"
)
// KeyStoreDB provides persistent storage for API keys using PostgreSQL
// It acts as the backend for the in-memory cache
type KeyStoreDB struct {
db *sql.DB
}
// NewKeyStoreDB creates a new database-backed key store
func NewKeyStoreDB(dbURL string) (*KeyStoreDB, error) {
db, err := sql.Open("postgres", dbURL)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Test connection
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
ks := &KeyStoreDB{db: db}
// Initialize schema
if err := ks.initializeSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
return ks, nil
}
// initializeSchema creates the necessary tables if they don't exist
func (ks *KeyStoreDB) initializeSchema() error {
schema := `
-- API Keys table
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
key VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
client_id VARCHAR(255) NOT NULL,
roles TEXT[] DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP,
expires_at TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT true,
rate_limit INTEGER NOT NULL DEFAULT 0,
created_by VARCHAR(255),
metadata JSONB DEFAULT '{}'::jsonb,
INDEX idx_key (key),
INDEX idx_client_id (client_id),
INDEX idx_active (is_active),
INDEX idx_expires (expires_at)
);
-- Audit log for key operations
CREATE TABLE IF NOT EXISTS api_key_audit_log (
id SERIAL PRIMARY KEY,
key_id INTEGER NOT NULL REFERENCES api_keys(id),
action VARCHAR(50) NOT NULL,
performed_by VARCHAR(255) NOT NULL,
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
details JSONB DEFAULT '{}'::jsonb,
INDEX idx_key_id (key_id),
INDEX idx_performed_at (performed_at)
);
`
_, err := ks.db.Exec(schema)
return err
}
// SaveKey persists an API key to the database
func (ks *KeyStoreDB) SaveKey(ctx context.Context, key *APIKey, createdBy string) error {
query := `
INSERT INTO api_keys (key, name, client_id, roles, created_at, last_used_at, expires_at, is_active, rate_limit, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (key) DO UPDATE SET
name = EXCLUDED.name,
roles = EXCLUDED.roles,
is_active = EXCLUDED.is_active,
rate_limit = EXCLUDED.rate_limit,
last_used_at = COALESCE(EXCLUDED.last_used_at, api_keys.last_used_at)
`
_, err := ks.db.ExecContext(ctx, query,
key.Key,
key.Name,
key.ClientID,
key.Roles,
key.CreatedAt,
key.LastUsedAt,
key.ExpiresAt,
key.IsActive,
key.RateLimit,
createdBy,
)
if err != nil {
return fmt.Errorf("failed to save key: %w", err)
}
// Log to audit trail
ks.logAudit(ctx, key.Key, "created", createdBy, map[string]interface{}{
"client_id": key.ClientID,
"roles": key.Roles,
})
return nil
}
// GetKey retrieves an API key from the database
func (ks *KeyStoreDB) GetKey(ctx context.Context, keyStr string) (*APIKey, error) {
query := `
SELECT key, name, client_id, roles, created_at, last_used_at, expires_at, is_active, rate_limit
FROM api_keys
WHERE key = $1
`
var key APIKey
var roles []string
err := ks.db.QueryRowContext(ctx, query, keyStr).Scan(
&key.Key,
&key.Name,
&key.ClientID,
&roles,
&key.CreatedAt,
&key.LastUsedAt,
&key.ExpiresAt,
&key.IsActive,
&key.RateLimit,
)
if err == sql.ErrNoRows {
return nil, ErrKeyNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get key: %w", err)
}
key.Roles = roles
return &key, nil
}
// ListKeys retrieves all API keys for a client
func (ks *KeyStoreDB) ListKeys(ctx context.Context, clientID string) ([]*APIKey, error) {
query := `
SELECT key, name, client_id, roles, created_at, last_used_at, expires_at, is_active, rate_limit
FROM api_keys
WHERE client_id = $1 AND is_active = true
ORDER BY created_at DESC
`
rows, err := ks.db.QueryContext(ctx, query, clientID)
if err != nil {
return nil, fmt.Errorf("failed to list keys: %w", err)
}
defer rows.Close()
var keys []*APIKey
for rows.Next() {
var key APIKey
var roles []string
err := rows.Scan(
&key.Key,
&key.Name,
&key.ClientID,
&roles,
&key.CreatedAt,
&key.LastUsedAt,
&key.ExpiresAt,
&key.IsActive,
&key.RateLimit,
)
if err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
key.Roles = roles
keys = append(keys, &key)
}
return keys, rows.Err()
}
// DeactivateKey disables an API key
func (ks *KeyStoreDB) DeactivateKey(ctx context.Context, keyStr string, deactivatedBy string) error {
query := `UPDATE api_keys SET is_active = false WHERE key = $1`
result, err := ks.db.ExecContext(ctx, query, keyStr)
if err != nil {
return fmt.Errorf("failed to deactivate key: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return ErrKeyNotFound
}
ks.logAudit(ctx, keyStr, "deactivated", deactivatedBy, nil)
return nil
}
// UpdateLastUsed updates the last_used_at timestamp
func (ks *KeyStoreDB) UpdateLastUsed(ctx context.Context, keyStr string) error {
query := `UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE key = $1`
_, err := ks.db.ExecContext(ctx, query, keyStr)
if err != nil {
return fmt.Errorf("failed to update last used: %w", err)
}
return nil
}
// LoadAllKeys loads all active keys into memory for caching
func (ks *KeyStoreDB) LoadAllKeys(ctx context.Context) ([]*APIKey, error) {
query := `
SELECT key, name, client_id, roles, created_at, last_used_at, expires_at, is_active, rate_limit
FROM api_keys
WHERE is_active = true AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
`
rows, err := ks.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to load keys: %w", err)
}
defer rows.Close()
var keys []*APIKey
for rows.Next() {
var key APIKey
var roles []string
err := rows.Scan(
&key.Key,
&key.Name,
&key.ClientID,
&roles,
&key.CreatedAt,
&key.LastUsedAt,
&key.ExpiresAt,
&key.IsActive,
&key.RateLimit,
)
if err != nil {
return nil, fmt.Errorf("failed to scan key: %w", err)
}
key.Roles = roles
keys = append(keys, &key)
}
return keys, rows.Err()
}
// logAudit logs a key operation to the audit trail
func (ks *KeyStoreDB) logAudit(ctx context.Context, keyStr string, action string, performedBy string, details map[string]interface{}) {
// Get key ID
var keyID int
err := ks.db.QueryRowContext(ctx, "SELECT id FROM api_keys WHERE key = $1", keyStr).Scan(&keyID)
if err != nil {
return // Silently fail audit logging
}
// Log the action
detailsJSON := "{}"
if len(details) > 0 {
// Simple JSON encoding (could use jsonb package for robustness)
detailsJSON = fmt.Sprintf(`{"event": "%s"}`, action)
}
query := `
INSERT INTO api_key_audit_log (key_id, action, performed_by, details)
VALUES ($1, $2, $3, $4)
`
ks.db.ExecContext(ctx, query, keyID, action, performedBy, detailsJSON)
}
// Close closes the database connection
func (ks *KeyStoreDB) Close() error {
return ks.db.Close()
}
// GetAuditLog retrieves audit log entries for a key
func (ks *KeyStoreDB) GetAuditLog(ctx context.Context, keyStr string, limit int) ([]map[string]interface{}, error) {
query := `
SELECT al.action, al.performed_by, al.performed_at, al.details
FROM api_key_audit_log al
JOIN api_keys ak ON al.key_id = ak.id
WHERE ak.key = $1
ORDER BY al.performed_at DESC
LIMIT $2
`
rows, err := ks.db.QueryContext(ctx, query, keyStr, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit log: %w", err)
}
defer rows.Close()
var logs []map[string]interface{}
for rows.Next() {
var action, performedBy, details string
var performedAt time.Time
err := rows.Scan(&action, &performedBy, &performedAt, &details)
if err != nil {
return nil, err
}
logs = append(logs, map[string]interface{}{
"action": action,
"performed_by": performedBy,
"performed_at": performedAt,
"details": details,
})
}
return logs, rows.Err()
}
// Custom errors
var (
ErrKeyNotFound = fmt.Errorf("API key not found")
)
+209
View File
@@ -0,0 +1,209 @@
package auth
import (
"context"
"fmt"
"sync"
"time"
)
// HybridKeyStore combines in-memory cache with persistent database backend
// Write-through strategy: writes go to DB first, then cache is updated
// This ensures consistency: if DB write fails, cache is not updated
type HybridKeyStore struct {
cache *APIKeyStore // In-memory cache for fast lookups
db *KeyStoreDB // Database backend for persistence
mu sync.RWMutex
}
// NewHybridKeyStore creates a new hybrid key store
func NewHybridKeyStore(cache *APIKeyStore, db *KeyStoreDB) *HybridKeyStore {
return &HybridKeyStore{
cache: cache,
db: db,
}
}
// InitializeFromDatabase loads all keys from database into cache at startup
func (h *HybridKeyStore) InitializeFromDatabase(ctx context.Context) error {
keys, err := h.db.LoadAllKeys(ctx)
if err != nil {
return fmt.Errorf("failed to load keys from database: %w", err)
}
for _, key := range keys {
h.cache.keys[key.Key] = key
rateLimit := key.RateLimit
if rateLimit <= 0 {
rateLimit = 100 // Default rate limit
}
h.cache.rateLimits[key.Key] = &RateLimiter{
maxRequests: rateLimit,
window: time.Minute,
resetTime: time.Now().Add(time.Minute),
count: 0,
}
}
return nil
}
// CreateKey generates a new API key and persists it
// Returns error if database write fails
func (h *HybridKeyStore) CreateKey(ctx context.Context, clientID string, roles []string, rateLimit int, expiresIn *time.Duration, createdBy string) (*APIKey, error) {
h.mu.Lock()
defer h.mu.Unlock()
// Generate random key in memory
apiKey, err := h.generateKey(clientID, roles, rateLimit, expiresIn)
if err != nil {
return nil, err
}
// Write to database first (consistency)
if err := h.db.SaveKey(ctx, apiKey, createdBy); err != nil {
return nil, err
}
// Update cache after successful DB write
h.cache.keys[apiKey.Key] = apiKey
h.cache.rateLimits[apiKey.Key] = &RateLimiter{
maxRequests: rateLimit,
window: time.Minute,
resetTime: time.Now().Add(time.Minute),
count: 0,
}
return apiKey, nil
}
// ValidateKey checks if a key is valid
// Checks cache first for performance, falls back to database if cache miss
func (h *HybridKeyStore) ValidateKey(keyStr string) (*APIKey, error) {
// Check cache first (fast path)
h.cache.mu.RLock()
key, exists := h.cache.keys[keyStr]
h.cache.mu.RUnlock()
if exists {
if h.isKeyValid(key) {
return key, nil
}
return nil, fmt.Errorf("key is inactive or expired")
}
// Cache miss - this is normal in distributed deployments
// Could implement database fallback here if needed:
// key, err := h.db.GetKey(context.Background(), keyStr)
// But for now, rely on cache being populated at startup
return nil, fmt.Errorf("API key not found")
}
// ListKeys returns all active keys for a client
func (h *HybridKeyStore) ListKeys(ctx context.Context, clientID string) ([]*APIKey, error) {
return h.db.ListKeys(ctx, clientID)
}
// DeactivateKey deactivates a key in both cache and database
func (h *HybridKeyStore) DeactivateKey(ctx context.Context, keyStr string, deactivatedBy string) error {
h.mu.Lock()
defer h.mu.Unlock()
// Remove from cache first
h.cache.mu.Lock()
delete(h.cache.keys, keyStr)
delete(h.cache.rateLimits, keyStr)
h.cache.mu.Unlock()
// Update database
return h.db.DeactivateKey(ctx, keyStr, deactivatedBy)
}
// UpdateLastUsed updates the last used timestamp in database
// Cache is not updated to avoid contention
func (h *HybridKeyStore) UpdateLastUsed(ctx context.Context, keyStr string) error {
return h.db.UpdateLastUsed(ctx, keyStr)
}
// CheckRateLimit checks if a key has exceeded its rate limit
func (h *HybridKeyStore) CheckRateLimit(keyStr string) error {
h.cache.mu.RLock()
defer h.cache.mu.RUnlock()
limiter, exists := h.cache.rateLimits[keyStr]
if !exists {
return fmt.Errorf("rate limiter not found")
}
return limiter.Check()
}
// GetAuditLog retrieves audit log for a key
func (h *HybridKeyStore) GetAuditLog(ctx context.Context, keyStr string, limit int) ([]map[string]interface{}, error) {
return h.db.GetAuditLog(ctx, keyStr, limit)
}
// Close closes the database connection
func (h *HybridKeyStore) Close() error {
return h.db.Close()
}
// Helper functions
// generateKey creates an APIKey with cryptographic random bytes
func (h *HybridKeyStore) generateKey(clientID string, roles []string, rateLimit int, expiresIn *time.Duration) (*APIKey, error) {
apiKey, err := h.cache.CreateKey(clientID, roles, rateLimit, expiresIn)
if err != nil {
return nil, err
}
return apiKey, nil
}
// isKeyValid checks if a key is currently valid
func (h *HybridKeyStore) isKeyValid(key *APIKey) bool {
if !key.IsActive {
return false
}
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
return false
}
return true
}
// SyncCache performs a full cache refresh from database
// Useful for multi-instance deployments where keys may be created elsewhere
func (h *HybridKeyStore) SyncCache(ctx context.Context) error {
h.mu.Lock()
defer h.mu.Unlock()
keys, err := h.db.LoadAllKeys(ctx)
if err != nil {
return fmt.Errorf("failed to sync cache: %w", err)
}
// Clear cache
h.cache.mu.Lock()
h.cache.keys = make(map[string]*APIKey)
h.cache.rateLimits = make(map[string]*RateLimiter)
// Repopulate cache
for _, key := range keys {
h.cache.keys[key.Key] = key
rateLimit := key.RateLimit
if rateLimit <= 0 {
rateLimit = 100
}
h.cache.rateLimits[key.Key] = &RateLimiter{
maxRequests: rateLimit,
window: time.Minute,
resetTime: time.Now().Add(time.Minute),
count: 0,
}
}
h.cache.mu.Unlock()
return nil
}
+41 -2
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/igodwin/notifier/internal/auth"
"github.com/igodwin/notifier/internal/config"
"github.com/igodwin/notifier/internal/domain"
"github.com/igodwin/notifier/internal/logging"
@@ -21,6 +22,7 @@ type NotificationService struct {
factory domain.NotifierFactory
queue domain.Queue
accountResolver AccountResolver
authz *auth.NotifierAuthz
notifications map[string]*domain.Notification
mu sync.RWMutex
workerCount int
@@ -34,7 +36,7 @@ type NotificationService struct {
}
// NewNotificationService creates a new notification service
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int, accountResolver AccountResolver, logger *logging.Logger) *NotificationService {
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int, accountResolver AccountResolver, authz *auth.NotifierAuthz, logger *logging.Logger) *NotificationService {
if workerCount <= 0 {
workerCount = 10
}
@@ -43,6 +45,7 @@ func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue,
factory: factory,
queue: queue,
accountResolver: accountResolver,
authz: authz,
notifications: make(map[string]*domain.Notification),
workerCount: workerCount,
stopChan: make(chan struct{}),
@@ -433,18 +436,54 @@ func (s *NotificationService) GetStats(ctx context.Context) (*domain.Notificatio
return stats, nil
}
// GetNotifiers returns information about available notifiers
// GetNotifiers returns information about available notifiers, filtered by authorization if auth context is provided
func (s *NotificationService) GetNotifiers(ctx context.Context) (*domain.NotifiersResponse, error) {
// Extract auth context from request context if available
var authCtx *auth.AuthContext
if authVal := ctx.Value("auth"); authVal != nil {
if ac, ok := authVal.(*auth.AuthContext); ok {
authCtx = ac
}
}
supportedTypes := s.factory.SupportedTypes()
notifiers := make([]domain.NotifierInfo, 0, len(supportedTypes))
for _, notifType := range supportedTypes {
accounts := s.factory.GetAccounts(notifType)
// Filter accounts by authorization if auth context is available and authz is configured
if authCtx != nil && s.authz != nil {
authorizedAccounts := make([]string, 0, len(accounts))
for _, account := range accounts {
if s.authz.IsAuthorized(authCtx, notifType, account) {
authorizedAccounts = append(authorizedAccounts, account)
}
}
accounts = authorizedAccounts
}
// Skip notifier type if no authorized accounts
if len(accounts) == 0 && authCtx != nil {
continue
}
defaultAccount := ""
if s.accountResolver != nil {
defaultAccount = s.accountResolver.GetDefaultAccount(notifType)
}
// If default account was filtered out, clear it
if authCtx != nil && s.authz != nil && defaultAccount != "" {
if !s.authz.IsAuthorized(authCtx, notifType, defaultAccount) {
defaultAccount = ""
// If available, use first authorized account as default
if len(accounts) > 0 {
defaultAccount = accounts[0]
}
}
}
notifiers = append(notifiers, domain.NotifierInfo{
Type: notifType,
Accounts: accounts,