ultisuite-backend/internal/api/admin/org_settings.go
R3D347HR4Y b90edf317c
Some checks failed
CI / Go tests (push) Has been cancelled
CI / Integration tests (push) Has been cancelled
CI / DB migrations (push) Has been cancelled
feat(scan): add VirusTotal upload antivirus
Admin-stored API key with env fallback; scan drive/mail/IMAP uploads.
Fail-open if VT down, 422 on malware; migration for virus_scan_status.
2026-06-07 22:05:27 +02:00

527 lines
16 KiB
Go

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,
"virustotal_api_key": "",
"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"},
{"file_policies", "virustotal_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")
maskStringField(cloned, "file_policies", "virustotal_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) != "",
},
"virustotal_api_key": map[string]any{
"configured": secretConfigured(policy, "file_policies", "virustotal_api_key") || strings.TrimSpace(cfg.VirusTotalAPIKey) != "",
},
}
}
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
}