ultisuite-backend/internal/api/admin/org_settings.go
R3D347HR4Y d3c930cac6
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(identity-providers): add management for identity providers in admin API
- Introduced new endpoints for managing identity providers, including retrieval of redirect URIs and testing/syncing providers.
- Enhanced organization settings to include identity provider configurations, allowing for self-enrollment and domain restrictions.
- Implemented caching for access policies and added validation for identity provider secrets.
- Added integration tests to ensure proper functionality of identity provider management and policy enforcement.
2026-06-09 09:36:38 +02:00

688 lines
20 KiB
Go

package admin
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/ultisuite/ulti-backend/internal/config"
"github.com/ultisuite/ulti-backend/internal/authentik"
)
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",
},
"identity_providers": map[string]any{
"allow_self_enrollment": true,
"default_login_source": "",
"providers": []any{},
},
"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)
}
}
if patchIDP, ok := patch["identity_providers"].(map[string]any); ok {
mergeIdentityProviderSecrets(existing, patchIDP, 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 mergeIdentityProviderSecrets(existing, patchIDP, merged map[string]any) {
patchProviders, _ := patchIDP["providers"].([]any)
if len(patchProviders) == 0 {
return
}
existingIDP, _ := existing["identity_providers"].(map[string]any)
existingProviders, _ := existingIDP["providers"].([]any)
mergedIDP, _ := merged["identity_providers"].(map[string]any)
if mergedIDP == 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)
for _, secretPath := range []struct{ section, key string }{
{"oauth", "client_secret"},
{"ldap", "bind_password"},
{"saml", "signing_cert"},
} {
mergeProviderSecretField(pm, existingProviders, id, secretPath.section, secretPath.key)
}
mergedProviders[i] = pm
}
mergedIDP["providers"] = mergedProviders
merged["identity_providers"] = mergedIDP
}
func mergeProviderSecretField(pm map[string]any, existingProviders []any, id, section, key string) {
nested, _ := pm[section].(map[string]any)
if nested == nil {
return
}
val, _ := nested[key].(string)
if strings.TrimSpace(val) != "" || id == "" {
return
}
for _, ep := range existingProviders {
em, ok := ep.(map[string]any)
if !ok {
continue
}
if eid, _ := em["id"].(string); eid != id {
continue
}
esec, _ := em[section].(map[string]any)
if esec == nil {
continue
}
if ek, ok := esec[key].(string); ok && ek != "" {
nested[key] = ek
}
}
}
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
}
}
}
maskIdentityProviderSecrets(cloned)
return cloned
}
func maskIdentityProviderSecrets(policy map[string]any) {
idp, ok := policy["identity_providers"].(map[string]any)
if !ok {
return
}
providers, ok := idp["providers"].([]any)
if !ok {
return
}
for i, item := range providers {
pm, ok := item.(map[string]any)
if !ok {
continue
}
for _, section := range []string{"oauth", "ldap", "saml"} {
nested, ok := pm[section].(map[string]any)
if !ok {
continue
}
for _, key := range []string{"client_secret", "bind_password", "signing_cert"} {
if v, _ := nested[key].(string); strings.TrimSpace(v) != "" {
nested[key] = ""
}
}
pm[section] = nested
}
providers[i] = pm
}
idp["providers"] = providers
}
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 {
secrets := 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) != "",
},
}
if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
secrets["identity_providers"] = idpSecrets
}
return secrets
}
func buildIdentityProviderSecretsStatus(policy map[string]any) map[string]any {
idp, ok := policy["identity_providers"].(map[string]any)
if !ok {
return nil
}
providers, ok := idp["providers"].([]any)
if !ok {
return nil
}
out := make(map[string]any)
for _, item := range providers {
pm, ok := item.(map[string]any)
if !ok {
continue
}
id, _ := pm["id"].(string)
if id == "" {
continue
}
entry := map[string]any{}
if oauth, ok := pm["oauth"].(map[string]any); ok {
if v, _ := oauth["client_secret"].(string); strings.TrimSpace(v) != "" {
entry["oauth_client_secret"] = map[string]any{"configured": true}
}
}
if ldap, ok := pm["ldap"].(map[string]any); ok {
if v, _ := ldap["bind_password"].(string); strings.TrimSpace(v) != "" {
entry["ldap_bind_password"] = map[string]any{"configured": true}
}
}
if saml, ok := pm["saml"].(map[string]any); ok {
if v, _ := saml["signing_cert"].(string); strings.TrimSpace(v) != "" {
entry["saml_signing_cert"] = map[string]any{"configured": true}
}
}
if len(entry) > 0 {
out[id] = entry
}
}
return out
}
func buildOrgEffective(cfg *config.Config) map[string]any {
authentikEnabled := strings.TrimSpace(cfg.AuthentikAPIToken) != "" || strings.TrimSpace(cfg.OIDCIssuer) != ""
publicBase := authentik.AuthentikPublicBaseURL(cfg.AuthentikAPIURL, cfg.AuthentikPublicHTTPS)
return map[string]any{
"authentik": map[string]any{
"enabled": authentikEnabled,
"api_url": cfg.AuthentikAPIURL,
"client_id": cfg.OIDCClientID,
"issuer": cfg.OIDCIssuer,
},
"identity_providers": map[string]any{
"authentik_public_url": publicBase,
"oauth_redirect_template": publicBase + "/source/oauth/callback/{slug}/",
},
"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) {
storedBefore, _, _, err := s.loadOrgPolicyRaw(ctx)
if err != nil {
return nil, err
}
merged := mergeOrgSecrets(storedBefore, 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),
})
if _, ok := patch["identity_providers"]; ok {
removed := authentik.RemovedIdentityProviders(storedBefore, merged)
syncer := authentik.NewSourceSyncer(s.db, s.cfg)
idpSection, _ := merged["identity_providers"].(map[string]any)
if err := syncer.SyncSectionWithRemoved(ctx, idpSection, removed); err != nil {
s.logger.Warn("identity provider sync failed", "error", err)
}
}
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
}