Further auth and authz configuration

This commit is contained in:
2025-10-30 23:29:25 -07:00
parent 52734efdec
commit 81d11e01bb
13 changed files with 695 additions and 148 deletions
+223 -6
View File
@@ -8,6 +8,10 @@ import (
"time"
"github.com/igodwin/notifier/internal/logging"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// BootstrapConfig holds configuration for bootstrap operations
@@ -20,8 +24,110 @@ type BootstrapConfig struct {
PrintToStdout bool
}
// BootstrapAdminKey creates an initial admin API key on first startup
// This should be called once per deployment
// BootstrapAdminKeyInMemory creates an initial admin API key on first startup (in-memory store)
// This is a simpler version for in-memory APIKeyStore (without database persistence)
func BootstrapAdminKeyInMemory(keyStore *APIKeyStore, 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(
"admin-bootstrap",
adminRoles,
0, // Unlimited rate limit
nil, // No expiration
)
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 {
separator := strings.Repeat("=", 60)
fmt.Println("\n" + separator)
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
fmt.Println(separator)
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(separator + "\n")
}
logger.Infof("Bootstrap admin key created successfully")
return apiKey, nil
}
// RegisterAdminKeyInMemory registers a pre-existing admin API key in the keystore
// Used when loading from Kubernetes secret or environment variable
func RegisterAdminKeyInMemory(keyStore *APIKeyStore, adminKey string, logger *logging.Logger) (*APIKey, error) {
if adminKey == "" {
return nil, fmt.Errorf("admin key value is empty")
}
// Validate key format (should start with "nk_")
if !strings.HasPrefix(adminKey, "nk_") {
return nil, fmt.Errorf("invalid admin key format: must start with 'nk_'")
}
// Create APIKey object with the provided key
adminRoles := []string{"admin", "notify-email", "notify-slack", "notify-ntfy"}
now := time.Now().UTC()
apiKey := &APIKey{
Key: adminKey,
ClientID: "admin-bootstrap",
Roles: adminRoles,
CreatedAt: now,
IsActive: true,
RateLimit: 0, // Unlimited
Name: fmt.Sprintf("admin-bootstrap-%d", now.Unix()),
}
// Add to keystore
keyStore.mu.Lock()
defer keyStore.mu.Unlock()
keyStore.keys[adminKey] = apiKey
keyStore.rateLimits[adminKey] = &RateLimiter{
maxRequests: 0, // Unlimited
window: time.Minute,
resetTime: time.Now().Add(time.Minute),
count: 0,
}
logger.Infof("Registered existing admin key from Kubernetes secret")
return apiKey, nil
}
// BootstrapAdminKey creates an initial admin API key on first startup (with database persistence)
// This should be called once per deployment when using HybridKeyStore with database
func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *BootstrapConfig, logger *logging.Logger) (*APIKey, error) {
if !cfg.Enabled {
return nil, fmt.Errorf("bootstrap is disabled")
@@ -42,7 +148,7 @@ func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *Boots
ctx,
"admin-bootstrap",
adminRoles,
0, // Unlimited rate limit
0, // Unlimited rate limit
nil, // No expiration
"system",
)
@@ -69,13 +175,14 @@ func BootstrapAdminKey(ctx context.Context, keyStore *HybridKeyStore, cfg *Boots
// Print to stdout if configured (DANGEROUS - only for interactive setup)
if cfg.PrintToStdout {
fmt.Println("\n" + strings.Repeat("=", 60))
separator := strings.Repeat("=", 60)
fmt.Println("\n" + separator)
fmt.Println("NOTIFIER BOOTSTRAP: ADMIN KEY CREATED")
fmt.Println(strings.Repeat("=", 60))
fmt.Println(separator)
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(strings.Repeat("=", 60) + "\n")
fmt.Println(separator + "\n")
}
logger.Infof("Bootstrap admin key created successfully")
@@ -97,3 +204,113 @@ func LoadBootstrapKeyFromEnv(ctx context.Context, keyStore *HybridKeyStore, logg
logger.Infof("Bootstrap key detected from environment variable")
return nil
}
// getKubernetesNamespace reads the pod's namespace from the service account token
func getKubernetesNamespace() (string, error) {
const namespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"
data, err := os.ReadFile(namespacePath)
if err != nil {
return "", fmt.Errorf("failed to read namespace from service account: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
// LoadAdminKeyFromKubernetesSecret attempts to load an existing admin key from a Kubernetes secret
// Returns the key string if found, empty string if secret doesn't exist, or error on failure
func LoadAdminKeyFromKubernetesSecret(ctx context.Context, secretName, secretKey string, logger *logging.Logger) (string, error) {
// Try to create Kubernetes client (will fail gracefully if not in cluster)
config, err := rest.InClusterConfig()
if err != nil {
logger.Debugf("Not running in Kubernetes cluster or in-cluster config unavailable: %v", err)
return "", nil // Not in Kubernetes, return empty (not an error)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
logger.Warnf("Failed to create Kubernetes client: %v", err)
return "", nil // Failed to create client, but not a fatal error
}
namespace, err := getKubernetesNamespace()
if err != nil {
logger.Warnf("Failed to determine pod namespace: %v", err)
return "", nil // Failed to get namespace, but not a fatal error
}
// Try to get the secret
secret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
if err != nil {
// Secret doesn't exist or other error occurred
logger.Debugf("Admin key secret not found in namespace %s: %v", namespace, err)
return "", nil // Secret not found is not an error
}
// Extract the key value from the secret
if secretValue, exists := secret.Data[secretKey]; exists {
logger.Infof("Found existing admin key in Kubernetes secret %s/%s", namespace, secretName)
return string(secretValue), nil
}
logger.Warnf("Kubernetes secret %s/%s exists but key %q not found", namespace, secretName, secretKey)
return "", nil
}
// CreateKubernetesSecret creates or updates a Kubernetes secret with the admin key
func CreateKubernetesSecret(ctx context.Context, secretName, secretKey, adminKey string, logger *logging.Logger) error {
// Try to create Kubernetes client (will fail gracefully if not in cluster)
config, err := rest.InClusterConfig()
if err != nil {
logger.Debugf("Not running in Kubernetes cluster, skipping secret creation: %v", err)
return nil // Not in Kubernetes, skip (not an error)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
logger.Warnf("Failed to create Kubernetes client, skipping secret creation: %v", err)
return nil // Failed to create client, but not a fatal error
}
namespace, err := getKubernetesNamespace()
if err != nil {
logger.Warnf("Failed to determine pod namespace, skipping secret creation: %v", err)
return nil // Failed to get namespace, but not a fatal error
}
// Create or update the secret
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: namespace,
Labels: map[string]string{
"app": "notifier",
},
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
secretKey: []byte(adminKey),
},
}
// Try to get existing secret first
existingSecret, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
if err == nil {
// Secret exists, update it
secret.ResourceVersion = existingSecret.ResourceVersion
_, err = clientset.CoreV1().Secrets(namespace).Update(ctx, secret, metav1.UpdateOptions{})
if err != nil {
logger.Warnf("Failed to update Kubernetes secret %s/%s: %v", namespace, secretName, err)
return nil // Log warning but don't fail
}
logger.Infof("Updated admin key in Kubernetes secret %s/%s", namespace, secretName)
} else {
// Secret doesn't exist, create it
_, err = clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{})
if err != nil {
logger.Warnf("Failed to create Kubernetes secret %s/%s: %v", namespace, secretName, err)
return nil // Log warning but don't fail
}
logger.Infof("Created Kubernetes secret %s/%s with admin key", namespace, secretName)
}
return nil
}
+27 -14
View File
@@ -6,7 +6,8 @@ import (
"fmt"
"time"
_ "github.com/lib/pq"
"github.com/lib/pq"
_ "github.com/lib/pq" // PostgreSQL driver
)
// KeyStoreDB provides persistent storage for API keys using PostgreSQL
@@ -37,9 +38,10 @@ func NewKeyStoreDB(dbURL string) (*KeyStoreDB, error) {
return ks, nil
}
// initializeSchema creates the necessary tables if they don't exist
// initializeSchema creates the necessary tables and indexes if they don't exist
func (ks *KeyStoreDB) initializeSchema() error {
schema := `
// Create tables
tableSchema := `
-- API Keys table
CREATE TABLE IF NOT EXISTS api_keys (
id SERIAL PRIMARY KEY,
@@ -53,11 +55,7 @@ func (ks *KeyStoreDB) initializeSchema() error {
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)
metadata JSONB DEFAULT '{}'::jsonb
);
-- Audit log for key operations
@@ -67,14 +65,29 @@ func (ks *KeyStoreDB) initializeSchema() error {
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)
details JSONB DEFAULT '{}'::jsonb
);
`
_, err := ks.db.Exec(schema)
return err
if _, err := ks.db.Exec(tableSchema); err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
// Create indexes separately (PostgreSQL syntax)
indexSchema := `
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key);
CREATE INDEX IF NOT EXISTS idx_api_keys_client_id ON api_keys(client_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
CREATE INDEX IF NOT EXISTS idx_api_keys_expires ON api_keys(expires_at);
CREATE INDEX IF NOT EXISTS idx_api_key_audit_log_key_id ON api_key_audit_log(key_id);
CREATE INDEX IF NOT EXISTS idx_api_key_audit_log_performed_at ON api_key_audit_log(performed_at);
`
if _, err := ks.db.Exec(indexSchema); err != nil {
return fmt.Errorf("failed to create indexes: %w", err)
}
return nil
}
// SaveKey persists an API key to the database
@@ -94,7 +107,7 @@ func (ks *KeyStoreDB) SaveKey(ctx context.Context, key *APIKey, createdBy string
key.Key,
key.Name,
key.ClientID,
key.Roles,
pq.Array(key.Roles), // Convert Go slice to PostgreSQL array
key.CreatedAt,
key.LastUsedAt,
key.ExpiresAt,
+2 -29
View File
@@ -12,7 +12,7 @@ import (
// 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
db *KeyStoreDB // Database backend for persistence
mu sync.RWMutex
}
@@ -128,34 +128,7 @@ func (h *HybridKeyStore) UpdateLastUsed(ctx context.Context, keyStr string) erro
// CheckRateLimit checks if a key has exceeded its rate limit
func (h *HybridKeyStore) CheckRateLimit(keyStr string) (bool, error) {
h.cache.mu.RLock()
defer h.cache.mu.RUnlock()
limiter, exists := h.cache.rateLimits[keyStr]
if !exists {
return false, fmt.Errorf("rate limiter not found")
}
// Check if we're under the rate limit
if limiter.maxRequests == 0 {
return true, nil // Unlimited
}
limiter.mu.Lock()
defer limiter.mu.Unlock()
now := time.Now()
if now.After(limiter.resetTime) {
limiter.resetTime = now.Add(limiter.window)
limiter.count = 0
}
if limiter.count >= limiter.maxRequests {
return false, nil
}
limiter.count++
return true, nil
return h.cache.CheckRateLimit(keyStr)
}
// GetAuditLog retrieves audit log for a key
+46 -4
View File
@@ -66,8 +66,24 @@ type HealthCheckConfig struct {
// AuthConfig contains authentication and authorization configuration
type AuthConfig struct {
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
Enabled bool `mapstructure:"enabled"` // Enable API key authentication
DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited)
Database DatabaseConfig `mapstructure:"database"` // Database configuration for persistent key storage
Bootstrap BootstrapConf `mapstructure:"bootstrap"` // Bootstrap admin key configuration
}
// DatabaseConfig contains database connection configuration
type DatabaseConfig struct {
URL string `mapstructure:"url"` // Database connection URL (e.g., "postgresql://user:pass@host:5432/db")
}
// BootstrapConf contains configuration for bootstrap admin key creation
type BootstrapConf struct {
Enabled bool `mapstructure:"enabled"` // Enable bootstrap on startup
AdminKeyFileName string `mapstructure:"admin_key_file"` // File to save the generated admin key
PrintToStdout bool `mapstructure:"print_to_stdout"` // Print admin key to stdout (only for setup)
KubernetesSecretName string `mapstructure:"kubernetes_secret_name"` // Kubernetes secret name (e.g., "notifier-admin-key")
KubernetesSecretKey string `mapstructure:"kubernetes_secret_key"` // Key within secret (e.g., "admin-key")
}
// CORSConfig contains CORS (Cross-Origin Resource Sharing) configuration
@@ -199,8 +215,13 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("health_check.interval", 30)
// Auth defaults
v.SetDefault("auth.enabled", false) // Authentication disabled by default
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
v.SetDefault("auth.enabled", false) // Authentication disabled by default
v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default
v.SetDefault("auth.bootstrap.enabled", false) // Bootstrap disabled by default
v.SetDefault("auth.bootstrap.admin_key_file", "") // No file by default
v.SetDefault("auth.bootstrap.print_to_stdout", false) // Don't print to stdout by default
v.SetDefault("auth.bootstrap.kubernetes_secret_name", "notifier-admin-key") // Default secret name
v.SetDefault("auth.bootstrap.kubernetes_secret_key", "admin-key") // Default secret key
// CORS defaults - secure by default (no origins allowed)
v.SetDefault("cors.allowed_origins", []string{}) // Empty by default - must be explicitly configured
@@ -393,6 +414,27 @@ func (c *Config) Sanitize() map[string]interface{} {
}
sanitized["notifiers"] = notifiers
// Sanitize auth config
sanitized["auth"] = map[string]interface{}{
"enabled": c.Auth.Enabled,
"bootstrap": map[string]interface{}{
"enabled": c.Auth.Bootstrap.Enabled,
"admin_key_file": c.Auth.Bootstrap.AdminKeyFileName,
"print_to_stdout": c.Auth.Bootstrap.PrintToStdout,
"kubernetes_secret_name": c.Auth.Bootstrap.KubernetesSecretName,
"kubernetes_secret_key": c.Auth.Bootstrap.KubernetesSecretKey,
},
}
// Sanitize retention config
sanitized["retention"] = map[string]interface{}{
"enabled": c.Retention.Enabled,
"ttl": c.Retention.TTL,
"check_frequency": c.Retention.CheckFrequency,
"max_size": c.Retention.MaxSize,
}
return sanitized
}