Refactor auth and authz
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user