- Refactored AI gateway to utilize new cost management structures for usage tracking. - Replaced deprecated token extraction methods with a unified cost parsing approach. - Enhanced usage fallback mechanisms and introduced detailed usage metrics in responses. - Added new metering functionality to record AI usage and costs effectively. - Updated tests to reflect changes in usage parsing and cost calculations. - Introduced new API endpoints for retrieving AI usage summaries and pricing information.
977 lines
29 KiB
Go
977 lines
29 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"
|
|
"github.com/ultisuite/ulti-backend/internal/orgpolicy"
|
|
)
|
|
|
|
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_daily_cost_limit_eur": 2,
|
|
"llm_monthly_cost_limit_eur": 35,
|
|
"llm_cost_warn_threshold_pct": 80,
|
|
"llm_requests_per_day": 75,
|
|
"llm_tokens_per_month": 2_000_000,
|
|
"search_requests_per_day": 20,
|
|
"max_api_tokens_per_user": 5,
|
|
"max_webhooks_per_user": 5,
|
|
},
|
|
"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,
|
|
"mount_oauth": orgpolicy.DefaultMountOAuthSection(),
|
|
},
|
|
"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",
|
|
},
|
|
"richtext": map[string]any{
|
|
"enabled": true,
|
|
"storage_mode": "sidecar",
|
|
"export_mirror_format": "",
|
|
"hocuspocus_url": "",
|
|
},
|
|
"ai_assistant": map[string]any{
|
|
"enabled": false,
|
|
"openwebui_internal_url": "",
|
|
"public_path": "/ai",
|
|
"embed_default_temporary": false,
|
|
"default_model": "",
|
|
"enabled_tools": []any{"mail", "drive", "contacts", "agenda", "search", "web_search"},
|
|
"chat_sync_enabled": true,
|
|
"chat_nc_path": "/.ultimail/ai/chats",
|
|
"models": []any{},
|
|
},
|
|
"agenda": map[string]any{
|
|
"default_theme_mode": "system",
|
|
"enforce_org_theme": false,
|
|
"default_video_provider": "ultimeet",
|
|
"enforce_org_video_provider": false,
|
|
"video_provider_api_keys": map[string]any{},
|
|
},
|
|
"meet": map[string]any{
|
|
"transcription_enabled": false,
|
|
"transcription_mode": "live",
|
|
"transcription_engine": "faster_whisper_local",
|
|
"skynet_url": "http://skynet:8000",
|
|
"whisper_model": "tiny",
|
|
"external_api_url": "",
|
|
"external_api_provider": "openai_compatible",
|
|
"external_api_key": "",
|
|
"auto_start_transcription": false,
|
|
"post_actions": map[string]any{
|
|
"email_enabled": false,
|
|
"email_recipients": "organizer",
|
|
"email_custom_addresses": "",
|
|
"drive_enabled": true,
|
|
"drive_folder_path": "/UltiMeet/Transcripts",
|
|
"llm_enabled": false,
|
|
"llm_provider_id": "",
|
|
"llm_prompt": "Résume cette réunion en français : points clés, décisions et actions à suivre.",
|
|
"llm_then_email": true,
|
|
"llm_then_drive": true,
|
|
},
|
|
},
|
|
"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"},
|
|
map[string]any{"id": "richtext-editor", "name": "Édition rich text TipTap", "description": "Édition rich text TipTap pour documents Word.", "enabled": true, "version": "1.0.0"},
|
|
map[string]any{"id": "ai-assistant", "name": "UltiAI", "description": "Assistant IA intégré (chat, tools mail/drive/contacts, recherche web).", "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 mergeOrgPlugins(defaults, stored []any) []any {
|
|
if len(defaults) == 0 {
|
|
return stored
|
|
}
|
|
storedByID := map[string]map[string]any{}
|
|
for _, item := range stored {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _ := m["id"].(string)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
storedByID[id] = m
|
|
}
|
|
out := make([]any, 0, len(defaults)+len(storedByID))
|
|
seen := map[string]bool{}
|
|
for _, item := range defaults {
|
|
def, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _ := def["id"].(string)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
if stored, ok := storedByID[id]; ok {
|
|
out = append(out, mergeMaps(def, stored))
|
|
continue
|
|
}
|
|
out = append(out, def)
|
|
}
|
|
for _, item := range stored {
|
|
m, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _ := m["id"].(string)
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeOrgPolicy(policy map[string]any) map[string]any {
|
|
defaults := defaultOrgPolicy()
|
|
defPlugins, _ := defaults["plugins"].([]any)
|
|
storedPlugins, _ := policy["plugins"].([]any)
|
|
policy["plugins"] = mergeOrgPlugins(defPlugins, storedPlugins)
|
|
return policy
|
|
}
|
|
|
|
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)
|
|
}
|
|
if patchAgenda, ok := patch["agenda"].(map[string]any); ok {
|
|
mergeAgendaProviderSecrets(existing, patchAgenda, merged)
|
|
}
|
|
if patchMeet, ok := patch["meet"].(map[string]any); ok {
|
|
mergeMeetSecrets(existing, patchMeet, merged)
|
|
}
|
|
if patchFilePolicies, ok := patch["file_policies"].(map[string]any); ok {
|
|
mergeMountOAuthSecrets(existing, patchFilePolicies, merged)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func mergeMountOAuthSecrets(existing, patchFilePolicies, merged map[string]any) {
|
|
patchMountOAuth, _ := patchFilePolicies["mount_oauth"].(map[string]any)
|
|
if patchMountOAuth == nil {
|
|
return
|
|
}
|
|
existingFilePolicies, _ := existing["file_policies"].(map[string]any)
|
|
existingMountOAuth, _ := existingFilePolicies["mount_oauth"].(map[string]any)
|
|
mergedFilePolicies, _ := merged["file_policies"].(map[string]any)
|
|
if mergedFilePolicies == nil {
|
|
return
|
|
}
|
|
out := map[string]any{}
|
|
for k, v := range patchMountOAuth {
|
|
if k == "redirect_uri" {
|
|
out[k] = v
|
|
continue
|
|
}
|
|
providerPatch, ok := v.(map[string]any)
|
|
if !ok {
|
|
out[k] = v
|
|
continue
|
|
}
|
|
mergedProvider := map[string]any{}
|
|
for pk, pv := range providerPatch {
|
|
mergedProvider[pk] = pv
|
|
}
|
|
if secret, _ := providerPatch["client_secret"].(string); strings.TrimSpace(secret) == "" {
|
|
if existingMountOAuth != nil {
|
|
if existingProvider, ok := existingMountOAuth[k].(map[string]any); ok {
|
|
if prev, ok := existingProvider["client_secret"].(string); ok && prev != "" {
|
|
mergedProvider["client_secret"] = prev
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out[k] = mergedProvider
|
|
}
|
|
mergedFilePolicies["mount_oauth"] = out
|
|
merged["file_policies"] = mergedFilePolicies
|
|
}
|
|
|
|
func mergeMeetSecrets(existing, patchMeet, merged map[string]any) {
|
|
if strings.TrimSpace(stringValueMap(patchMeet, "external_api_key")) != "" {
|
|
return
|
|
}
|
|
existingMeet, _ := existing["meet"].(map[string]any)
|
|
prev := stringValueMap(existingMeet, "external_api_key")
|
|
if prev == "" {
|
|
return
|
|
}
|
|
mergedMeet, _ := merged["meet"].(map[string]any)
|
|
if mergedMeet == nil {
|
|
return
|
|
}
|
|
mergedMeet["external_api_key"] = prev
|
|
merged["meet"] = mergedMeet
|
|
}
|
|
|
|
func stringValueMap(m map[string]any, key string) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
s, _ := m[key].(string)
|
|
return s
|
|
}
|
|
|
|
func mergeAgendaProviderSecrets(existing, patchAgenda, merged map[string]any) {
|
|
patchKeys, _ := patchAgenda["video_provider_api_keys"].(map[string]any)
|
|
if len(patchKeys) == 0 {
|
|
return
|
|
}
|
|
existingAgenda, _ := existing["agenda"].(map[string]any)
|
|
existingKeys, _ := existingAgenda["video_provider_api_keys"].(map[string]any)
|
|
mergedAgenda, _ := merged["agenda"].(map[string]any)
|
|
if mergedAgenda == nil {
|
|
return
|
|
}
|
|
outKeys := map[string]any{}
|
|
for k, v := range patchKeys {
|
|
s, _ := v.(string)
|
|
if strings.TrimSpace(s) == "" {
|
|
if existingKeys != nil {
|
|
if prev, ok := existingKeys[k].(string); ok && prev != "" {
|
|
outKeys[k] = prev
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
outKeys[k] = v
|
|
}
|
|
mergedAgenda["video_provider_api_keys"] = outKeys
|
|
merged["agenda"] = mergedAgenda
|
|
}
|
|
|
|
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 filePolicies, ok := cloned["file_policies"].(map[string]any); ok {
|
|
if mountOAuth, ok := filePolicies["mount_oauth"].(map[string]any); ok {
|
|
for _, provider := range []string{"google", "dropbox", "microsoft"} {
|
|
if section, ok := mountOAuth[provider].(map[string]any); ok {
|
|
if secret, _ := section["client_secret"].(string); strings.TrimSpace(secret) != "" {
|
|
section["client_secret"] = ""
|
|
}
|
|
mountOAuth[provider] = section
|
|
}
|
|
}
|
|
filePolicies["mount_oauth"] = mountOAuth
|
|
}
|
|
}
|
|
if agenda, ok := cloned["agenda"].(map[string]any); ok {
|
|
if keys, ok := agenda["video_provider_api_keys"].(map[string]any); ok {
|
|
for k, v := range keys {
|
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
|
keys[k] = ""
|
|
}
|
|
}
|
|
agenda["video_provider_api_keys"] = keys
|
|
}
|
|
}
|
|
if meet, ok := cloned["meet"].(map[string]any); ok {
|
|
if v, _ := meet["external_api_key"].(string); strings.TrimSpace(v) != "" {
|
|
meet["external_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 mountOAuthProviderSecretConfigured(policy map[string]any, provider string) bool {
|
|
fp, ok := policy["file_policies"].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
mo, ok := fp["mount_oauth"].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
section, ok := mo[provider].(map[string]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
enabled, _ := section["enabled"].(bool)
|
|
if !enabled {
|
|
return false
|
|
}
|
|
clientID, _ := section["client_id"].(string)
|
|
clientSecret, _ := section["client_secret"].(string)
|
|
return strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
|
|
}
|
|
|
|
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) != "",
|
|
},
|
|
"mount_oauth_google": map[string]any{
|
|
"configured": mountOAuthProviderSecretConfigured(policy, "google"),
|
|
},
|
|
"mount_oauth_dropbox": map[string]any{
|
|
"configured": mountOAuthProviderSecretConfigured(policy, "dropbox"),
|
|
},
|
|
"mount_oauth_microsoft": map[string]any{
|
|
"configured": mountOAuthProviderSecretConfigured(policy, "microsoft"),
|
|
},
|
|
}
|
|
if idpSecrets := buildIdentityProviderSecretsStatus(policy); len(idpSecrets) > 0 {
|
|
secrets["identity_providers"] = idpSecrets
|
|
}
|
|
if llmSecrets := buildLLMProviderSecretsStatus(policy); len(llmSecrets) > 0 {
|
|
secrets["llm_providers"] = llmSecrets
|
|
}
|
|
if webSearchSecrets := buildWebSearchProviderSecretsStatus(policy); len(webSearchSecrets) > 0 {
|
|
secrets["web_search_providers"] = webSearchSecrets
|
|
}
|
|
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,
|
|
},
|
|
"ai_assistant": map[string]any{
|
|
"enabled": cfg.AIAssistantEnabled,
|
|
"openwebui_internal_url": cfg.OpenWebUIInternalURL,
|
|
"public_path": cfg.AIAssistantPublicPath,
|
|
},
|
|
}
|
|
}
|
|
|
|
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 := normalizeOrgPolicy(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)
|
|
}
|
|
}
|
|
|
|
if usageQuotas, ok := merged["usage_quotas"].(map[string]any); ok {
|
|
if err := s.syncUsageQuotasToCostPolicy(ctx, usageQuotas); err != nil {
|
|
s.logger.Warn("sync ai cost policy 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
|
|
}
|