diff --git a/backend/internal/application/services/config_service.go b/backend/internal/application/services/config_service.go new file mode 100644 index 0000000..cf7c976 --- /dev/null +++ b/backend/internal/application/services/config_service.go @@ -0,0 +1,836 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package services + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/pkg/config" + "github.com/btouchard/ackify-ce/backend/pkg/logger" + mail "github.com/go-mail/mail/v2" +) + +var ( + ErrNoAuthMethod = errors.New("at least one authentication method must be enabled") + ErrMagicLinkNeedsSMTP = errors.New("MagicLink requires SMTP to be configured") + ErrOIDCNeedsURLs = errors.New("custom OIDC provider requires auth, token, and userinfo URLs") + ErrInvalidCategory = errors.New("invalid configuration category") +) + +// configRepository defines the interface for config storage +type configRepository interface { + GetByCategory(ctx context.Context, category models.ConfigCategory) (*models.TenantConfig, error) + GetAll(ctx context.Context) ([]*models.TenantConfig, error) + Upsert(ctx context.Context, category models.ConfigCategory, config json.RawMessage, secrets []byte, updatedBy string) error + IsSeeded(ctx context.Context) (bool, error) + MarkSeeded(ctx context.Context) error + DeleteAll(ctx context.Context) error + GetLatestUpdatedAt(ctx context.Context) (time.Time, error) +} + +// ConfigService manages application configuration with hot-reload support +type ConfigService struct { + repo configRepository + encryptionKey []byte + envConfig *config.Config + + currentConfig atomic.Value // *models.MutableConfig + + subscribersMu sync.RWMutex + subscribers []chan<- models.MutableConfig +} + +// NewConfigService creates a new configuration service +func NewConfigService(repo configRepository, envConfig *config.Config, encryptionKey []byte) *ConfigService { + svc := &ConfigService{ + repo: repo, + envConfig: envConfig, + encryptionKey: encryptionKey, + subscribers: make([]chan<- models.MutableConfig, 0), + } + svc.currentConfig.Store(&models.MutableConfig{}) + return svc +} + +// Initialize loads config from DB or seeds from ENV on first start +func (s *ConfigService) Initialize(ctx context.Context) error { + seeded, err := s.repo.IsSeeded(ctx) + if err != nil { + return fmt.Errorf("failed to check if seeded: %w", err) + } + + if !seeded { + logger.Logger.Info("First startup: seeding configuration from environment") + if err := s.seedFromENV(ctx); err != nil { + return fmt.Errorf("failed to seed config: %w", err) + } + } + + return s.reload(ctx) +} + +// GetConfig returns the current config (lock-free read) +func (s *ConfigService) GetConfig() *models.MutableConfig { + return s.currentConfig.Load().(*models.MutableConfig) +} + +// UpdateSection updates a specific config section +func (s *ConfigService) UpdateSection(ctx context.Context, category models.ConfigCategory, input json.RawMessage, updatedBy string) error { + if !category.IsValid() { + return ErrInvalidCategory + } + + // Parse the input to validate structure + if err := s.validateSection(category, input); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + + // Get current config to check cross-category validation + currentConfig := s.GetConfig() + + // Apply the update temporarily to check cross-category validation + tempConfig := *currentConfig + if err := s.applyUpdateToConfig(&tempConfig, category, input); err != nil { + return fmt.Errorf("failed to apply update: %w", err) + } + + // Validate cross-category rules + if err := s.validateCrossCategory(&tempConfig); err != nil { + return err + } + + // Extract and encrypt secrets + configWithoutSecrets, encryptedSecrets, err := s.processSecrets(category, input) + if err != nil { + return fmt.Errorf("failed to process secrets: %w", err) + } + + // Store in DB + if err := s.repo.Upsert(ctx, category, configWithoutSecrets, encryptedSecrets, updatedBy); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + // Hot-reload + return s.reload(ctx) +} + +// ResetFromENV resets config to current ENV values +func (s *ConfigService) ResetFromENV(ctx context.Context, updatedBy string) error { + // Delete all existing config + if err := s.repo.DeleteAll(ctx); err != nil { + return fmt.Errorf("failed to delete existing config: %w", err) + } + + // Seed from ENV + if err := s.seedFromENV(ctx); err != nil { + return fmt.Errorf("failed to seed from ENV: %w", err) + } + + // Reload + return s.reload(ctx) +} + +// Subscribe registers a channel to receive config updates +func (s *ConfigService) Subscribe() <-chan models.MutableConfig { + ch := make(chan models.MutableConfig, 1) + s.subscribersMu.Lock() + s.subscribers = append(s.subscribers, ch) + s.subscribersMu.Unlock() + return ch +} + +// CloseAllSubscribers closes all subscriber channels (used during shutdown) +func (s *ConfigService) CloseAllSubscribers() { + s.subscribersMu.Lock() + defer s.subscribersMu.Unlock() + + for _, ch := range s.subscribers { + close(ch) + } + s.subscribers = nil +} + +// --- Test Connection Methods --- + +// TestSMTP tests SMTP connection +func (s *ConfigService) TestSMTP(ctx context.Context, cfg models.SMTPConfig) error { + if cfg.Host == "" { + return errors.New("SMTP host is required") + } + + // Handle masked password - use current config's password + if models.IsSecretMasked(cfg.Password) { + current := s.GetConfig() + cfg.Password = current.SMTP.Password + } + + d := mail.NewDialer(cfg.Host, cfg.Port, cfg.Username, cfg.Password) + + if cfg.TLS { + d.SSL = true + d.TLSConfig = &tls.Config{ + ServerName: cfg.Host, + InsecureSkipVerify: cfg.InsecureSkipVerify, + } + } else if cfg.StartTLS { + d.TLSConfig = &tls.Config{ + ServerName: cfg.Host, + InsecureSkipVerify: cfg.InsecureSkipVerify, + } + d.StartTLSPolicy = mail.MandatoryStartTLS + } + + timeout, err := time.ParseDuration(cfg.Timeout) + if err != nil { + timeout = 10 * time.Second + } + d.Timeout = timeout + + // Try to connect + closer, err := d.Dial() + if err != nil { + return fmt.Errorf("SMTP connection failed: %w", err) + } + defer closer.Close() + + return nil +} + +// TestS3 tests S3 connection +func (s *ConfigService) TestS3(ctx context.Context, cfg models.StorageConfig) error { + if cfg.Type != "s3" { + return errors.New("storage type must be 's3' for S3 test") + } + if cfg.S3Bucket == "" { + return errors.New("S3 bucket is required") + } + + // Handle masked secret key - use current config's key + if models.IsSecretMasked(cfg.S3SecretKey) { + current := s.GetConfig() + cfg.S3SecretKey = current.Storage.S3SecretKey + } + + // Build AWS config + opts := []func(*awsconfig.LoadOptions) error{ + awsconfig.WithRegion(cfg.S3Region), + } + + if cfg.S3AccessKey != "" && cfg.S3SecretKey != "" { + opts = append(opts, awsconfig.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(cfg.S3AccessKey, cfg.S3SecretKey, ""), + )) + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + + // Create S3 client + s3Opts := []func(*s3.Options){} + if cfg.S3Endpoint != "" { + s3Opts = append(s3Opts, func(o *s3.Options) { + o.BaseEndpoint = aws.String(cfg.S3Endpoint) + o.UsePathStyle = true + }) + } + + client := s3.NewFromConfig(awsCfg, s3Opts...) + + // Test bucket access + _, err = client.HeadBucket(ctx, &s3.HeadBucketInput{ + Bucket: aws.String(cfg.S3Bucket), + }) + if err != nil { + return fmt.Errorf("S3 bucket access failed: %w", err) + } + + return nil +} + +// TestOIDC tests OIDC configuration by fetching the well-known endpoint +func (s *ConfigService) TestOIDC(ctx context.Context, cfg models.OIDCConfig) error { + if !cfg.Enabled { + return errors.New("OIDC is not enabled") + } + + // Handle masked client secret - use current config's secret + if models.IsSecretMasked(cfg.ClientSecret) { + current := s.GetConfig() + cfg.ClientSecret = current.OIDC.ClientSecret + } + + // Determine the well-known URL based on provider + var wellKnownURL string + switch cfg.Provider { + case "google": + wellKnownURL = "https://accounts.google.com/.well-known/openid-configuration" + case "github": + // GitHub doesn't have a standard OIDC discovery endpoint, just validate URLs exist + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return errors.New("GitHub OAuth requires client_id and client_secret") + } + return nil + case "gitlab": + baseURL := "https://gitlab.com" + if cfg.AuthURL != "" && strings.Contains(cfg.AuthURL, "gitlab") { + // Extract base URL from auth URL + parts := strings.Split(cfg.AuthURL, "/oauth") + if len(parts) > 0 { + baseURL = parts[0] + } + } + wellKnownURL = baseURL + "/.well-known/openid-configuration" + case "custom": + // For custom providers, validate that required URLs are present + if cfg.AuthURL == "" || cfg.TokenURL == "" || cfg.UserInfoURL == "" { + return ErrOIDCNeedsURLs + } + // Try to derive issuer from auth URL + parts := strings.Split(cfg.AuthURL, "/") + if len(parts) >= 3 { + issuer := strings.Join(parts[:3], "/") + wellKnownURL = issuer + "/.well-known/openid-configuration" + } + default: + return fmt.Errorf("unknown OIDC provider: %s", cfg.Provider) + } + + if wellKnownURL == "" { + return nil // No well-known to check + } + + // Fetch well-known endpoint + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to fetch OIDC discovery endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("OIDC discovery endpoint returned status %d", resp.StatusCode) + } + + // Parse response to validate it's a valid OIDC configuration + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var discovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + } + + if err := json.Unmarshal(body, &discovery); err != nil { + return fmt.Errorf("invalid OIDC discovery response: %w", err) + } + + if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint == "" { + return errors.New("OIDC discovery missing required endpoints") + } + + return nil +} + +// --- Internal Methods --- + +// seedFromENV seeds configuration from environment variables +func (s *ConfigService) seedFromENV(ctx context.Context) error { + // General config + general := models.GeneralConfig{ + Organisation: s.envConfig.App.Organisation, + OnlyAdminCanCreate: s.envConfig.App.OnlyAdminCanCreate, + } + if err := s.upsertSection(ctx, models.ConfigCategoryGeneral, general, nil, "system"); err != nil { + return fmt.Errorf("failed to seed general config: %w", err) + } + + // OIDC config + oidc := models.OIDCConfig{ + Enabled: s.envConfig.Auth.OAuthEnabled, + Provider: s.detectOAuthProvider(), + ClientID: s.envConfig.OAuth.ClientID, + ClientSecret: s.envConfig.OAuth.ClientSecret, + AuthURL: s.envConfig.OAuth.AuthURL, + TokenURL: s.envConfig.OAuth.TokenURL, + UserInfoURL: s.envConfig.OAuth.UserInfoURL, + LogoutURL: s.envConfig.OAuth.LogoutURL, + Scopes: s.envConfig.OAuth.Scopes, + AllowedDomain: s.envConfig.OAuth.AllowedDomain, + AutoLogin: s.envConfig.OAuth.AutoLogin, + } + oidcSecrets := models.OIDCSecrets{ClientSecret: s.envConfig.OAuth.ClientSecret} + if err := s.upsertSection(ctx, models.ConfigCategoryOIDC, oidc, oidcSecrets, "system"); err != nil { + return fmt.Errorf("failed to seed OIDC config: %w", err) + } + + // MagicLink config + magicLink := models.MagicLinkConfig{ + Enabled: s.envConfig.Auth.MagicLinkEnabled, + } + if err := s.upsertSection(ctx, models.ConfigCategoryMagicLink, magicLink, nil, "system"); err != nil { + return fmt.Errorf("failed to seed MagicLink config: %w", err) + } + + // SMTP config + smtp := models.SMTPConfig{ + Host: s.envConfig.Mail.Host, + Port: s.envConfig.Mail.Port, + Username: s.envConfig.Mail.Username, + Password: s.envConfig.Mail.Password, + TLS: s.envConfig.Mail.TLS, + StartTLS: s.envConfig.Mail.StartTLS, + InsecureSkipVerify: s.envConfig.Mail.InsecureSkipVerify, + Timeout: s.envConfig.Mail.Timeout, + From: s.envConfig.Mail.From, + FromName: s.envConfig.Mail.FromName, + SubjectPrefix: s.envConfig.Mail.SubjectPrefix, + } + smtpSecrets := models.SMTPSecrets{Password: s.envConfig.Mail.Password} + if err := s.upsertSection(ctx, models.ConfigCategorySMTP, smtp, smtpSecrets, "system"); err != nil { + return fmt.Errorf("failed to seed SMTP config: %w", err) + } + + // Storage config + storage := models.StorageConfig{ + Type: s.envConfig.Storage.Type, + MaxSizeMB: s.envConfig.Storage.MaxSizeMB, + LocalPath: s.envConfig.Storage.LocalPath, + S3Endpoint: s.envConfig.Storage.S3Endpoint, + S3Bucket: s.envConfig.Storage.S3Bucket, + S3AccessKey: s.envConfig.Storage.S3AccessKey, + S3SecretKey: s.envConfig.Storage.S3SecretKey, + S3Region: s.envConfig.Storage.S3Region, + S3UseSSL: s.envConfig.Storage.S3UseSSL, + } + storageSecrets := models.StorageSecrets{S3SecretKey: s.envConfig.Storage.S3SecretKey} + if err := s.upsertSection(ctx, models.ConfigCategoryStorage, storage, storageSecrets, "system"); err != nil { + return fmt.Errorf("failed to seed Storage config: %w", err) + } + + // Mark as seeded + if err := s.repo.MarkSeeded(ctx); err != nil { + return fmt.Errorf("failed to mark config as seeded: %w", err) + } + + return nil +} + +// detectOAuthProvider detects the OAuth provider from the configuration +func (s *ConfigService) detectOAuthProvider() string { + authURL := s.envConfig.OAuth.AuthURL + if strings.Contains(authURL, "accounts.google.com") { + return "google" + } + if strings.Contains(authURL, "github.com") { + return "github" + } + if strings.Contains(authURL, "gitlab") { + return "gitlab" + } + if authURL != "" { + return "custom" + } + return "" +} + +// upsertSection marshals and stores a config section +func (s *ConfigService) upsertSection(ctx context.Context, category models.ConfigCategory, cfg any, secrets any, updatedBy string) error { + configJSON, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Remove secrets from config JSON + configWithoutSecrets := s.removeSecretsFromJSON(category, configJSON) + + var encryptedSecrets []byte + if secrets != nil { + secretsJSON, err := json.Marshal(secrets) + if err != nil { + return fmt.Errorf("failed to marshal secrets: %w", err) + } + encryptedSecrets, err = s.encryptSecrets(secretsJSON) + if err != nil { + return fmt.Errorf("failed to encrypt secrets: %w", err) + } + } + + return s.repo.Upsert(ctx, category, configWithoutSecrets, encryptedSecrets, updatedBy) +} + +// removeSecretsFromJSON removes secret fields from config JSON +func (s *ConfigService) removeSecretsFromJSON(category models.ConfigCategory, configJSON json.RawMessage) json.RawMessage { + var data map[string]any + if err := json.Unmarshal(configJSON, &data); err != nil { + return configJSON + } + + // Remove secret fields based on category + switch category { + case models.ConfigCategoryOIDC: + delete(data, "client_secret") + case models.ConfigCategorySMTP: + delete(data, "password") + case models.ConfigCategoryStorage: + delete(data, "s3_secret_key") + } + + result, err := json.Marshal(data) + if err != nil { + return configJSON + } + return result +} + +// reload fetches all config from DB and notifies subscribers +func (s *ConfigService) reload(ctx context.Context) error { + configs, err := s.repo.GetAll(ctx) + if err != nil { + return fmt.Errorf("failed to load configs: %w", err) + } + + mutable := &models.MutableConfig{} + for _, cfg := range configs { + if err := s.populateCategory(mutable, cfg); err != nil { + logger.Logger.Warn("Failed to parse config category", "category", cfg.Category, "error", err) + } + } + + // Get latest updated_at + updatedAt, err := s.repo.GetLatestUpdatedAt(ctx) + if err != nil { + logger.Logger.Warn("Failed to get latest updated_at", "error", err) + } else { + mutable.UpdatedAt = updatedAt + } + + // Atomic swap + s.currentConfig.Store(mutable) + + // Notify subscribers + s.notifySubscribers(*mutable) + + logger.Logger.Info("Configuration reloaded", "updated_at", mutable.UpdatedAt) + return nil +} + +// populateCategory populates the MutableConfig with data from a TenantConfig +func (s *ConfigService) populateCategory(mutable *models.MutableConfig, tc *models.TenantConfig) error { + // Decrypt secrets if present + var secrets map[string]string + if len(tc.SecretsEncrypted) > 0 { + decrypted, err := s.decryptSecrets(tc.SecretsEncrypted) + if err != nil { + logger.Logger.Warn("Failed to decrypt secrets", "category", tc.Category, "error", err) + } else { + _ = json.Unmarshal(decrypted, &secrets) + } + } + + switch tc.Category { + case models.ConfigCategoryGeneral: + var cfg models.GeneralConfig + if err := json.Unmarshal(tc.Config, &cfg); err != nil { + return err + } + mutable.General = cfg + + case models.ConfigCategoryOIDC: + var cfg models.OIDCConfig + if err := json.Unmarshal(tc.Config, &cfg); err != nil { + return err + } + if secrets != nil { + if v, ok := secrets["client_secret"]; ok { + cfg.ClientSecret = v + } + } + mutable.OIDC = cfg + + case models.ConfigCategoryMagicLink: + var cfg models.MagicLinkConfig + if err := json.Unmarshal(tc.Config, &cfg); err != nil { + return err + } + mutable.MagicLink = cfg + + case models.ConfigCategorySMTP: + var cfg models.SMTPConfig + if err := json.Unmarshal(tc.Config, &cfg); err != nil { + return err + } + if secrets != nil { + if v, ok := secrets["password"]; ok { + cfg.Password = v + } + } + mutable.SMTP = cfg + + case models.ConfigCategoryStorage: + var cfg models.StorageConfig + if err := json.Unmarshal(tc.Config, &cfg); err != nil { + return err + } + if secrets != nil { + if v, ok := secrets["s3_secret_key"]; ok { + cfg.S3SecretKey = v + } + } + mutable.Storage = cfg + } + + return nil +} + +// notifySubscribers sends config updates to all subscribers +func (s *ConfigService) notifySubscribers(cfg models.MutableConfig) { + s.subscribersMu.RLock() + defer s.subscribersMu.RUnlock() + + for _, ch := range s.subscribers { + select { + case ch <- cfg: + default: + // Channel full, skip this update + } + } +} + +// validateSection validates a single config section +func (s *ConfigService) validateSection(category models.ConfigCategory, input json.RawMessage) error { + switch category { + case models.ConfigCategoryGeneral: + var cfg models.GeneralConfig + return json.Unmarshal(input, &cfg) + + case models.ConfigCategoryOIDC: + var cfg models.OIDCConfig + if err := json.Unmarshal(input, &cfg); err != nil { + return err + } + if cfg.Enabled && cfg.Provider == "custom" { + if cfg.AuthURL == "" || cfg.TokenURL == "" || cfg.UserInfoURL == "" { + return ErrOIDCNeedsURLs + } + } + return nil + + case models.ConfigCategoryMagicLink: + var cfg models.MagicLinkConfig + return json.Unmarshal(input, &cfg) + + case models.ConfigCategorySMTP: + var cfg models.SMTPConfig + return json.Unmarshal(input, &cfg) + + case models.ConfigCategoryStorage: + var cfg models.StorageConfig + if err := json.Unmarshal(input, &cfg); err != nil { + return err + } + if cfg.Type == "s3" && cfg.S3Bucket == "" { + return errors.New("S3 bucket is required when storage type is 's3'") + } + return nil + } + + return ErrInvalidCategory +} + +// applyUpdateToConfig applies an update to a MutableConfig for validation +func (s *ConfigService) applyUpdateToConfig(cfg *models.MutableConfig, category models.ConfigCategory, input json.RawMessage) error { + switch category { + case models.ConfigCategoryGeneral: + return json.Unmarshal(input, &cfg.General) + case models.ConfigCategoryOIDC: + var oidc models.OIDCConfig + if err := json.Unmarshal(input, &oidc); err != nil { + return err + } + // Preserve existing secret if masked + if models.IsSecretMasked(oidc.ClientSecret) { + oidc.ClientSecret = cfg.OIDC.ClientSecret + } + cfg.OIDC = oidc + return nil + case models.ConfigCategoryMagicLink: + return json.Unmarshal(input, &cfg.MagicLink) + case models.ConfigCategorySMTP: + var smtp models.SMTPConfig + if err := json.Unmarshal(input, &smtp); err != nil { + return err + } + // Preserve existing secret if masked + if models.IsSecretMasked(smtp.Password) { + smtp.Password = cfg.SMTP.Password + } + cfg.SMTP = smtp + return nil + case models.ConfigCategoryStorage: + var storage models.StorageConfig + if err := json.Unmarshal(input, &storage); err != nil { + return err + } + // Preserve existing secret if masked + if models.IsSecretMasked(storage.S3SecretKey) { + storage.S3SecretKey = cfg.Storage.S3SecretKey + } + cfg.Storage = storage + return nil + } + return ErrInvalidCategory +} + +// validateCrossCategory validates cross-category rules +func (s *ConfigService) validateCrossCategory(cfg *models.MutableConfig) error { + // At least one auth method must be enabled + if !cfg.HasAtLeastOneAuthMethod() { + return ErrNoAuthMethod + } + + // MagicLink requires SMTP + if !cfg.MagicLinkRequiresSMTP() { + return ErrMagicLinkNeedsSMTP + } + + return nil +} + +// processSecrets extracts and encrypts secrets from input +func (s *ConfigService) processSecrets(category models.ConfigCategory, input json.RawMessage) (json.RawMessage, []byte, error) { + var data map[string]any + if err := json.Unmarshal(input, &data); err != nil { + return nil, nil, err + } + + secrets := make(map[string]string) + currentConfig := s.GetConfig() + + switch category { + case models.ConfigCategoryOIDC: + if secret, ok := data["client_secret"].(string); ok && secret != "" { + if models.IsSecretMasked(secret) { + secrets["client_secret"] = currentConfig.OIDC.ClientSecret + } else { + secrets["client_secret"] = secret + } + } + delete(data, "client_secret") + + case models.ConfigCategorySMTP: + if secret, ok := data["password"].(string); ok && secret != "" { + if models.IsSecretMasked(secret) { + secrets["password"] = currentConfig.SMTP.Password + } else { + secrets["password"] = secret + } + } + delete(data, "password") + + case models.ConfigCategoryStorage: + if secret, ok := data["s3_secret_key"].(string); ok && secret != "" { + if models.IsSecretMasked(secret) { + secrets["s3_secret_key"] = currentConfig.Storage.S3SecretKey + } else { + secrets["s3_secret_key"] = secret + } + } + delete(data, "s3_secret_key") + } + + configWithoutSecrets, err := json.Marshal(data) + if err != nil { + return nil, nil, err + } + + var encryptedSecrets []byte + if len(secrets) > 0 { + secretsJSON, err := json.Marshal(secrets) + if err != nil { + return nil, nil, err + } + encryptedSecrets, err = s.encryptSecrets(secretsJSON) + if err != nil { + return nil, nil, err + } + } + + return configWithoutSecrets, encryptedSecrets, nil +} + +// encryptSecrets encrypts secrets using AES-256-GCM +func (s *ConfigService) encryptSecrets(plaintext []byte) ([]byte, error) { + if len(s.encryptionKey) < 32 { + return nil, errors.New("encryption key too short") + } + + block, err := aes.NewCipher(s.encryptionKey[:32]) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +// decryptSecrets decrypts secrets using AES-256-GCM +func (s *ConfigService) decryptSecrets(ciphertext []byte) ([]byte, error) { + if len(s.encryptionKey) < 32 { + return nil, errors.New("encryption key too short") + } + + block, err := aes.NewCipher(s.encryptionKey[:32]) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, errors.New("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + return gcm.Open(nil, nonce, ciphertext, nil) +} diff --git a/backend/internal/application/services/config_service_test.go b/backend/internal/application/services/config_service_test.go new file mode 100644 index 0000000..64a4a94 --- /dev/null +++ b/backend/internal/application/services/config_service_test.go @@ -0,0 +1,658 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package services + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/pkg/config" +) + +// fakeConfigRepository is a mock implementation of configRepository +type fakeConfigRepository struct { + configs map[models.ConfigCategory]*models.TenantConfig + seeded bool + shouldFailGet bool + shouldFailGetAll bool + shouldFailUpsert bool + shouldFailSeeded bool + shouldFailMark bool + shouldFailDelete bool +} + +func newFakeConfigRepository() *fakeConfigRepository { + return &fakeConfigRepository{ + configs: make(map[models.ConfigCategory]*models.TenantConfig), + } +} + +func (f *fakeConfigRepository) GetByCategory(_ context.Context, category models.ConfigCategory) (*models.TenantConfig, error) { + if f.shouldFailGet { + return nil, errors.New("repository get failed") + } + tc, ok := f.configs[category] + if !ok { + return nil, errors.New("config not found") + } + return tc, nil +} + +func (f *fakeConfigRepository) GetAll(_ context.Context) ([]*models.TenantConfig, error) { + if f.shouldFailGetAll { + return nil, errors.New("repository get all failed") + } + result := make([]*models.TenantConfig, 0, len(f.configs)) + for _, tc := range f.configs { + result = append(result, tc) + } + return result, nil +} + +func (f *fakeConfigRepository) Upsert(_ context.Context, category models.ConfigCategory, cfg json.RawMessage, secrets []byte, updatedBy string) error { + if f.shouldFailUpsert { + return errors.New("repository upsert failed") + } + f.configs[category] = &models.TenantConfig{ + Category: category, + Config: cfg, + SecretsEncrypted: secrets, + UpdatedAt: time.Now(), + } + return nil +} + +func (f *fakeConfigRepository) IsSeeded(_ context.Context) (bool, error) { + if f.shouldFailSeeded { + return false, errors.New("repository is seeded failed") + } + return f.seeded, nil +} + +func (f *fakeConfigRepository) MarkSeeded(_ context.Context) error { + if f.shouldFailMark { + return errors.New("repository mark seeded failed") + } + f.seeded = true + return nil +} + +func (f *fakeConfigRepository) DeleteAll(_ context.Context) error { + if f.shouldFailDelete { + return errors.New("repository delete all failed") + } + f.configs = make(map[models.ConfigCategory]*models.TenantConfig) + return nil +} + +func (f *fakeConfigRepository) GetLatestUpdatedAt(_ context.Context) (time.Time, error) { + var latest time.Time + for _, tc := range f.configs { + if tc.UpdatedAt.After(latest) { + latest = tc.UpdatedAt + } + } + return latest, nil +} + +// createTestConfigService creates a ConfigService with a fake repository for testing +func createTestConfigService() (*ConfigService, *fakeConfigRepository) { + repo := newFakeConfigRepository() + envConfig := &config.Config{ + App: config.AppConfig{ + Organisation: "Test Org", + OnlyAdminCanCreate: false, + }, + Auth: config.AuthConfig{ + OAuthEnabled: true, + MagicLinkEnabled: false, + }, + OAuth: config.OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo", + Scopes: []string{"openid", "email", "profile"}, + AllowedDomain: "@example.com", + }, + Mail: config.MailConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "test@example.com", + Password: "smtp-password", + TLS: false, + StartTLS: true, + From: "noreply@example.com", + FromName: "Test App", + Timeout: "10s", + }, + Storage: config.StorageConfig{ + Type: "local", + MaxSizeMB: 50, + LocalPath: "/data/documents", + }, + } + encryptionKey := make([]byte, 32) + for i := range encryptionKey { + encryptionKey[i] = byte(i) + } + + svc := NewConfigService(repo, envConfig, encryptionKey) + return svc, repo +} + +func TestNewConfigService(t *testing.T) { + svc, _ := createTestConfigService() + if svc == nil { + t.Fatal("expected non-nil ConfigService") + } + + cfg := svc.GetConfig() + if cfg == nil { + t.Fatal("expected non-nil config") + } +} + +func TestConfigService_Initialize_FirstStartup(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + err := svc.Initialize(ctx) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Verify config was seeded + if !repo.seeded { + t.Error("expected config to be marked as seeded") + } + + // Verify configs were created + if len(repo.configs) == 0 { + t.Error("expected configs to be created") + } + + // Verify we can get the config + cfg := svc.GetConfig() + if cfg.General.Organisation != "Test Org" { + t.Errorf("expected organisation 'Test Org', got '%s'", cfg.General.Organisation) + } + if !cfg.OIDC.Enabled { + t.Error("expected OIDC to be enabled") + } +} + +func TestConfigService_Initialize_AlreadySeeded(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + // Pre-seed with existing config + repo.seeded = true + generalCfg, _ := json.Marshal(models.GeneralConfig{ + Organisation: "Existing Org", + OnlyAdminCanCreate: true, + }) + repo.configs[models.ConfigCategoryGeneral] = &models.TenantConfig{ + Category: models.ConfigCategoryGeneral, + Config: generalCfg, + UpdatedAt: time.Now(), + } + + err := svc.Initialize(ctx) + if err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + // Verify it loaded from DB, not ENV + cfg := svc.GetConfig() + if cfg.General.Organisation != "Existing Org" { + t.Errorf("expected organisation 'Existing Org', got '%s'", cfg.General.Organisation) + } +} + +func TestConfigService_UpdateSection_General(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + // First initialize + _ = svc.Initialize(ctx) + + // Update general config + input := json.RawMessage(`{"organisation": "Updated Org", "only_admin_can_create": true}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com") + if err != nil { + t.Fatalf("UpdateSection failed: %v", err) + } + + // Verify update + cfg := svc.GetConfig() + if cfg.General.Organisation != "Updated Org" { + t.Errorf("expected organisation 'Updated Org', got '%s'", cfg.General.Organisation) + } + if !cfg.General.OnlyAdminCanCreate { + t.Error("expected OnlyAdminCanCreate to be true") + } + + // Verify it was saved to repo + tc, err := repo.GetByCategory(ctx, models.ConfigCategoryGeneral) + if err != nil { + t.Fatalf("GetByCategory failed: %v", err) + } + var savedCfg models.GeneralConfig + _ = json.Unmarshal(tc.Config, &savedCfg) + if savedCfg.Organisation != "Updated Org" { + t.Errorf("expected saved organisation 'Updated Org', got '%s'", savedCfg.Organisation) + } +} + +func TestConfigService_UpdateSection_InvalidCategory(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + input := json.RawMessage(`{"foo": "bar"}`) + err := svc.UpdateSection(ctx, "invalid", input, "admin@test.com") + if err == nil { + t.Error("expected error for invalid category") + } +} + +func TestConfigService_UpdateSection_ValidationError(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // Disable both auth methods - should fail validation + oidcInput := json.RawMessage(`{"enabled": false, "provider": ""}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryOIDC, oidcInput, "admin@test.com") + if err == nil { + t.Error("expected error when disabling all auth methods") + } + if !errors.Is(err, ErrNoAuthMethod) { + t.Errorf("expected ErrNoAuthMethod, got %v", err) + } +} + +func TestConfigService_UpdateSection_MagicLinkRequiresSMTP(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // Clear SMTP config + emptySMTP, _ := json.Marshal(models.SMTPConfig{}) + repo.configs[models.ConfigCategorySMTP] = &models.TenantConfig{ + Category: models.ConfigCategorySMTP, + Config: emptySMTP, + UpdatedAt: time.Now(), + } + _ = svc.reload(ctx) + + // Try to enable MagicLink without SMTP + input := json.RawMessage(`{"enabled": true}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryMagicLink, input, "admin@test.com") + if err == nil { + t.Error("expected error when enabling MagicLink without SMTP") + } + if !errors.Is(err, ErrMagicLinkNeedsSMTP) { + t.Errorf("expected ErrMagicLinkNeedsSMTP, got %v", err) + } +} + +func TestConfigService_UpdateSection_OIDCCustomRequiresURLs(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // Enable custom OIDC without URLs + input := json.RawMessage(`{"enabled": true, "provider": "custom", "client_id": "test"}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryOIDC, input, "admin@test.com") + if err == nil { + t.Error("expected error when enabling custom OIDC without URLs") + } +} + +func TestConfigService_ResetFromENV(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // Modify config + input := json.RawMessage(`{"organisation": "Modified Org", "only_admin_can_create": true}`) + _ = svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com") + + cfg := svc.GetConfig() + if cfg.General.Organisation != "Modified Org" { + t.Fatalf("expected 'Modified Org', got '%s'", cfg.General.Organisation) + } + + // Reset from ENV + err := svc.ResetFromENV(ctx, "admin@test.com") + if err != nil { + t.Fatalf("ResetFromENV failed: %v", err) + } + + // Verify it was reset to ENV values + cfg = svc.GetConfig() + if cfg.General.Organisation != "Test Org" { + t.Errorf("expected organisation 'Test Org' after reset, got '%s'", cfg.General.Organisation) + } + + // Verify repo was cleared and reseeded + if len(repo.configs) == 0 { + t.Error("expected configs to be present after reset") + } +} + +func TestConfigService_Subscribe(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // Subscribe + ch := svc.Subscribe() + if ch == nil { + t.Fatal("expected non-nil channel") + } + + // Update config - should trigger notification + go func() { + input := json.RawMessage(`{"organisation": "Notified Org", "only_admin_can_create": false}`) + _ = svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com") + }() + + // Wait for notification + select { + case cfg := <-ch: + if cfg.General.Organisation != "Notified Org" { + t.Errorf("expected 'Notified Org', got '%s'", cfg.General.Organisation) + } + case <-time.After(2 * time.Second): + t.Error("timeout waiting for config notification") + } +} + +func TestConfigService_CloseAllSubscribers(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + ch := svc.Subscribe() + svc.CloseAllSubscribers() + + // Channel should be closed + _, ok := <-ch + if ok { + t.Error("expected channel to be closed") + } +} + +func TestConfigService_EncryptDecryptSecrets(t *testing.T) { + svc, _ := createTestConfigService() + + plaintext := []byte(`{"password":"secret123"}`) + + encrypted, err := svc.encryptSecrets(plaintext) + if err != nil { + t.Fatalf("encryptSecrets failed: %v", err) + } + + if len(encrypted) == 0 { + t.Error("expected non-empty encrypted data") + } + + decrypted, err := svc.decryptSecrets(encrypted) + if err != nil { + t.Fatalf("decryptSecrets failed: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("expected '%s', got '%s'", plaintext, decrypted) + } +} + +func TestConfigService_ValidateSection_Storage(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // S3 without bucket should fail + input := json.RawMessage(`{"type": "s3", "max_size_mb": 50, "s3_endpoint": "s3.amazonaws.com"}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryStorage, input, "admin@test.com") + if err == nil { + t.Error("expected error when S3 type without bucket") + } +} + +func TestConfigService_UpdateSection_PreserveMaskedSecrets(t *testing.T) { + svc, _ := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + + // First set a secret + input := json.RawMessage(`{"enabled": true, "provider": "google", "client_id": "id123", "client_secret": "secret123"}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryOIDC, input, "admin@test.com") + if err != nil { + t.Fatalf("UpdateSection failed: %v", err) + } + + cfg := svc.GetConfig() + if cfg.OIDC.ClientSecret != "secret123" { + t.Fatalf("expected secret to be stored") + } + + // Update with masked secret - should preserve original + maskedInput := json.RawMessage(`{"enabled": true, "provider": "google", "client_id": "id123", "client_secret": "********"}`) + err = svc.UpdateSection(ctx, models.ConfigCategoryOIDC, maskedInput, "admin@test.com") + if err != nil { + t.Fatalf("UpdateSection with masked secret failed: %v", err) + } + + cfg = svc.GetConfig() + if cfg.OIDC.ClientSecret != "secret123" { + t.Errorf("expected secret to be preserved, got '%s'", cfg.OIDC.ClientSecret) + } +} + +func TestConfigService_DetectOAuthProvider(t *testing.T) { + tests := []struct { + name string + authURL string + expected string + }{ + {"Google", "https://accounts.google.com/o/oauth2/auth", "google"}, + {"GitHub", "https://github.com/login/oauth/authorize", "github"}, + {"GitLab", "https://gitlab.com/oauth/authorize", "gitlab"}, + {"Custom", "https://auth.custom.com/authorize", "custom"}, + {"Empty", "", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := newFakeConfigRepository() + envConfig := &config.Config{ + OAuth: config.OAuthConfig{ + AuthURL: tc.authURL, + }, + } + svc := NewConfigService(repo, envConfig, make([]byte, 32)) + result := svc.detectOAuthProvider() + if result != tc.expected { + t.Errorf("expected '%s', got '%s'", tc.expected, result) + } + }) + } +} + +func TestConfigService_Initialize_RepoError(t *testing.T) { + svc, repo := createTestConfigService() + repo.shouldFailSeeded = true + ctx := context.Background() + + err := svc.Initialize(ctx) + if err == nil { + t.Error("expected error when repo fails") + } +} + +func TestConfigService_UpdateSection_RepoError(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + repo.shouldFailUpsert = true + + input := json.RawMessage(`{"organisation": "Test", "only_admin_can_create": false}`) + err := svc.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com") + if err == nil { + t.Error("expected error when repo fails") + } +} + +func TestConfigService_ResetFromENV_DeleteError(t *testing.T) { + svc, repo := createTestConfigService() + ctx := context.Background() + + _ = svc.Initialize(ctx) + repo.shouldFailDelete = true + + err := svc.ResetFromENV(ctx, "admin@test.com") + if err == nil { + t.Error("expected error when delete fails") + } +} + +func TestConfigCategory_IsValid(t *testing.T) { + tests := []struct { + category models.ConfigCategory + valid bool + }{ + {models.ConfigCategoryGeneral, true}, + {models.ConfigCategoryOIDC, true}, + {models.ConfigCategoryMagicLink, true}, + {models.ConfigCategorySMTP, true}, + {models.ConfigCategoryStorage, true}, + {"invalid", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(string(tc.category), func(t *testing.T) { + result := tc.category.IsValid() + if result != tc.valid { + t.Errorf("expected %v, got %v", tc.valid, result) + } + }) + } +} + +func TestMutableConfig_HasAtLeastOneAuthMethod(t *testing.T) { + tests := []struct { + name string + oidcEnabled bool + magicEnabled bool + expected bool + }{ + {"Both enabled", true, true, true}, + {"Only OIDC", true, false, true}, + {"Only MagicLink", false, true, true}, + {"Neither", false, false, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &models.MutableConfig{ + OIDC: models.OIDCConfig{Enabled: tc.oidcEnabled}, + MagicLink: models.MagicLinkConfig{Enabled: tc.magicEnabled}, + } + result := cfg.HasAtLeastOneAuthMethod() + if result != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestMutableConfig_MagicLinkRequiresSMTP(t *testing.T) { + tests := []struct { + name string + magicEnabled bool + smtpHost string + expected bool + }{ + {"MagicLink disabled", false, "", true}, + {"MagicLink enabled with SMTP", true, "smtp.test.com", true}, + {"MagicLink enabled without SMTP", true, "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &models.MutableConfig{ + MagicLink: models.MagicLinkConfig{Enabled: tc.magicEnabled}, + SMTP: models.SMTPConfig{Host: tc.smtpHost}, + } + result := cfg.MagicLinkRequiresSMTP() + if result != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, result) + } + }) + } +} + +func TestMutableConfig_MaskSecrets(t *testing.T) { + cfg := &models.MutableConfig{ + OIDC: models.OIDCConfig{ClientSecret: "secret123"}, + SMTP: models.SMTPConfig{Password: "smtppass"}, + Storage: models.StorageConfig{S3SecretKey: "s3key"}, + } + + masked := cfg.MaskSecrets() + + if masked.OIDC.ClientSecret != models.SecretMask { + t.Errorf("expected OIDC secret to be masked") + } + if masked.SMTP.Password != models.SecretMask { + t.Errorf("expected SMTP password to be masked") + } + if masked.Storage.S3SecretKey != models.SecretMask { + t.Errorf("expected S3 secret to be masked") + } + + // Original should be unchanged + if cfg.OIDC.ClientSecret != "secret123" { + t.Errorf("original OIDC secret should be unchanged") + } +} + +func TestIsSecretMasked(t *testing.T) { + tests := []struct { + value string + expected bool + }{ + {models.SecretMask, true}, + {"********", true}, + {"secret123", false}, + {"", false}, + } + + for _, tc := range tests { + t.Run(tc.value, func(t *testing.T) { + result := models.IsSecretMasked(tc.value) + if result != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, result) + } + }) + } +} diff --git a/backend/internal/domain/models/tenant_config.go b/backend/internal/domain/models/tenant_config.go new file mode 100644 index 0000000..ef63683 --- /dev/null +++ b/backend/internal/domain/models/tenant_config.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +// ConfigCategory represents the category of configuration +type ConfigCategory string + +const ( + ConfigCategoryGeneral ConfigCategory = "general" + ConfigCategoryOIDC ConfigCategory = "oidc" + ConfigCategoryMagicLink ConfigCategory = "magiclink" + ConfigCategorySMTP ConfigCategory = "smtp" + ConfigCategoryStorage ConfigCategory = "storage" +) + +// AllConfigCategories returns all valid configuration categories +func AllConfigCategories() []ConfigCategory { + return []ConfigCategory{ + ConfigCategoryGeneral, + ConfigCategoryOIDC, + ConfigCategoryMagicLink, + ConfigCategorySMTP, + ConfigCategoryStorage, + } +} + +// IsValid checks if the category is valid +func (c ConfigCategory) IsValid() bool { + switch c { + case ConfigCategoryGeneral, ConfigCategoryOIDC, ConfigCategoryMagicLink, + ConfigCategorySMTP, ConfigCategoryStorage: + return true + } + return false +} + +// TenantConfig represents a configuration section stored in the database +type TenantConfig struct { + ID int64 `json:"id" db:"id"` + TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` + Category ConfigCategory `json:"category" db:"category"` + Config json.RawMessage `json:"config" db:"config"` + SecretsEncrypted []byte `json:"-" db:"secrets_encrypted"` + Version int `json:"version" db:"version"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + UpdatedBy *string `json:"updated_by,omitempty" db:"updated_by"` +} + +// GeneralConfig holds general application settings +type GeneralConfig struct { + Organisation string `json:"organisation"` + OnlyAdminCanCreate bool `json:"only_admin_can_create"` +} + +// OIDCConfig holds OIDC/OAuth2 authentication settings +type OIDCConfig struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` // google, github, gitlab, custom + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + UserInfoURL string `json:"userinfo_url,omitempty"` + LogoutURL string `json:"logout_url,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AllowedDomain string `json:"allowed_domain,omitempty"` + AutoLogin bool `json:"auto_login"` +} + +// OIDCSecrets holds the secret fields for OIDC config +type OIDCSecrets struct { + ClientSecret string `json:"client_secret,omitempty"` +} + +// MagicLinkConfig holds MagicLink authentication settings +type MagicLinkConfig struct { + Enabled bool `json:"enabled"` +} + +// SMTPConfig holds SMTP email settings +type SMTPConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password,omitempty"` + TLS bool `json:"tls"` + StartTLS bool `json:"starttls"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + Timeout string `json:"timeout"` + From string `json:"from"` + FromName string `json:"from_name"` + SubjectPrefix string `json:"subject_prefix,omitempty"` +} + +// SMTPSecrets holds the secret fields for SMTP config +type SMTPSecrets struct { + Password string `json:"password,omitempty"` +} + +// IsConfigured returns true if SMTP is properly configured +func (c *SMTPConfig) IsConfigured() bool { + return c.Host != "" && c.From != "" +} + +// StorageConfig holds document storage settings +type StorageConfig struct { + Type string `json:"type"` // "", "local", "s3" + MaxSizeMB int64 `json:"max_size_mb"` + LocalPath string `json:"local_path,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3AccessKey string `json:"s3_access_key,omitempty"` + S3SecretKey string `json:"s3_secret_key,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3UseSSL bool `json:"s3_use_ssl"` +} + +// StorageSecrets holds the secret fields for Storage config +type StorageSecrets struct { + S3SecretKey string `json:"s3_secret_key,omitempty"` +} + +// IsEnabled returns true if storage is enabled +func (c *StorageConfig) IsEnabled() bool { + return c.Type == "local" || c.Type == "s3" +} + +// MutableConfig combines all mutable configuration sections +type MutableConfig struct { + General GeneralConfig `json:"general"` + OIDC OIDCConfig `json:"oidc"` + MagicLink MagicLinkConfig `json:"magiclink"` + SMTP SMTPConfig `json:"smtp"` + Storage StorageConfig `json:"storage"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ConfigSecrets holds all encrypted secrets +type ConfigSecrets struct { + OIDCClientSecret string `json:"oidc_client_secret,omitempty"` + SMTPPassword string `json:"smtp_password,omitempty"` + S3SecretKey string `json:"s3_secret_key,omitempty"` +} + +// HasAtLeastOneAuthMethod validates that at least one auth method is enabled +func (c *MutableConfig) HasAtLeastOneAuthMethod() bool { + return c.OIDC.Enabled || c.MagicLink.Enabled +} + +// MagicLinkRequiresSMTP validates that MagicLink has SMTP configured +func (c *MutableConfig) MagicLinkRequiresSMTP() bool { + if !c.MagicLink.Enabled { + return true + } + return c.SMTP.IsConfigured() +} + +// SecretMask is the value returned for masked secrets +const SecretMask = "********" + +// MaskSecrets returns a copy of MutableConfig with secrets masked +func (c *MutableConfig) MaskSecrets() *MutableConfig { + masked := *c + + if masked.OIDC.ClientSecret != "" { + masked.OIDC.ClientSecret = SecretMask + } + if masked.SMTP.Password != "" { + masked.SMTP.Password = SecretMask + } + if masked.Storage.S3SecretKey != "" { + masked.Storage.S3SecretKey = SecretMask + } + + return &masked +} + +// IsSecretMasked checks if a value is the secret mask +func IsSecretMasked(value string) bool { + return value == SecretMask +} diff --git a/backend/internal/infrastructure/database/config_repository.go b/backend/internal/infrastructure/database/config_repository.go new file mode 100644 index 0000000..3d73883 --- /dev/null +++ b/backend/internal/infrastructure/database/config_repository.go @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package database + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/dbctx" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/tenant" + "github.com/google/uuid" +) + +type ConfigRepository struct { + db *sql.DB + tenants tenant.Provider +} + +func NewConfigRepository(db *sql.DB, tenants tenant.Provider) *ConfigRepository { + return &ConfigRepository{db: db, tenants: tenants} +} + +// GetByCategory retrieves a configuration section by category +func (r *ConfigRepository) GetByCategory(ctx context.Context, category models.ConfigCategory) (*models.TenantConfig, error) { + query := ` + SELECT id, tenant_id, category, config, secrets_encrypted, version, created_at, updated_at, updated_by + FROM tenant_config + WHERE category = $1 + ` + cfg := &models.TenantConfig{} + var updatedBy sql.NullString + var secretsEncrypted []byte + + err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query, string(category)).Scan( + &cfg.ID, &cfg.TenantID, &cfg.Category, &cfg.Config, &secretsEncrypted, + &cfg.Version, &cfg.CreatedAt, &cfg.UpdatedAt, &updatedBy, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get config by category: %w", err) + } + + cfg.SecretsEncrypted = secretsEncrypted + if updatedBy.Valid { + cfg.UpdatedBy = &updatedBy.String + } + return cfg, nil +} + +// GetAll retrieves all configuration sections for the current tenant +func (r *ConfigRepository) GetAll(ctx context.Context) ([]*models.TenantConfig, error) { + query := ` + SELECT id, tenant_id, category, config, secrets_encrypted, version, created_at, updated_at, updated_by + FROM tenant_config + ORDER BY category + ` + rows, err := dbctx.GetQuerier(ctx, r.db).QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to list configs: %w", err) + } + defer rows.Close() + + var configs []*models.TenantConfig + for rows.Next() { + cfg := &models.TenantConfig{} + var updatedBy sql.NullString + var secretsEncrypted []byte + + if err := rows.Scan( + &cfg.ID, &cfg.TenantID, &cfg.Category, &cfg.Config, &secretsEncrypted, + &cfg.Version, &cfg.CreatedAt, &cfg.UpdatedAt, &updatedBy, + ); err != nil { + return nil, fmt.Errorf("failed to scan config row: %w", err) + } + + cfg.SecretsEncrypted = secretsEncrypted + if updatedBy.Valid { + cfg.UpdatedBy = &updatedBy.String + } + configs = append(configs, cfg) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating config rows: %w", err) + } + + return configs, nil +} + +// Upsert creates or updates a configuration section +func (r *ConfigRepository) Upsert(ctx context.Context, category models.ConfigCategory, config json.RawMessage, secrets []byte, updatedBy string) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + query := ` + INSERT INTO tenant_config (tenant_id, category, config, secrets_encrypted, updated_by) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (tenant_id, category) + DO UPDATE SET + config = EXCLUDED.config, + secrets_encrypted = COALESCE(EXCLUDED.secrets_encrypted, tenant_config.secrets_encrypted), + updated_by = EXCLUDED.updated_by + ` + + var secretsArg interface{} + if len(secrets) > 0 { + secretsArg = secrets + } + + _, err = dbctx.GetQuerier(ctx, r.db).ExecContext(ctx, query, + tenantID, string(category), config, secretsArg, updatedBy, + ) + if err != nil { + return fmt.Errorf("failed to upsert config: %w", err) + } + + return nil +} + +// IsSeeded checks if configuration has been seeded from environment variables +func (r *ConfigRepository) IsSeeded(ctx context.Context) (bool, error) { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return false, fmt.Errorf("failed to get tenant: %w", err) + } + + query := `SELECT config_seeded_at FROM instance_metadata WHERE id = $1` + var seededAt sql.NullTime + err = r.db.QueryRowContext(ctx, query, tenantID).Scan(&seededAt) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("failed to check seeded status: %w", err) + } + + return seededAt.Valid, nil +} + +// MarkSeeded marks configuration as seeded from environment variables +func (r *ConfigRepository) MarkSeeded(ctx context.Context) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + query := `UPDATE instance_metadata SET config_seeded_at = NOW() WHERE id = $1` + _, err = r.db.ExecContext(ctx, query, tenantID) + if err != nil { + return fmt.Errorf("failed to mark config as seeded: %w", err) + } + + return nil +} + +// ClearSeeded clears the seeded flag (for reset functionality) +func (r *ConfigRepository) ClearSeeded(ctx context.Context) error { + tenantID, err := r.tenants.CurrentTenant(ctx) + if err != nil { + return fmt.Errorf("failed to get tenant: %w", err) + } + + query := `UPDATE instance_metadata SET config_seeded_at = NULL WHERE id = $1` + _, err = r.db.ExecContext(ctx, query, tenantID) + if err != nil { + return fmt.Errorf("failed to clear seeded status: %w", err) + } + + return nil +} + +// DeleteAll removes all configuration for the current tenant (for reset) +func (r *ConfigRepository) DeleteAll(ctx context.Context) error { + query := `DELETE FROM tenant_config` + _, err := dbctx.GetQuerier(ctx, r.db).ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to delete all configs: %w", err) + } + return nil +} + +// GetTenantID returns the current tenant ID +func (r *ConfigRepository) GetTenantID(ctx context.Context) (uuid.UUID, error) { + return r.tenants.CurrentTenant(ctx) +} + +// GetLatestUpdatedAt returns the most recent updated_at across all config sections +func (r *ConfigRepository) GetLatestUpdatedAt(ctx context.Context) (time.Time, error) { + query := `SELECT COALESCE(MAX(updated_at), NOW()) FROM tenant_config` + var updatedAt time.Time + err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query).Scan(&updatedAt) + if err != nil { + return time.Time{}, fmt.Errorf("failed to get latest updated_at: %w", err) + } + return updatedAt, nil +} diff --git a/backend/internal/presentation/api/admin/settings_handler.go b/backend/internal/presentation/api/admin/settings_handler.go new file mode 100644 index 0000000..d5f0fad --- /dev/null +++ b/backend/internal/presentation/api/admin/settings_handler.go @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared" + "github.com/go-chi/chi/v5" +) + +// configService defines the interface for configuration management +type configService interface { + GetConfig() *models.MutableConfig + UpdateSection(ctx context.Context, category models.ConfigCategory, input json.RawMessage, updatedBy string) error + TestSMTP(ctx context.Context, cfg models.SMTPConfig) error + TestS3(ctx context.Context, cfg models.StorageConfig) error + TestOIDC(ctx context.Context, cfg models.OIDCConfig) error + ResetFromENV(ctx context.Context, updatedBy string) error +} + +// SettingsHandler handles admin settings endpoints +type SettingsHandler struct { + configService configService +} + +// NewSettingsHandler creates a new settings handler +func NewSettingsHandler(configService configService) *SettingsHandler { + return &SettingsHandler{configService: configService} +} + +// SettingsResponse represents the full settings response +type SettingsResponse struct { + General models.GeneralConfig `json:"general"` + OIDC OIDCResponse `json:"oidc"` + MagicLink models.MagicLinkConfig `json:"magiclink"` + SMTP SMTPResponse `json:"smtp"` + Storage StorageResponse `json:"storage"` + UpdatedAt string `json:"updated_at"` +} + +// OIDCResponse is OIDCConfig with masked secrets +type OIDCResponse struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url,omitempty"` + TokenURL string `json:"token_url,omitempty"` + UserInfoURL string `json:"userinfo_url,omitempty"` + LogoutURL string `json:"logout_url,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AllowedDomain string `json:"allowed_domain,omitempty"` + AutoLogin bool `json:"auto_login"` +} + +// SMTPResponse is SMTPConfig with masked secrets +type SMTPResponse struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + TLS bool `json:"tls"` + StartTLS bool `json:"starttls"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + Timeout string `json:"timeout"` + From string `json:"from"` + FromName string `json:"from_name"` + SubjectPrefix string `json:"subject_prefix,omitempty"` +} + +// StorageResponse is StorageConfig with masked secrets +type StorageResponse struct { + Type string `json:"type"` + MaxSizeMB int64 `json:"max_size_mb"` + LocalPath string `json:"local_path,omitempty"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3AccessKey string `json:"s3_access_key,omitempty"` + S3SecretKey string `json:"s3_secret_key,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3UseSSL bool `json:"s3_use_ssl"` +} + +// HandleGetSettings handles GET /api/v1/admin/settings +func (h *SettingsHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) { + cfg := h.configService.GetConfig() + + // Build response with masked secrets + response := SettingsResponse{ + General: cfg.General, + OIDC: OIDCResponse{ + Enabled: cfg.OIDC.Enabled, + Provider: cfg.OIDC.Provider, + ClientID: cfg.OIDC.ClientID, + ClientSecret: maskSecret(cfg.OIDC.ClientSecret), + AuthURL: cfg.OIDC.AuthURL, + TokenURL: cfg.OIDC.TokenURL, + UserInfoURL: cfg.OIDC.UserInfoURL, + LogoutURL: cfg.OIDC.LogoutURL, + Scopes: cfg.OIDC.Scopes, + AllowedDomain: cfg.OIDC.AllowedDomain, + AutoLogin: cfg.OIDC.AutoLogin, + }, + MagicLink: cfg.MagicLink, + SMTP: SMTPResponse{ + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.Username, + Password: maskSecret(cfg.SMTP.Password), + TLS: cfg.SMTP.TLS, + StartTLS: cfg.SMTP.StartTLS, + InsecureSkipVerify: cfg.SMTP.InsecureSkipVerify, + Timeout: cfg.SMTP.Timeout, + From: cfg.SMTP.From, + FromName: cfg.SMTP.FromName, + SubjectPrefix: cfg.SMTP.SubjectPrefix, + }, + Storage: StorageResponse{ + Type: cfg.Storage.Type, + MaxSizeMB: cfg.Storage.MaxSizeMB, + LocalPath: cfg.Storage.LocalPath, + S3Endpoint: cfg.Storage.S3Endpoint, + S3Bucket: cfg.Storage.S3Bucket, + S3AccessKey: cfg.Storage.S3AccessKey, + S3SecretKey: maskSecret(cfg.Storage.S3SecretKey), + S3Region: cfg.Storage.S3Region, + S3UseSSL: cfg.Storage.S3UseSSL, + }, + UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + + shared.WriteJSON(w, http.StatusOK, response) +} + +// HandleUpdateSection handles PUT /api/v1/admin/settings/{section} +func (h *SettingsHandler) HandleUpdateSection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + section := chi.URLParam(r, "section") + + category, err := parseCategory(section) + if err != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid section: "+section, nil) + return + } + + user, ok := shared.GetUserFromContext(ctx) + if !ok || user == nil { + shared.WriteUnauthorized(w, "Authentication required") + return + } + + var input json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid JSON: "+err.Error(), nil) + return + } + + if err := h.configService.UpdateSection(ctx, category, input, user.Email); err != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, err.Error(), nil) + return + } + + shared.WriteJSON(w, http.StatusOK, map[string]string{"message": "Configuration updated"}) +} + +// HandleTestConnection handles POST /api/v1/admin/settings/test/{type} +func (h *SettingsHandler) HandleTestConnection(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + testType := chi.URLParam(r, "type") + + var err error + switch testType { + case "smtp": + var cfg models.SMTPConfig + if decErr := json.NewDecoder(r.Body).Decode(&cfg); decErr != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid SMTP config: "+decErr.Error(), nil) + return + } + err = h.configService.TestSMTP(ctx, cfg) + + case "s3": + var cfg models.StorageConfig + if decErr := json.NewDecoder(r.Body).Decode(&cfg); decErr != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid storage config: "+decErr.Error(), nil) + return + } + err = h.configService.TestS3(ctx, cfg) + + case "oidc": + var cfg models.OIDCConfig + if decErr := json.NewDecoder(r.Body).Decode(&cfg); decErr != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Invalid OIDC config: "+decErr.Error(), nil) + return + } + err = h.configService.TestOIDC(ctx, cfg) + + default: + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, "Unknown test type: "+testType, nil) + return + } + + if err != nil { + shared.WriteError(w, http.StatusBadRequest, shared.ErrCodeBadRequest, err.Error(), nil) + return + } + + shared.WriteJSON(w, http.StatusOK, map[string]string{"message": "Connection successful"}) +} + +// HandleResetFromENV handles POST /api/v1/admin/settings/reset +func (h *SettingsHandler) HandleResetFromENV(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + user, ok := shared.GetUserFromContext(ctx) + if !ok || user == nil { + shared.WriteUnauthorized(w, "Authentication required") + return + } + + if err := h.configService.ResetFromENV(ctx, user.Email); err != nil { + shared.WriteError(w, http.StatusInternalServerError, shared.ErrCodeInternal, "Reset failed: "+err.Error(), nil) + return + } + + shared.WriteJSON(w, http.StatusOK, map[string]string{"message": "Configuration reset from environment"}) +} + +// parseCategory converts a string to a ConfigCategory +func parseCategory(s string) (models.ConfigCategory, error) { + category := models.ConfigCategory(s) + if !category.IsValid() { + return "", errors.New("invalid category") + } + return category, nil +} + +// maskSecret returns the mask if the secret is set, empty string otherwise +func maskSecret(secret string) string { + if secret == "" { + return "" + } + return models.SecretMask +} diff --git a/backend/internal/presentation/api/admin/settings_handler_test.go b/backend/internal/presentation/api/admin/settings_handler_test.go new file mode 100644 index 0000000..52482ba --- /dev/null +++ b/backend/internal/presentation/api/admin/settings_handler_test.go @@ -0,0 +1,378 @@ +//go:build integration +// +build integration + +// SPDX-License-Identifier: AGPL-3.0-or-later +package admin_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/btouchard/ackify-ce/backend/internal/application/services" + "github.com/btouchard/ackify-ce/backend/internal/domain/models" + "github.com/btouchard/ackify-ce/backend/internal/infrastructure/database" + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/admin" + "github.com/btouchard/ackify-ce/backend/internal/presentation/api/shared" + "github.com/btouchard/ackify-ce/backend/pkg/config" + "github.com/go-chi/chi/v5" +) + +func setupConfigTestDB(t *testing.T) *database.TestDB { + testDB := database.SetupTestDB(t) + return testDB +} + +func createTestConfigService(t *testing.T, testDB *database.TestDB) *services.ConfigService { + configRepo := database.NewConfigRepository(testDB.DB, testDB.TenantProvider) + + envConfig := &config.Config{ + App: config.AppConfig{ + Organisation: "Test Org", + OnlyAdminCanCreate: false, + }, + Auth: config.AuthConfig{ + OAuthEnabled: true, + MagicLinkEnabled: false, + }, + OAuth: config.OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + UserInfoURL: "https://openidconnect.googleapis.com/v1/userinfo", + Scopes: []string{"openid", "email", "profile"}, + }, + Mail: config.MailConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "test@example.com", + Password: "smtp-password", + TLS: false, + StartTLS: true, + From: "noreply@example.com", + FromName: "Test App", + Timeout: "10s", + }, + Storage: config.StorageConfig{ + Type: "local", + MaxSizeMB: 50, + LocalPath: "/data/documents", + }, + } + + encryptionKey := make([]byte, 32) + for i := range encryptionKey { + encryptionKey[i] = byte(i) + } + + svc := services.NewConfigService(configRepo, envConfig, encryptionKey) + + ctx := context.Background() + if err := svc.Initialize(ctx); err != nil { + t.Fatalf("Failed to initialize config service: %v", err) + } + + return svc +} + +func createTestUser() *models.User { + return &models.User{ + Sub: "test-admin-sub", + Email: "admin@example.com", + Name: "Test Admin", + IsAdmin: true, + } +} + +func TestSettingsHandler_GetSettings(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) + w := httptest.NewRecorder() + + handler.HandleGetSettings(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Data struct { + General models.GeneralConfig `json:"general"` + OIDC admin.OIDCResponse `json:"oidc"` + MagicLink models.MagicLinkConfig `json:"magiclink"` + SMTP admin.SMTPResponse `json:"smtp"` + Storage admin.StorageResponse `json:"storage"` + UpdatedAt string `json:"updated_at"` + } `json:"data"` + } + + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Verify general config + if response.Data.General.Organisation != "Test Org" { + t.Errorf("Expected organisation 'Test Org', got '%s'", response.Data.General.Organisation) + } + + // Verify OIDC config - secret should be masked + if response.Data.OIDC.ClientSecret != models.SecretMask { + t.Errorf("Expected OIDC client_secret to be masked, got '%s'", response.Data.OIDC.ClientSecret) + } + + // Verify SMTP config - password should be masked + if response.Data.SMTP.Password != models.SecretMask { + t.Errorf("Expected SMTP password to be masked, got '%s'", response.Data.SMTP.Password) + } +} + +func TestSettingsHandler_UpdateSection_General(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + // Create request with user context + body := `{"organisation": "Updated Org", "only_admin_can_create": true}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/general", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + // Add chi URL params + rctx := chi.NewRouteContext() + rctx.URLParams.Add("section", "general") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Add user context + user := createTestUser() + req = req.WithContext(shared.SetUserInContext(req.Context(), user)) + + w := httptest.NewRecorder() + handler.HandleUpdateSection(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify the update + cfg := configService.GetConfig() + if cfg.General.Organisation != "Updated Org" { + t.Errorf("Expected organisation 'Updated Org', got '%s'", cfg.General.Organisation) + } + if !cfg.General.OnlyAdminCanCreate { + t.Error("Expected OnlyAdminCanCreate to be true") + } +} + +func TestSettingsHandler_UpdateSection_InvalidSection(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + body := `{"foo": "bar"}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/invalid", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("section", "invalid") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + user := createTestUser() + req = req.WithContext(shared.SetUserInContext(req.Context(), user)) + + w := httptest.NewRecorder() + handler.HandleUpdateSection(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Expected status 400, got %d", w.Code) + } +} + +func TestSettingsHandler_UpdateSection_ValidationError(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + // Try to disable all auth methods + body := `{"enabled": false, "provider": ""}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/oidc", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("section", "oidc") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + user := createTestUser() + req = req.WithContext(shared.SetUserInContext(req.Context(), user)) + + w := httptest.NewRecorder() + handler.HandleUpdateSection(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSettingsHandler_UpdateSection_NoAuth(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + body := `{"organisation": "Test"}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/general", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("section", "general") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // No user context + + w := httptest.NewRecorder() + handler.HandleUpdateSection(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("Expected status 401, got %d", w.Code) + } +} + +func TestSettingsHandler_UpdateSection_InvalidJSON(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + body := `{invalid json}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/general", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("section", "general") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + user := createTestUser() + req = req.WithContext(shared.SetUserInContext(req.Context(), user)) + + w := httptest.NewRecorder() + handler.HandleUpdateSection(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Expected status 400, got %d", w.Code) + } +} + +func TestSettingsHandler_ResetFromENV(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + // First modify the config + ctx := context.Background() + input := json.RawMessage(`{"organisation": "Modified Org", "only_admin_can_create": true}`) + _ = configService.UpdateSection(ctx, models.ConfigCategoryGeneral, input, "admin@test.com") + + // Verify modification + cfg := configService.GetConfig() + if cfg.General.Organisation != "Modified Org" { + t.Fatalf("Setup failed: expected 'Modified Org'") + } + + // Call reset + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/reset", nil) + user := createTestUser() + req = req.WithContext(shared.SetUserInContext(req.Context(), user)) + + w := httptest.NewRecorder() + handler.HandleResetFromENV(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify reset to ENV values + cfg = configService.GetConfig() + if cfg.General.Organisation != "Test Org" { + t.Errorf("Expected organisation 'Test Org' after reset, got '%s'", cfg.General.Organisation) + } +} + +func TestSettingsHandler_ResetFromENV_NoAuth(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/reset", nil) + // No user context + + w := httptest.NewRecorder() + handler.HandleResetFromENV(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("Expected status 401, got %d", w.Code) + } +} + +func TestSettingsHandler_TestConnection_InvalidType(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/settings/test/invalid", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("type", "invalid") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + w := httptest.NewRecorder() + handler.HandleTestConnection(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("Expected status 400, got %d", w.Code) + } +} + +func TestSettingsHandler_UpdateSection_PreserveMaskedSecrets(t *testing.T) { + testDB := setupConfigTestDB(t) + configService := createTestConfigService(t, testDB) + handler := admin.NewSettingsHandler(configService) + + // Verify initial secret exists + cfg := configService.GetConfig() + if cfg.OIDC.ClientSecret != "test-client-secret" { + t.Fatalf("Initial secret not set") + } + + // Update with masked secret + body := `{"enabled": true, "provider": "google", "client_id": "new-id", "client_secret": "********"}` + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings/oidc", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("section", "oidc") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + user := createTestUser() + req = req.WithContext(shared.SetUserInContext(req.Context(), user)) + + w := httptest.NewRecorder() + handler.HandleUpdateSection(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify original secret is preserved + cfg = configService.GetConfig() + if cfg.OIDC.ClientSecret != "test-client-secret" { + t.Errorf("Expected secret to be preserved, got '%s'", cfg.OIDC.ClientSecret) + } + if cfg.OIDC.ClientID != "new-id" { + t.Errorf("Expected client_id to be updated, got '%s'", cfg.OIDC.ClientID) + } +} diff --git a/backend/internal/presentation/api/router.go b/backend/internal/presentation/api/router.go index 080b835..8c19c4b 100644 --- a/backend/internal/presentation/api/router.go +++ b/backend/internal/presentation/api/router.go @@ -99,6 +99,16 @@ type webhookService interface { ListDeliveries(ctx context.Context, webhookID int64, limit, offset int) ([]*models.WebhookDelivery, error) } +// configService defines configuration management operations +type configService interface { + GetConfig() *models.MutableConfig + UpdateSection(ctx context.Context, category models.ConfigCategory, input json.RawMessage, updatedBy string) error + TestSMTP(ctx context.Context, cfg models.SMTPConfig) error + TestS3(ctx context.Context, cfg models.StorageConfig) error + TestOIDC(ctx context.Context, cfg models.OIDCConfig) error + ResetFromENV(ctx context.Context, updatedBy string) error +} + // RouterConfig holds configuration for the API router type RouterConfig struct { // Database for RLS middleware @@ -118,6 +128,7 @@ type RouterConfig struct { ReminderService reminderService WebhookService webhookService WebhookPublisher webhookPublisher + ConfigService configService // Storage StorageProvider storage.Provider // Optional, for document file storage @@ -351,6 +362,17 @@ func NewRouter(cfg RouterConfig) *chi.Mux { r.Delete("/{id}", webhooksHandler.HandleDeleteWebhook) r.Get("/{id}/deliveries", webhooksHandler.HandleListDeliveries) }) + + // Settings management (configuration) + if cfg.ConfigService != nil { + settingsHandler := apiAdmin.NewSettingsHandler(cfg.ConfigService) + r.Route("/settings", func(r chi.Router) { + r.Get("/", settingsHandler.HandleGetSettings) + r.Put("/{section}", settingsHandler.HandleUpdateSection) + r.Post("/test/{type}", settingsHandler.HandleTestConnection) + r.Post("/reset", settingsHandler.HandleResetFromENV) + }) + } }) }) diff --git a/backend/migrations/0019_add_tenant_config.down.sql b/backend/migrations/0019_add_tenant_config.down.sql new file mode 100644 index 0000000..7b24342 --- /dev/null +++ b/backend/migrations/0019_add_tenant_config.down.sql @@ -0,0 +1,24 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- Rollback: Remove Tenant Configuration Storage + +-- Revoke permissions +REVOKE SELECT, INSERT, UPDATE, DELETE ON tenant_config FROM ackify_app; +REVOKE USAGE, SELECT ON SEQUENCE tenant_config_id_seq FROM ackify_app; +REVOKE UPDATE (config_seeded_at) ON instance_metadata FROM ackify_app; + +-- Drop RLS policy +DROP POLICY IF EXISTS tenant_isolation_tenant_config ON tenant_config; + +-- Drop triggers +DROP TRIGGER IF EXISTS tr_tenant_config_tenant_id_immutable ON tenant_config; +DROP TRIGGER IF EXISTS tr_tenant_config_update_timestamp ON tenant_config; + +-- Drop function +DROP FUNCTION IF EXISTS update_tenant_config_timestamp(); + +-- Remove column from instance_metadata +ALTER TABLE instance_metadata DROP COLUMN IF EXISTS config_seeded_at; + +-- Drop table +DROP TABLE IF EXISTS tenant_config; diff --git a/backend/migrations/0019_add_tenant_config.up.sql b/backend/migrations/0019_add_tenant_config.up.sql new file mode 100644 index 0000000..4c72e2b --- /dev/null +++ b/backend/migrations/0019_add_tenant_config.up.sql @@ -0,0 +1,80 @@ +-- SPDX-License-Identifier: AGPL-3.0-or-later + +-- ============================================================================ +-- Migration: Add Tenant Configuration Storage +-- ============================================================================ +-- This migration creates a table for storing tenant-specific configuration +-- with support for: +-- - Category-based JSONB storage for flexibility +-- - Encrypted secrets storage (separate from main config) +-- - Optimistic locking via version field +-- - Audit trail (updated_by, updated_at) +-- - Tenant isolation via RLS +-- ============================================================================ + +-- Step 1: Create tenant_config table +CREATE TABLE tenant_config ( + id BIGSERIAL PRIMARY KEY, + tenant_id UUID NOT NULL, + category TEXT NOT NULL CHECK (category IN ('general', 'oidc', 'magiclink', 'smtp', 'storage')), + config JSONB NOT NULL DEFAULT '{}', + secrets_encrypted BYTEA, + version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by TEXT, + UNIQUE(tenant_id, category) +); + +COMMENT ON TABLE tenant_config IS 'Stores tenant-specific configuration with JSONB per category'; +COMMENT ON COLUMN tenant_config.tenant_id IS 'Tenant identifier (references instance_metadata.id)'; +COMMENT ON COLUMN tenant_config.category IS 'Configuration category: general, oidc, magiclink, smtp, storage'; +COMMENT ON COLUMN tenant_config.config IS 'JSONB configuration data (secrets excluded)'; +COMMENT ON COLUMN tenant_config.secrets_encrypted IS 'AES-256-GCM encrypted secrets blob'; +COMMENT ON COLUMN tenant_config.version IS 'Optimistic locking version (incremented on each update)'; +COMMENT ON COLUMN tenant_config.updated_by IS 'Email of the user who last updated this config'; + +-- Step 2: Add indexes +CREATE INDEX idx_tenant_config_tenant_category ON tenant_config(tenant_id, category); + +-- Step 3: Add config_seeded_at column to instance_metadata +ALTER TABLE instance_metadata ADD COLUMN IF NOT EXISTS config_seeded_at TIMESTAMPTZ; + +COMMENT ON COLUMN instance_metadata.config_seeded_at IS 'Timestamp when configuration was first seeded from environment variables'; + +-- Step 4: Create trigger for automatic updated_at and version increment +CREATE OR REPLACE FUNCTION update_tenant_config_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + NEW.version = OLD.version + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION update_tenant_config_timestamp() IS 'Automatically updates updated_at and increments version on tenant_config updates'; + +CREATE TRIGGER tr_tenant_config_update_timestamp + BEFORE UPDATE ON tenant_config + FOR EACH ROW EXECUTE FUNCTION update_tenant_config_timestamp(); + +-- Step 5: Add tenant_id immutability trigger +CREATE TRIGGER tr_tenant_config_tenant_id_immutable + BEFORE UPDATE ON tenant_config + FOR EACH ROW EXECUTE FUNCTION prevent_tenant_id_modification(); + +-- Step 6: Enable Row Level Security +ALTER TABLE tenant_config ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant_config FORCE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_tenant_config ON tenant_config; +CREATE POLICY tenant_isolation_tenant_config ON tenant_config + USING (tenant_id = current_tenant_id()) + WITH CHECK (tenant_id = current_tenant_id()); + +-- Step 7: Grant permissions to ackify_app role +GRANT SELECT, INSERT, UPDATE, DELETE ON tenant_config TO ackify_app; +GRANT USAGE, SELECT ON SEQUENCE tenant_config_id_seq TO ackify_app; + +-- Step 8: Grant UPDATE on instance_metadata for config_seeded_at column +GRANT UPDATE (config_seeded_at) ON instance_metadata TO ackify_app; diff --git a/backend/pkg/web/server.go b/backend/pkg/web/server.go index 49f48f8..6755b92 100644 --- a/backend/pkg/web/server.go +++ b/backend/pkg/web/server.go @@ -84,6 +84,7 @@ type ServerBuilder struct { adminService *services.AdminService webhookService *services.WebhookService reminderService *services.ReminderAsyncService + configService *services.ConfigService // Flags oauthEnabled bool @@ -200,6 +201,12 @@ func (b *ServerBuilder) WithReminderService(service *services.ReminderAsyncServi return b } +// WithConfigService injects a configuration service. +func (b *ServerBuilder) WithConfigService(service *services.ConfigService) *ServerBuilder { + b.configService = service + return b +} + // Build constructs the server with all dependencies. func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { if err := b.validateProviders(); err != nil { @@ -229,6 +236,7 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) { } b.initializeCoreServices(repos) + b.initializeConfigService(ctx, repos) magicLinkWorker := b.initializeMagicLinkService(ctx, repos) b.initializeReminderService(repos) @@ -334,6 +342,7 @@ type repositories struct { webhookDelivery *database.WebhookDeliveryRepository magicLink services.MagicLinkRepository // Interface, not concrete type oauthSession *database.OAuthSessionRepository + config *database.ConfigRepository } // createRepositories creates all repository instances. @@ -348,6 +357,7 @@ func (b *ServerBuilder) createRepositories() *repositories { webhookDelivery: database.NewWebhookDeliveryRepository(b.db, b.tenantProvider), magicLink: database.NewMagicLinkRepository(b.db), oauthSession: database.NewOAuthSessionRepository(b.db, b.tenantProvider), + config: database.NewConfigRepository(b.db, b.tenantProvider), } } @@ -433,6 +443,25 @@ func (b *ServerBuilder) initializeCoreServices(repos *repositories) { } } +// initializeConfigService initializes the configuration service with hot-reload. +func (b *ServerBuilder) initializeConfigService(ctx context.Context, repos *repositories) { + if b.configService == nil { + encryptionKey := b.cfg.OAuth.CookieSecret + b.configService = services.NewConfigService(repos.config, b.cfg, encryptionKey) + + // Initialize will seed from ENV on first start or load from DB + // Must use tenant context for RLS to work correctly + err := tenant.WithTenantContextFromProvider(ctx, b.db, b.tenantProvider, func(txCtx context.Context) error { + return b.configService.Initialize(txCtx) + }) + if err != nil { + logger.Logger.Warn("Failed to initialize config service, using ENV config", "error", err) + return + } + logger.Logger.Info("Configuration service initialized") + } +} + // initializeMagicLinkService initializes MagicLink service and cleanup worker. func (b *ServerBuilder) initializeMagicLinkService(ctx context.Context, repos *repositories) *workers.MagicLinkCleanupWorker { if b.magicLinkService == nil { @@ -521,6 +550,7 @@ func (b *ServerBuilder) buildRouter(repos *repositories, whPublisher *services.W DocumentRateLimit: b.cfg.App.DocumentRateLimit, GeneralRateLimit: b.cfg.App.GeneralRateLimit, ImportMaxSigners: b.cfg.App.ImportMaxSigners, + ConfigService: b.configService, } apiRouter := api.NewRouter(apiConfig) router.Mount("/api/v1", apiRouter) diff --git a/webapp/cypress/e2e/16-admin-settings.cy.ts b/webapp/cypress/e2e/16-admin-settings.cy.ts new file mode 100644 index 0000000..e2065bc --- /dev/null +++ b/webapp/cypress/e2e/16-admin-settings.cy.ts @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +/// + +describe('Test 16: Admin Settings Configuration', () => { + beforeEach(() => { + cy.clearCookies() + }) + + // Helper to save settings and wait for success message + const saveAndWaitForSuccess = () => { + cy.get('button').contains('Save').click() + cy.contains('saved successfully', { timeout: 10000 }).should('be.visible') + } + + // Helper to navigate to a settings section + const navigateToSection = (sectionName: string) => { + cy.contains('button', sectionName).click() + } + + describe('Basic Navigation and Access', () => { + it('should allow admin to access settings page', () => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + cy.url({ timeout: 10000 }).should('include', '/admin/settings') + + // Verify page elements + cy.contains('Settings', { timeout: 10000 }).should('be.visible') + + // Verify sidebar navigation sections are present + cy.contains('button', 'General').should('be.visible') + cy.contains('button', 'OAuth / OIDC').should('be.visible') + cy.contains('button', 'Magic Link').should('be.visible') + cy.contains('button', 'Email (SMTP)').should('be.visible') + cy.contains('button', 'Storage').should('be.visible') + }) + + it('should prevent non-admin from accessing settings', () => { + cy.loginViaMagicLink('user@test.com') + cy.visit('/admin/settings', { failOnStatusCode: false }) + + // Should redirect away from admin settings + cy.url({ timeout: 10000 }).should('not.include', '/admin/settings') + }) + + it('should navigate to settings from admin dashboard', () => { + cy.loginAsAdmin() + cy.visit('/admin') + + // Click on Settings link/button in the dashboard (router-link wrapped button) + cy.get('a[href="/admin/settings"]').click() + + // Should navigate to settings page + cy.url({ timeout: 10000 }).should('include', '/admin/settings') + cy.contains('Settings', { timeout: 10000 }).should('be.visible') + }) + }) + + describe('General Settings Section', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + cy.contains('General', { timeout: 10000 }).should('be.visible') + }) + + it('should display general settings form', () => { + // General section should be visible by default + cy.contains('h2', 'General', { timeout: 10000 }).should('be.visible') + + // Verify form fields are present + cy.get('[data-testid="organisation"]').should('exist') + cy.get('[data-testid="only_admin_can_create"]').should('exist') + }) + + it('should update organisation name and persist after reload', () => { + const newOrg = 'E2E Test Org ' + Date.now() + + // Update organisation name + cy.get('[data-testid="organisation"]').clear().type(newOrg) + + // Save settings + saveAndWaitForSuccess() + + // Reload and verify persistence + cy.reload() + cy.get('[data-testid="organisation"]', { timeout: 10000 }).should('have.value', newOrg) + + // Restore original value + cy.get('[data-testid="organisation"]').clear().type('Ackify Test') + saveAndWaitForSuccess() + }) + + it('should toggle only_admin_can_create setting', () => { + // Get initial state + cy.get('[data-testid="only_admin_can_create"]').then(($checkbox) => { + const wasChecked = $checkbox.is(':checked') + + // Toggle the checkbox + if (wasChecked) { + cy.get('[data-testid="only_admin_can_create"]').uncheck() + } else { + cy.get('[data-testid="only_admin_can_create"]').check() + } + + saveAndWaitForSuccess() + + // Reload and verify the change persisted + cy.reload() + cy.get('[data-testid="only_admin_can_create"]', { timeout: 10000 }).should( + wasChecked ? 'not.be.checked' : 'be.checked' + ) + + // Restore original state + if (wasChecked) { + cy.get('[data-testid="only_admin_can_create"]').check() + } else { + cy.get('[data-testid="only_admin_can_create"]').uncheck() + } + saveAndWaitForSuccess() + }) + }) + }) + + describe('OAuth/OIDC Settings Section', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('OAuth / OIDC') + cy.contains('h2', 'OAuth / OIDC', { timeout: 10000 }).should('be.visible') + }) + + it('should display OIDC form fields', () => { + cy.get('[data-testid="oidc_enabled"]').should('exist') + + // Enable OIDC to see more fields + cy.get('[data-testid="oidc_enabled"]').check() + cy.get('[data-testid="oidc_provider"]').should('be.visible') + cy.get('[data-testid="oidc_client_id"]').should('be.visible') + cy.get('[data-testid="oidc_client_secret"]').should('be.visible') + }) + + it('should show custom provider URLs when custom is selected', () => { + cy.get('[data-testid="oidc_enabled"]').check() + cy.get('[data-testid="oidc_provider"]').select('custom') + + // Custom URL fields should appear + cy.get('[data-testid="oidc_auth_url"]').should('be.visible') + cy.get('[data-testid="oidc_token_url"]').should('be.visible') + cy.get('[data-testid="oidc_userinfo_url"]').should('be.visible') + }) + + it('should mask client secret', () => { + cy.get('[data-testid="oidc_enabled"]').check() + + // Client secret input should be password type (masked) + cy.get('[data-testid="oidc_client_secret"]').should('have.attr', 'type', 'password') + }) + }) + + describe('Magic Link Settings Section', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('Magic Link') + cy.contains('h2', 'Magic Link', { timeout: 10000 }).should('be.visible') + }) + + it('should display Magic Link form fields', () => { + cy.get('[data-testid="magiclink_enabled"]').should('exist') + }) + + it('should toggle Magic Link enabled setting', () => { + cy.get('[data-testid="magiclink_enabled"]').then(($checkbox) => { + const wasEnabled = $checkbox.is(':checked') + + // Toggle + if (wasEnabled) { + cy.get('[data-testid="magiclink_enabled"]').uncheck() + } else { + cy.get('[data-testid="magiclink_enabled"]').check() + } + + saveAndWaitForSuccess() + + // Reload and verify + cy.reload() + navigateToSection('Magic Link') + cy.get('[data-testid="magiclink_enabled"]', { timeout: 10000 }).should( + wasEnabled ? 'not.be.checked' : 'be.checked' + ) + + // Restore + if (wasEnabled) { + cy.get('[data-testid="magiclink_enabled"]').check() + } else { + cy.get('[data-testid="magiclink_enabled"]').uncheck() + } + saveAndWaitForSuccess() + }) + }) + }) + + describe('SMTP Settings Section', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('Email (SMTP)') + cy.contains('h2', 'Email (SMTP)', { timeout: 10000 }).should('be.visible') + }) + + it('should display SMTP form fields', () => { + cy.get('[data-testid="smtp_host"]').should('exist') + cy.get('[data-testid="smtp_port"]').should('exist') + cy.get('[data-testid="smtp_from"]').should('exist') + cy.get('[data-testid="smtp_from_name"]').should('exist') + cy.get('[data-testid="smtp_tls"]').should('exist') + cy.get('[data-testid="smtp_starttls"]').should('exist') + }) + + it('should update SMTP port and persist', () => { + // Get current port value + cy.get('[data-testid="smtp_port"]').invoke('val').then((originalPort) => { + const newPort = '2525' + + cy.get('[data-testid="smtp_port"]').clear().type(newPort) + saveAndWaitForSuccess() + + // Reload and verify + cy.reload() + navigateToSection('Email (SMTP)') + cy.get('[data-testid="smtp_port"]', { timeout: 10000 }).should('have.value', newPort) + + // Restore original + cy.get('[data-testid="smtp_port"]').clear().type(String(originalPort)) + saveAndWaitForSuccess() + }) + }) + }) + + describe('Storage Settings Section', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('Storage') + cy.contains('h2', 'Storage', { timeout: 10000 }).should('be.visible') + }) + + it('should display storage form fields', () => { + cy.get('[data-testid="storage_type"]').should('exist') + cy.get('[data-testid="storage_max_size_mb"]').should('exist') + }) + + it('should show local path when storage type is local', () => { + cy.get('[data-testid="storage_type"]').select('local') + cy.get('[data-testid="storage_local_path"]').should('be.visible') + }) + + it('should show S3 fields when storage type is s3', () => { + cy.get('[data-testid="storage_type"]').select('s3') + + // S3 fields should appear + cy.get('[data-testid="storage_s3_endpoint"]').should('be.visible') + cy.get('[data-testid="storage_s3_bucket"]').should('be.visible') + cy.get('[data-testid="storage_s3_access_key"]').should('be.visible') + cy.get('[data-testid="storage_s3_secret_key"]').should('be.visible') + cy.get('[data-testid="storage_s3_region"]').should('be.visible') + cy.get('[data-testid="s3_use_ssl"]').should('be.visible') + }) + + it('should update max size and persist', () => { + cy.get('[data-testid="storage_max_size_mb"]').invoke('val').then((originalSize) => { + const newSize = '100' + + cy.get('[data-testid="storage_max_size_mb"]').clear().type(newSize) + saveAndWaitForSuccess() + + // Reload and verify + cy.reload() + navigateToSection('Storage') + cy.get('[data-testid="storage_max_size_mb"]', { timeout: 10000 }).should('have.value', newSize) + + // Restore original + cy.get('[data-testid="storage_max_size_mb"]').clear().type(String(originalSize)) + saveAndWaitForSuccess() + }) + }) + + it('should maintain selected section after save', () => { + // We're already on Storage section + cy.get('[data-testid="storage_max_size_mb"]').clear().type('75') + saveAndWaitForSuccess() + + // Should still be on Storage section + cy.contains('h2', 'Storage').should('be.visible') + }) + }) + + describe('Reset from ENV', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + }) + + it('should show reset confirmation dialog', () => { + cy.contains('button', 'Reset from ENV').click() + + // Confirmation modal should appear + cy.contains('Reset Settings?', { timeout: 10000 }).should('be.visible') + cy.contains('reset all settings').should('be.visible') + + // Cancel the reset + cy.contains('button', 'Cancel').click() + + // Modal should be closed + cy.contains('Reset Settings?').should('not.exist') + }) + + it('should reset settings to ENV values when confirmed', () => { + // First, modify a setting + cy.get('[data-testid="organisation"]').clear() + cy.get('[data-testid="organisation"]').type('Modified Org Name') + saveAndWaitForSuccess() + + // Now reset from ENV + cy.contains('button', 'Reset from ENV').click() + cy.contains('Reset Settings?', { timeout: 10000 }).should('be.visible') + + // Confirm reset - click the amber button inside the modal (not the Reset from ENV button) + cy.get('.bg-amber-600').click() + + // Wait for reset success + cy.contains('reset', { matchCase: false, timeout: 10000 }).should('be.visible') + + // Organisation should be back to ENV value + cy.get('[data-testid="organisation"]', { timeout: 10000 }).should('have.value', 'Ackify Test') + }) + }) + + describe('Full Flow: Auth Settings affect Login Page', () => { + it('should hide MagicLink option on login page when disabled', () => { + // Login as admin + cy.loginAsAdmin() + + // Go to settings and disable MagicLink + cy.visit('/admin/settings') + navigateToSection('Magic Link') + cy.contains('h2', 'Magic Link', { timeout: 10000 }).should('be.visible') + + // Remember current state and disable + cy.get('[data-testid="magiclink_enabled"]').then(($checkbox) => { + const wasEnabled = $checkbox.is(':checked') + + if (wasEnabled) { + cy.get('[data-testid="magiclink_enabled"]').uncheck() + saveAndWaitForSuccess() + + // Logout + cy.logout() + + // Visit auth page (fresh load to get new window variables) + cy.visit('/auth') + + // MagicLink card should NOT be visible + cy.contains('Send Magic Link').should('not.exist') + + // OAuth should still be visible (if enabled) + // Note: OAuth button might auto-redirect if only method available + + // Re-enable MagicLink via API for other tests + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('Magic Link') + cy.get('[data-testid="magiclink_enabled"]').check() + saveAndWaitForSuccess() + } else { + // MagicLink was already disabled, enable it first then run test + cy.get('[data-testid="magiclink_enabled"]').check() + saveAndWaitForSuccess() + + cy.logout() + cy.visit('/auth') + + // MagicLink should be visible now + cy.contains('Send Magic Link', { timeout: 10000 }).should('be.visible') + + // Disable it + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('Magic Link') + cy.get('[data-testid="magiclink_enabled"]').uncheck() + saveAndWaitForSuccess() + + cy.logout() + cy.visit('/auth') + + // MagicLink should NOT be visible + cy.contains('Send Magic Link').should('not.exist') + + // Re-enable for other tests + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('Magic Link') + cy.get('[data-testid="magiclink_enabled"]').check() + saveAndWaitForSuccess() + } + }) + }) + + it('should hide OAuth option on login page when disabled', () => { + cy.loginAsAdmin() + + // Go to settings and check OIDC status + cy.visit('/admin/settings') + navigateToSection('OAuth / OIDC') + cy.contains('h2', 'OAuth / OIDC', { timeout: 10000 }).should('be.visible') + + cy.get('[data-testid="oidc_enabled"]').then(($checkbox) => { + const wasEnabled = $checkbox.is(':checked') + + if (wasEnabled) { + // Disable OAuth + cy.get('[data-testid="oidc_enabled"]').uncheck() + saveAndWaitForSuccess() + + cy.logout() + cy.visit('/auth') + + // OAuth login button should NOT be visible + cy.contains('Continue with OAuth').should('not.exist') + + // MagicLink should still work + cy.contains('Send Magic Link', { timeout: 10000 }).should('be.visible') + + // Re-enable OAuth + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('OAuth / OIDC') + cy.get('[data-testid="oidc_enabled"]').check() + saveAndWaitForSuccess() + } else { + // OAuth was disabled, enable it first + cy.get('[data-testid="oidc_enabled"]').check() + // Fill in required fields for custom provider + cy.get('[data-testid="oidc_provider"]').select('custom') + cy.get('[data-testid="oidc_client_id"]').clear().type('test_client_id') + cy.get('[data-testid="oidc_client_secret"]').clear().type('test_client_secret') + cy.get('[data-testid="oidc_auth_url"]').clear().type('https://auth.url.com/auth') + cy.get('[data-testid="oidc_token_url"]').clear().type('https://auth.url.com/token') + cy.get('[data-testid="oidc_userinfo_url"]').clear().type('https://auth.url.com/userinfo') + saveAndWaitForSuccess() + + cy.logout() + cy.visit('/auth') + + // OAuth should be visible + cy.contains('Continue with OAuth', { timeout: 10000 }).should('be.visible') + + // Disable it + cy.loginAsAdmin() + cy.visit('/admin/settings') + navigateToSection('OAuth / OIDC') + cy.get('[data-testid="oidc_enabled"]').uncheck() + saveAndWaitForSuccess() + + cy.logout() + cy.visit('/auth') + + // OAuth should NOT be visible + cy.contains('Continue with OAuth').should('not.exist') + + // Don't re-enable as it was originally disabled + } + }) + }) + + it('should show both auth methods when both are enabled', () => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + + // Ensure MagicLink is enabled + navigateToSection('Magic Link') + cy.get('[data-testid="magiclink_enabled"]').check() + saveAndWaitForSuccess() + + // Ensure OAuth is enabled + navigateToSection('OAuth / OIDC') + cy.get('[data-testid="oidc_enabled"]').check() + // If provider not set, select custom and fill required fields + cy.get('[data-testid="oidc_provider"]').then(($select) => { + if (!$select.val()) { + cy.get('[data-testid="oidc_provider"]').select('custom') + cy.get('[data-testid="oidc_client_id"]').clear().type('test_client_id') + cy.get('[data-testid="oidc_client_secret"]').clear().type('test_client_secret') + cy.get('[data-testid="oidc_auth_url"]').clear().type('https://auth.url.com/auth') + cy.get('[data-testid="oidc_token_url"]').clear().type('https://auth.url.com/token') + cy.get('[data-testid="oidc_userinfo_url"]').clear().type('https://auth.url.com/userinfo') + } + }) + saveAndWaitForSuccess() + + cy.logout() + cy.visit('/auth') + + // Both auth methods should be visible + cy.contains('Continue with OAuth', { timeout: 10000 }).should('be.visible') + cy.contains('Send Magic Link').should('be.visible') + }) + + // Note: Test for "no auth method available" is not feasible in e2e + // because disabling both auth methods would prevent re-login to restore state. + // This scenario should be tested at the unit/integration level. + }) + + describe('Validation Errors', () => { + beforeEach(() => { + cy.loginAsAdmin() + cy.visit('/admin/settings') + }) + + it('should show validation error for OIDC without required fields', () => { + navigateToSection('OAuth / OIDC') + cy.contains('h2', 'OAuth / OIDC', { timeout: 10000 }).should('be.visible') + + // Enable OIDC + cy.get('[data-testid="oidc_enabled"]').check() + + // Select custom provider + cy.get('[data-testid="oidc_provider"]').select('custom') + + // Fill only client_id, leave URLs empty + cy.get('[data-testid="oidc_client_id"]').clear() + cy.get('[data-testid="oidc_client_id"]').type('test-id') + cy.get('[data-testid="oidc_auth_url"]').clear() + cy.get('[data-testid="oidc_token_url"]').clear() + cy.get('[data-testid="oidc_userinfo_url"]').clear() + + // Try to save + cy.get('button').contains('Save').click() + + // Should show validation error (red alert box with error icon) + cy.get('.bg-red-50, .bg-red-900\\/20', { timeout: 10000 }).should('be.visible') + }) + }) +}) diff --git a/webapp/src/components/layout/AppHeader.vue b/webapp/src/components/layout/AppHeader.vue index f4d64cb..fa0fefd 100644 --- a/webapp/src/components/layout/AppHeader.vue +++ b/webapp/src/components/layout/AppHeader.vue @@ -3,7 +3,7 @@ import { ref, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' -import { Menu, X, ChevronDown, LogOut, Shield } from 'lucide-vue-next' +import { Menu, X, ChevronDown, LogOut, Shield, FileText, Settings, Webhook } from 'lucide-vue-next' import ThemeToggle from './ThemeToggle.vue' import LanguageSelect from './LanguageSelect.vue' import AppLogo from '@/components/AppLogo.vue' @@ -17,6 +17,7 @@ const router = useRouter() const mobileMenuOpen = ref(false) const userMenuOpen = ref(false) +const adminMenuOpen = ref(false) const isAuthenticated = computed(() => authStore.isAuthenticated) const isAdmin = computed(() => authStore.isAdmin) @@ -89,6 +90,14 @@ const closeMobileMenu = () => { const closeUserMenu = () => { userMenuOpen.value = false } + +const toggleAdminMenu = () => { + adminMenuOpen.value = !adminMenuOpen.value +} + +const closeAdminMenu = () => { + adminMenuOpen.value = false +}