package admin import ( "context" "encoding/json" "strings" "time" "github.com/jackc/pgx/v5" "github.com/ultisuite/ulti-backend/internal/config" ) const orgSettingsSingletonID = 1 func defaultOrgPolicy() map[string]any { return map[string]any{ "authentik": map[string]any{ "enabled": true, "api_url": "", "slug": "ulti-suite", "client_id": "", "enforce_sso": true, "allow_password_fallback": false, "default_groups": "ulti-users", }, "two_factor": map[string]any{ "required_for_all": false, "required_for_admins": true, "allowed_methods": []any{"totp", "webauthn"}, "grace_period_days": 7, "remember_device_days": 30, }, "storage_quotas": map[string]any{ "default_mail_gib": 5, "default_drive_gib": 5, "default_photos_gib": 5, "warn_threshold_pct": 90, }, "usage_quotas": map[string]any{ "llm_requests_per_day": 100, "llm_tokens_per_month": 500000, "search_requests_per_day": 50, "max_api_tokens_per_user": 10, "max_webhooks_per_user": 20, }, "file_policies": map[string]any{ "max_upload_mib": 512, "allowed_extensions": "", "block_executable": true, "external_sharing": "authenticated", "default_link_expiry_days": 30, "virus_scan_enabled": false, "retention_trash_days": 30, }, "llm": map[string]any{ "default_provider_id": "", "providers": []any{}, "enforce_org_providers": false, "allow_user_override": true, }, "search": map[string]any{ "suite_engine": "postgres", "meilisearch_url": "", "meilisearch_api_key": "", "typesense_url": "", "typesense_api_key": "", "web_search": map[string]any{ "default_provider_id": "brave-default", "providers": []any{}, }, "enforce_org_search": false, }, "administrators": []any{}, "nextcloud": map[string]any{ "enabled": false, "base_url": "", "admin_user": "", "admin_password": "", "drive_enabled": true, "calendar_enabled": true, "contacts_enabled": true, "talk_enabled": false, }, "mailing": map[string]any{ "enabled": false, "smtp_host": "", "smtp_port": 587, "smtp_user": "", "smtp_password": "", "from_email": "noreply@example.com", "from_name": "Ulti Suite", "tls_mode": "starttls", }, "onlyoffice": map[string]any{ "enabled": false, "document_server_url": "", "jwt_secret": "", "jwt_header": "Authorization", }, "plugins": []any{ map[string]any{"id": "mail-automation", "name": "Automatisations mail", "description": "Règles, webhooks et tri IA sur la réception.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "contact-discovery", "name": "Découverte contacts", "description": "Enrichissement IA et signatures détectées.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "public-share", "name": "Partage public Drive", "description": "Liens publics et partages externes.", "enabled": true, "version": "1.0.0"}, map[string]any{"id": "office-editor", "name": "Édition OnlyOffice", "description": "Édition collaborative de documents.", "enabled": false, "version": "1.0.0"}, }, "integrations": []any{ map[string]any{"id": "authentik", "name": "Authentik", "description": "SSO, groupes et provisionnement des comptes.", "enabled": true, "configured": false}, map[string]any{"id": "nextcloud", "name": "Nextcloud", "description": "Drive, agenda, contacts et Talk.", "enabled": false, "configured": false}, map[string]any{"id": "onlyoffice", "name": "OnlyOffice", "description": "Édition de documents dans le navigateur.", "enabled": false, "configured": false}, map[string]any{"id": "smtp", "name": "Mailing unifié", "description": "SMTP pour notifications suite (partages, mentions).", "enabled": false, "configured": false}, }, } } func (s *Service) loadOrgPolicyRaw(ctx context.Context) (map[string]any, time.Time, string, error) { var raw []byte var updatedAt time.Time var updatedBy string err := s.db.QueryRow(ctx, ` SELECT settings, updated_at, updated_by FROM org_settings WHERE id = $1 `, orgSettingsSingletonID).Scan(&raw, &updatedAt, &updatedBy) if err != nil { if err == pgx.ErrNoRows { return map[string]any{}, time.Time{}, "", nil } return nil, time.Time{}, "", err } stored := map[string]any{} if len(raw) > 0 { if err := json.Unmarshal(raw, &stored); err != nil { return nil, time.Time{}, "", err } } return stored, updatedAt, updatedBy, nil } func mergeMaps(base, patch map[string]any) map[string]any { if base == nil { base = map[string]any{} } out := make(map[string]any, len(base)+len(patch)) for k, v := range base { out[k] = v } for k, patchVal := range patch { if patchVal == nil { continue } baseVal, ok := out[k] if !ok { out[k] = patchVal continue } baseMap, baseOK := baseVal.(map[string]any) patchMap, patchOK := patchVal.(map[string]any) if baseOK && patchOK { out[k] = mergeMaps(baseMap, patchMap) continue } out[k] = patchVal } return out } func mergeOrgSecrets(existing, patch map[string]any) map[string]any { merged := mergeMaps(existing, patch) secretPaths := []struct { section string key string }{ {"nextcloud", "admin_password"}, {"mailing", "smtp_password"}, {"onlyoffice", "jwt_secret"}, {"search", "meilisearch_api_key"}, {"search", "typesense_api_key"}, } for _, p := range secretPaths { patchSection, _ := patch[p.section].(map[string]any) if patchSection == nil { continue } val, _ := patchSection[p.key].(string) if strings.TrimSpace(val) == "" { if existingSection, ok := existing[p.section].(map[string]any); ok { if existingVal, ok := existingSection[p.key].(string); ok && existingVal != "" { mergedSection, _ := merged[p.section].(map[string]any) if mergedSection == nil { mergedSection = map[string]any{} } mergedSection[p.key] = existingVal merged[p.section] = mergedSection } } } } if patchLLM, ok := patch["llm"].(map[string]any); ok { mergeLLMProviderSecrets(existing, patchLLM, merged) } if patchSearch, ok := patch["search"].(map[string]any); ok { if patchWS, ok := patchSearch["web_search"].(map[string]any); ok { mergeSearchProviderSecrets(existing, patchWS, merged) } } return merged } func mergeLLMProviderSecrets(existing, patchLLM, merged map[string]any) { patchProviders, _ := patchLLM["providers"].([]any) if len(patchProviders) == 0 { return } existingLLM, _ := existing["llm"].(map[string]any) existingProviders, _ := existingLLM["providers"].([]any) mergedLLM, _ := merged["llm"].(map[string]any) if mergedLLM == nil { return } mergedProviders := make([]any, len(patchProviders)) for i, item := range patchProviders { pm, ok := item.(map[string]any) if !ok { mergedProviders[i] = item continue } id, _ := pm["id"].(string) apiKey, _ := pm["api_key"].(string) if strings.TrimSpace(apiKey) == "" && id != "" { for _, ep := range existingProviders { em, ok := ep.(map[string]any) if !ok { continue } if eid, _ := em["id"].(string); eid == id { if ek, ok := em["api_key"].(string); ok && ek != "" { pm["api_key"] = ek } } } } mergedProviders[i] = pm } mergedLLM["providers"] = mergedProviders merged["llm"] = mergedLLM } func mergeSearchProviderSecrets(existing map[string]any, patchWS map[string]any, merged map[string]any) { patchProviders, _ := patchWS["providers"].([]any) if len(patchProviders) == 0 { return } existingSearch, _ := existing["search"].(map[string]any) existingWS, _ := existingSearch["web_search"].(map[string]any) existingProviders, _ := existingWS["providers"].([]any) mergedSearch, _ := merged["search"].(map[string]any) if mergedSearch == nil { return } mergedWS, _ := mergedSearch["web_search"].(map[string]any) if mergedWS == nil { mergedWS = map[string]any{} } mergedProviders := make([]any, len(patchProviders)) for i, item := range patchProviders { pm, ok := item.(map[string]any) if !ok { mergedProviders[i] = item continue } id, _ := pm["id"].(string) apiKey, _ := pm["api_key"].(string) if strings.TrimSpace(apiKey) == "" && id != "" { for _, ep := range existingProviders { em, ok := ep.(map[string]any) if !ok { continue } if eid, _ := em["id"].(string); eid == id { if ek, ok := em["api_key"].(string); ok && ek != "" { pm["api_key"] = ek } } } } mergedProviders[i] = pm } mergedWS["providers"] = mergedProviders mergedSearch["web_search"] = mergedWS merged["search"] = mergedSearch } func maskOrgPolicy(policy map[string]any) map[string]any { cloned := deepCloneMap(policy) maskStringField(cloned, "nextcloud", "admin_password") maskStringField(cloned, "mailing", "smtp_password") maskStringField(cloned, "onlyoffice", "jwt_secret") maskStringField(cloned, "search", "meilisearch_api_key") maskStringField(cloned, "search", "typesense_api_key") if llm, ok := cloned["llm"].(map[string]any); ok { if providers, ok := llm["providers"].([]any); ok { for i, p := range providers { pm, ok := p.(map[string]any) if !ok { continue } if v, _ := pm["api_key"].(string); strings.TrimSpace(v) != "" { pm["api_key"] = "" } providers[i] = pm } llm["providers"] = providers } } if search, ok := cloned["search"].(map[string]any); ok { if ws, ok := search["web_search"].(map[string]any); ok { if providers, ok := ws["providers"].([]any); ok { for i, p := range providers { pm, ok := p.(map[string]any) if !ok { continue } if v, _ := pm["api_key"].(string); strings.TrimSpace(v) != "" { pm["api_key"] = "" } providers[i] = pm } ws["providers"] = providers } } } return cloned } func maskStringField(m map[string]any, section, key string) { sec, ok := m[section].(map[string]any) if !ok { return } if v, _ := sec[key].(string); strings.TrimSpace(v) != "" { sec[key] = "" } } func deepCloneMap(in map[string]any) map[string]any { raw, _ := json.Marshal(in) out := map[string]any{} _ = json.Unmarshal(raw, &out) return out } func secretConfigured(policy map[string]any, section, key string) bool { sec, ok := policy[section].(map[string]any) if !ok { return false } v, _ := sec[key].(string) return strings.TrimSpace(v) != "" } func buildOrgSecretsStatus(policy map[string]any, cfg *config.Config) map[string]any { return map[string]any{ "nextcloud_admin_password": map[string]any{ "configured": secretConfigured(policy, "nextcloud", "admin_password") || strings.TrimSpace(cfg.NCAdminPass) != "", }, "mailing_smtp_password": map[string]any{ "configured": secretConfigured(policy, "mailing", "smtp_password"), }, "onlyoffice_jwt_secret": map[string]any{ "configured": secretConfigured(policy, "onlyoffice", "jwt_secret") || strings.TrimSpace(cfg.OnlyOfficeJWTSecret) != "", }, "meilisearch_api_key": map[string]any{ "configured": secretConfigured(policy, "search", "meilisearch_api_key") || strings.TrimSpace(cfg.MeilisearchKey) != "", }, "typesense_api_key": map[string]any{ "configured": secretConfigured(policy, "search", "typesense_api_key") || strings.TrimSpace(cfg.TypesenseKey) != "", }, } } func buildOrgEffective(cfg *config.Config) map[string]any { authentikEnabled := strings.TrimSpace(cfg.AuthentikAPIToken) != "" || strings.TrimSpace(cfg.OIDCIssuer) != "" return map[string]any{ "authentik": map[string]any{ "enabled": authentikEnabled, "api_url": cfg.AuthentikAPIURL, "client_id": cfg.OIDCClientID, "issuer": cfg.OIDCIssuer, }, "nextcloud": map[string]any{ "enabled": cfg.NextcloudEnabled, "base_url": firstNonEmpty(cfg.NextcloudPublicURL, cfg.NextcloudURL), "admin_user": cfg.NCAdminUser, }, "onlyoffice": map[string]any{ "enabled": cfg.OnlyOfficeEnabled, "document_server_url": firstNonEmpty(cfg.OnlyOfficePublicURL, cfg.OnlyOfficeURL), }, "search": map[string]any{ "suite_engine": cfg.SearchEngine, "meilisearch_url": cfg.MeilisearchURL, "typesense_url": cfg.TypesenseURL, }, "immich": map[string]any{ "enabled": cfg.ImmichEnabled, "api_url": cfg.ImmichAPIURL, }, "jitsi": map[string]any{ "enabled": cfg.JitsiEnabled, "public_url": cfg.JitsiPublicURL, }, } } func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return v } } return "" } func (s *Service) GetOrgSettings(ctx context.Context) (map[string]any, error) { stored, updatedAt, updatedBy, err := s.loadOrgPolicyRaw(ctx) if err != nil { return nil, err } policy := mergeMaps(defaultOrgPolicy(), stored) masked := maskOrgPolicy(policy) return map[string]any{ "policy": masked, "effective": buildOrgEffective(s.cfg), "secrets": buildOrgSecretsStatus(policy, s.cfg), "env_vars": buildOrgEnvVars(), "deploy_locked": buildOrgDeployLocked(s.cfg), "updated_at": updatedAt.UTC().Format(time.RFC3339), "updated_by": updatedBy, }, nil } func (s *Service) PutOrgSettings(ctx context.Context, actorSub string, patch map[string]any) (map[string]any, error) { stored, _, _, err := s.loadOrgPolicyRaw(ctx) if err != nil { return nil, err } merged := mergeOrgSecrets(stored, patch) raw, err := json.Marshal(merged) if err != nil { return nil, err } _, err = s.db.Exec(ctx, ` INSERT INTO org_settings (id, settings, updated_at, updated_by) VALUES ($1, $2, NOW(), $3) ON CONFLICT (id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = NOW(), updated_by = EXCLUDED.updated_by `, orgSettingsSingletonID, raw, actorSub) if err != nil { return nil, err } s.logAudit(ctx, actorSub, "update_org_settings", map[string]any{ "sections": mapKeys(patch), }) return s.GetOrgSettings(ctx) } func mapKeys(m map[string]any) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } const gibBytes = int64(1024 * 1024 * 1024) func policyGibValue(v any, fallback int64) int64 { switch n := v.(type) { case float64: if n > 0 { return int64(n * float64(gibBytes)) } case int: if n > 0 { return int64(n) * gibBytes } case int64: if n > 0 { return n * gibBytes } } return fallback * gibBytes } func (s *Service) applyOrgDefaultQuotas(ctx context.Context, userID string) error { stored, _, _, err := s.loadOrgPolicyRaw(ctx) if err != nil { return err } policy := mergeMaps(defaultOrgPolicy(), stored) storage, _ := policy["storage_quotas"].(map[string]any) const defaultGib = int64(5) mail := policyGibValue(storage["default_mail_gib"], defaultGib) drive := policyGibValue(storage["default_drive_gib"], defaultGib) photos := policyGibValue(storage["default_photos_gib"], defaultGib) _, err = s.db.Exec(ctx, ` INSERT INTO settings (user_id, preferences) VALUES ( $1, jsonb_build_object( 'mail_max_storage_bytes', $2::text, 'drive_max_storage_bytes', $3::text, 'photos_max_storage_bytes', $4::text ) ) ON CONFLICT (user_id) DO NOTHING `, userID, mail, drive, photos) return err }