From f67c109f2feeb3db8ba3e53c31fade5984fd7ac2 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Sun, 7 Jun 2026 21:55:22 +0200 Subject: [PATCH] Admin interface --- README.md | 2 +- .../blueprints/03-ulti-suite-groups.yaml | 29 +- deploy/nginx/default.conf.template | 14 + internal/api/admin/handlers.go | 90 ++- internal/api/admin/org_settings.go | 520 ++++++++++++++++++ internal/api/admin/org_settings_env.go | 109 ++++ internal/api/admin/public_shares.go | 194 +++++++ internal/api/admin/public_shares_handlers.go | 59 ++ internal/api/admin/service.go | 405 ++++++++++++-- internal/api/admin/validate.go | 29 + internal/api/drive/public_service.go | 6 +- internal/api/drive/service.go | 8 + internal/api/middleware/auth.go | 9 +- internal/api/middleware/rbac.go | 20 + internal/api/users/handlers.go | 59 ++ .../admin/org_settings_test.go | 59 ++ .../integrationtest/auth/first_admin_test.go | 66 +++ internal/nextcloud/drive.go | 6 +- internal/nextcloud/share_filter.go | 21 + internal/nextcloud/share_filter_test.go | 18 + internal/permission/permission.go | 75 +++ internal/permission/permission_test.go | 42 ++ internal/publicshare/access.go | 60 ++ internal/server/bootstrap.go | 49 +- internal/users/admin.go | 82 +++ internal/users/provision.go | 17 +- internal/users/role.go | 96 ++++ migrations/000031_org_settings.down.sql | 1 + migrations/000031_org_settings.up.sql | 8 + .../000032_user_platform_admin.down.sql | 1 + migrations/000032_user_platform_admin.up.sql | 10 + .../000033_drive_public_share_access.down.sql | 1 + .../000033_drive_public_share_access.up.sql | 8 + 33 files changed, 2086 insertions(+), 87 deletions(-) create mode 100644 internal/api/admin/org_settings.go create mode 100644 internal/api/admin/org_settings_env.go create mode 100644 internal/api/admin/public_shares.go create mode 100644 internal/api/admin/public_shares_handlers.go create mode 100644 internal/api/users/handlers.go create mode 100644 internal/integrationtest/admin/org_settings_test.go create mode 100644 internal/integrationtest/auth/first_admin_test.go create mode 100644 internal/nextcloud/share_filter.go create mode 100644 internal/nextcloud/share_filter_test.go create mode 100644 internal/publicshare/access.go create mode 100644 internal/users/admin.go create mode 100644 internal/users/role.go create mode 100644 migrations/000031_org_settings.down.sql create mode 100644 migrations/000031_org_settings.up.sql create mode 100644 migrations/000032_user_platform_admin.down.sql create mode 100644 migrations/000032_user_platform_admin.up.sql create mode 100644 migrations/000033_drive_public_share_access.down.sql create mode 100644 migrations/000033_drive_public_share_access.up.sql diff --git a/README.md b/README.md index 19c1829..4cc3196 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route : | `/auth/*` | Authentik | | `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) | | `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) | -| `/mail/*`, `/drive/*`, `/contacts` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000`) | +| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000` ; Docker : `suite-frontend:3000`) | Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`). Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx). diff --git a/deploy/authentik/blueprints/03-ulti-suite-groups.yaml b/deploy/authentik/blueprints/03-ulti-suite-groups.yaml index c92cf5d..49331a5 100644 --- a/deploy/authentik/blueprints/03-ulti-suite-groups.yaml +++ b/deploy/authentik/blueprints/03-ulti-suite-groups.yaml @@ -5,6 +5,14 @@ metadata: labels: blueprints.goauthentik.io/instantiate: "true" entries: + - model: authentik_core.group + id: ulti-admins-group + identifiers: + name: ulti-admins + attrs: + name: ulti-admins + is_superuser: false + - model: authentik_providers_oauth2.scopemapping id: ulti-suite-groups-mapping identifiers: @@ -14,15 +22,18 @@ entries: scope_name: profile description: Suite RBAC groups for Ultimail API expression: | - return { - "groups": [ - "role:user", - "contacts:write", - "calendar:write", - "drive:write", - "photos:write", - ], - } + groups = [ + "role:user", + "contacts:write", + "calendar:write", + "drive:write", + "photos:write", + ] + for group in user.ak_groups.all(): + if group.name == "ulti-admins": + groups.extend(["admin", "admin:write"]) + break + return {"groups": groups} - model: authentik_providers_oauth2.oauth2provider identifiers: diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index 579cffa..fcfd61e 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -254,6 +254,20 @@ server { proxy_set_header Connection $connection_upgrade; } + # Console d'administration suite + location ^~ /admin { + resolver 127.0.0.11 valid=10s ipv6=off; + set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; + proxy_pass http://$mail_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } + location ^~ /_next/ { resolver 127.0.0.11 valid=10s ipv6=off; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; diff --git a/internal/api/admin/handlers.go b/internal/api/admin/handlers.go index 8d00f01..393c9f1 100644 --- a/internal/api/admin/handlers.go +++ b/internal/api/admin/handlers.go @@ -14,8 +14,11 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/securityaudit" + platformusers "github.com/ultisuite/ulti-backend/internal/users" ) type Handler struct { @@ -23,9 +26,9 @@ type Handler struct { logger *slog.Logger } -func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger) *Handler { +func NewHandler(db *pgxpool.Pool, audit *securityaudit.Logger, cfg *config.Config, nc *nextcloud.Client) *Handler { return &Handler{ - svc: NewService(db, audit), + svc: NewService(db, audit, cfg, nc), logger: slog.Default().With("component", "admin-api"), } } @@ -44,12 +47,19 @@ func (h *Handler) Routes() chi.Router { r.With(write).Post("/users/{userID}/disable", h.DisableUser) r.With(write).Post("/users/{userID}/reactivate", h.ReactivateUser) r.With(write).Put("/users/{userID}/quota", h.SetQuota) + r.With(write).Put("/users/{userID}/role", h.SetUserRole) r.With(write).Delete("/users/{userID}", h.DeleteUser) r.With(read).Get("/audit", h.ListAuditLogs) r.With(read).Get("/audit/export", h.ExportAuditLogs) r.With(read).Get("/stats", h.GetStats) + r.With(read).Get("/public-shares", h.ListPublicShares) + r.With(write).Delete("/public-shares/{shareID}", h.RevokePublicShare) + + r.With(read).Get("/org/settings", h.GetOrgSettings) + r.With(write).Put("/org/settings", h.PutOrgSettings) + return r } @@ -64,9 +74,15 @@ func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) { apivalidate.WriteValidationError(w, r, verr) return } + role, verr := validateAccountRoleFilter(r.URL.Query().Get("role")) + if verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } result, err := h.svc.ListUsers(r.Context(), params, UserFilter{ Status: status, + Role: role, Q: strings.TrimSpace(params.Q), }) if err != nil { @@ -169,6 +185,42 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { apiresponse.WriteJSON(w, http.StatusOK, user) } +func (h *Handler) SetUserRole(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "userID") + if verr := validateUserID(userID); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + claims := middleware.ClaimsFromContext(r.Context()) + + var req setUserRoleRequest + if err := apivalidate.DecodeJSON(w, r, maxQuotaRequestBody, &req); err != nil { + return + } + if verr := validateSetUserRole(&req); verr != nil { + apivalidate.WriteValidationError(w, r, verr) + return + } + + user, err := h.svc.SetUserRole(r.Context(), claims.Sub, userID, req.Role) + if err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + if errors.Is(err, platformusers.ErrLastPlatformAdmin) { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "role", Message: "cannot remove the last platform admin"}, + )) + return + } + h.logger.Error("set user role", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, user) +} + func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if verr := validateUserID(userID); verr != nil { @@ -305,3 +357,37 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { } apiresponse.WriteJSON(w, http.StatusOK, stats) } + +func (h *Handler) GetOrgSettings(w http.ResponseWriter, r *http.Request) { + payload, err := h.svc.GetOrgSettings(r.Context()) + if err != nil { + h.logger.Error("get org settings", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, payload) +} + +func (h *Handler) PutOrgSettings(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + + var req map[string]any + if err := apivalidate.DecodeJSON(w, r, 1<<20, &req); err != nil { + return + } + patch, ok := req["policy"].(map[string]any) + if !ok || len(patch) == 0 { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "policy", Message: "required"}, + )) + return + } + + payload, err := h.svc.PutOrgSettings(r.Context(), claims.Sub, patch) + if err != nil { + h.logger.Error("put org settings", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, payload) +} diff --git a/internal/api/admin/org_settings.go b/internal/api/admin/org_settings.go new file mode 100644 index 0000000..e7a11ae --- /dev/null +++ b/internal/api/admin/org_settings.go @@ -0,0 +1,520 @@ +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 +} diff --git a/internal/api/admin/org_settings_env.go b/internal/api/admin/org_settings_env.go new file mode 100644 index 0000000..d2fa50e --- /dev/null +++ b/internal/api/admin/org_settings_env.go @@ -0,0 +1,109 @@ +package admin + +import ( + "os" + "strings" + + "github.com/ultisuite/ulti-backend/internal/config" +) + +type envVarSpec struct { + Name string + Group string + Secret bool +} + +var orgEnvVarSpecs = []envVarSpec{ + // Authentik / OIDC + {Name: "ULTID_OIDC_ISSUER", Group: "authentik", Secret: false}, + {Name: "ULTID_OIDC_CLIENT_ID", Group: "authentik", Secret: false}, + {Name: "ULTID_OIDC_CLIENT_SECRET", Group: "authentik", Secret: true}, + {Name: "AUTHENTIK_API_URL", Group: "authentik", Secret: false}, + {Name: "AUTHENTIK_API_TOKEN", Group: "authentik", Secret: true}, + // Nextcloud + {Name: "NEXTCLOUD_ENABLED", Group: "nextcloud", Secret: false}, + {Name: "NEXTCLOUD_URL", Group: "nextcloud", Secret: false}, + {Name: "NC_PUBLIC_URL", Group: "nextcloud", Secret: false}, + {Name: "NC_ADMIN_USER", Group: "nextcloud", Secret: false}, + {Name: "NC_ADMIN_PASSWORD", Group: "nextcloud", Secret: true}, + // OnlyOffice + {Name: "ONLYOFFICE_ENABLED", Group: "onlyoffice", Secret: false}, + {Name: "ONLYOFFICE_URL", Group: "onlyoffice", Secret: false}, + {Name: "ONLYOFFICE_PUBLIC_URL", Group: "onlyoffice", Secret: false}, + {Name: "ONLYOFFICE_JWT_SECRET", Group: "onlyoffice", Secret: true}, + // Search + {Name: "SEARCH_ENGINE", Group: "search", Secret: false}, + {Name: "MEILISEARCH_URL", Group: "search", Secret: false}, + {Name: "MEILISEARCH_API_KEY", Group: "search", Secret: true}, + {Name: "TYPESENSE_URL", Group: "search", Secret: false}, + {Name: "TYPESENSE_API_KEY", Group: "search", Secret: true}, + // Immich / Jitsi (docker compose modules) + {Name: "IMMICH_ENABLED", Group: "immich", Secret: false}, + {Name: "IMMICH_API_URL", Group: "immich", Secret: false}, + {Name: "JITSI_ENABLED", Group: "jitsi", Secret: false}, + {Name: "JITSI_PUBLIC_URL", Group: "jitsi", Secret: false}, + {Name: "JITSI_APP_SECRET", Group: "jitsi", Secret: true}, + // Object storage (mail attachments) + {Name: "ULTID_RUSTFS_ENDPOINT", Group: "storage", Secret: false}, + {Name: "ULTID_RUSTFS_ACCESS_KEY", Group: "storage", Secret: true}, + {Name: "ULTID_RUSTFS_SECRET_KEY", Group: "storage", Secret: true}, + {Name: "MAIL_ATTACHMENTS_BUCKET", Group: "storage", Secret: false}, +} + +func buildOrgEnvVars() []map[string]any { + out := make([]map[string]any, 0, len(orgEnvVarSpecs)) + for _, spec := range orgEnvVarSpecs { + raw, set := os.LookupEnv(spec.Name) + entry := map[string]any{ + "name": spec.Name, + "group": spec.Group, + "set": set && strings.TrimSpace(raw) != "", + "secret": spec.Secret, + } + if set && !spec.Secret && strings.TrimSpace(raw) != "" { + entry["value"] = raw + } + out = append(out, entry) + } + return out +} + +func buildOrgDeployLocked(cfg *config.Config) map[string]any { + // Services deployed via Docker Compose: runtime flags come from env, not admin policy toggles. + return map[string]any{ + "nextcloud": map[string]any{ + "locked": true, + "reason": "docker_compose", + "fields": []string{"enabled", "base_url", "admin_user", "admin_password"}, + }, + "onlyoffice": map[string]any{ + "locked": true, + "reason": "docker_compose", + "fields": []string{"enabled", "document_server_url", "jwt_secret", "jwt_header"}, + }, + "search": map[string]any{ + "locked": true, + "reason": "docker_compose", + "fields": []string{"suite_engine", "meilisearch_url", "meilisearch_api_key", "typesense_url", "typesense_api_key"}, + }, + "authentik": map[string]any{ + "locked": envAuthentikLocked(cfg), + "reason": "docker_compose", + "fields": []string{"enabled", "api_url", "client_id"}, + }, + "plugins": map[string]any{ + "locked": true, + "reason": "docker_compose", + "fields": []string{"office-editor"}, + }, + } +} + +func envAuthentikLocked(cfg *config.Config) bool { + if cfg == nil { + return false + } + return strings.TrimSpace(cfg.OIDCIssuer) != "" || + strings.TrimSpace(cfg.OIDCClientID) != "" || + strings.TrimSpace(cfg.AuthentikAPIToken) != "" +} diff --git a/internal/api/admin/public_shares.go b/internal/api/admin/public_shares.go new file mode 100644 index 0000000..051b5e2 --- /dev/null +++ b/internal/api/admin/public_shares.go @@ -0,0 +1,194 @@ +package admin + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/nextcloud" + "github.com/ultisuite/ulti-backend/internal/publicshare" +) + +type PublicSharesList struct { + Shares []map[string]any `json:"shares"` + Pagination query.PaginationMeta `json:"pagination,omitempty"` +} + +func (s *Service) ListPublicShares(ctx context.Context, params query.ListParams) (PublicSharesList, error) { + if s.nc == nil { + return PublicSharesList{ + Shares: []map[string]any{}, + Pagination: params.Meta(ptrInt64(0)), + }, nil + } + + rows, err := s.db.Query(ctx, ` + SELECT email, external_id FROM users WHERE status != 'disabled' + `) + if err != nil { + return PublicSharesList{}, err + } + defer rows.Close() + + type platformUser struct { + email string + externalID string + } + users := make([]platformUser, 0) + for rows.Next() { + var u platformUser + if err := rows.Scan(&u.email, &u.externalID); err != nil { + return PublicSharesList{}, err + } + users = append(users, u) + } + if err := rows.Err(); err != nil { + return PublicSharesList{}, err + } + + q := strings.ToLower(strings.TrimSpace(params.Q)) + all := make([]map[string]any, 0) + + for _, u := range users { + ncUser := nextcloud.UserIDFromClaims(u.email, u.externalID) + if ncUser == "" { + continue + } + shares, err := s.nc.ListShares(ctx, ncUser, "") + if err != nil { + s.logger.Debug("list shares for admin audit", "nc_user", ncUser, "error", err) + continue + } + for _, share := range shares { + if !nextcloud.IsExternalShare(share) { + continue + } + if q != "" && !publicShareMatchesQuery(share, u.email, q) { + continue + } + entry := map[string]any{ + "id": share.ID, + "token": share.Token, + "path": share.Path, + "item_type": share.ItemType, + "access_mode": share.AccessMode, + "share_type": share.ShareType, + "permissions": share.Permissions, + "url": share.URL, + "expires_at": share.ExpiresAt, + "created_at": share.CreatedAt, + "owner_nc_user_id": ncUser, + "owner_email": strings.ToLower(strings.TrimSpace(u.email)), + "owner_display_name": firstNonEmptyStr(share.OwnerDisplayName, share.FileOwnerDisplayName), + "share_with": share.ShareWith, + "share_with_display_name": share.ShareWithDisplayName, + "has_password": share.HasPassword, + "label": share.Label, + } + all = append(all, entry) + } + } + + sort.Slice(all, func(i, j int) bool { + return shareCreatedAt(all[i]).After(shareCreatedAt(all[j])) + }) + + tokens := make([]string, 0, len(all)) + for _, item := range all { + if t, _ := item["token"].(string); t != "" { + tokens = append(tokens, t) + } + } + access, err := publicshare.Lookup(ctx, s.db, tokens) + if err != nil { + return PublicSharesList{}, err + } + for _, item := range all { + token, _ := item["token"].(string) + if stats, ok := access[token]; ok { + item["last_access_at"] = stats.LastAccessAt.UTC().Format(time.RFC3339) + item["access_count"] = stats.AccessCount + } else { + item["last_access_at"] = nil + item["access_count"] = int64(0) + } + } + + total := int64(len(all)) + start := params.Offset() + if start > len(all) { + start = len(all) + } + end := start + params.Limit() + if end > len(all) { + end = len(all) + } + page := all[start:end] + + return PublicSharesList{ + Shares: page, + Pagination: params.Meta(&total), + }, nil +} + +func (s *Service) RevokePublicShare(ctx context.Context, actorSub, shareID, ownerNCUserID string) error { + if s.nc == nil { + return ErrNotFound + } + shareID = strings.TrimSpace(shareID) + ownerNCUserID = strings.TrimSpace(ownerNCUserID) + if shareID == "" || ownerNCUserID == "" { + return ErrNotFound + } + if err := s.nc.DeleteShare(ctx, ownerNCUserID, shareID); err != nil { + return err + } + s.logAudit(ctx, actorSub, "revoke_public_share", map[string]any{ + "share_id": shareID, + "owner_nc_user_id": ownerNCUserID, + }) + return nil +} + +func publicShareMatchesQuery(share nextcloud.ShareInfo, ownerEmail, q string) bool { + haystack := strings.ToLower(strings.Join([]string{ + share.Path, + share.Token, + share.URL, + share.ShareWith, + share.ShareWithDisplayName, + share.OwnerDisplayName, + share.FileOwnerDisplayName, + ownerEmail, + share.Label, + share.Note, + }, " ")) + return strings.Contains(haystack, q) +} + +func shareCreatedAt(item map[string]any) time.Time { + raw, _ := item["created_at"].(string) + if raw == "" { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{} + } + return t +} + +func firstNonEmptyStr(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func ptrInt64(v int64) *int64 { + return &v +} diff --git a/internal/api/admin/public_shares_handlers.go b/internal/api/admin/public_shares_handlers.go new file mode 100644 index 0000000..b38cefc --- /dev/null +++ b/internal/api/admin/public_shares_handlers.go @@ -0,0 +1,59 @@ +package admin + +import ( + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/api/query" +) + +func (h *Handler) ListPublicShares(w http.ResponseWriter, r *http.Request) { + params, err := query.ParseListRequest(r) + if err != nil { + apivalidate.WriteQueryError(w, r, err) + return + } + + result, err := h.svc.ListPublicShares(r.Context(), params) + if err != nil { + h.logger.Error("list public shares", "error", err) + apivalidate.WriteInternal(w, r) + return + } + apiresponse.WriteJSON(w, http.StatusOK, result) +} + +func (h *Handler) RevokePublicShare(w http.ResponseWriter, r *http.Request) { + shareID := strings.TrimSpace(chi.URLParam(r, "shareID")) + if shareID == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "shareID", Message: "required"}, + )) + return + } + claims := middleware.ClaimsFromContext(r.Context()) + owner := strings.TrimSpace(r.URL.Query().Get("owner_nc_user_id")) + if owner == "" { + apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError( + apivalidate.FieldDetail{Field: "owner_nc_user_id", Message: "required"}, + )) + return + } + + if err := h.svc.RevokePublicShare(r.Context(), claims.Sub, shareID, owner); err != nil { + if errors.Is(err, ErrNotFound) { + apivalidate.WriteNotFound(w, r, "not found") + return + } + h.logger.Error("revoke public share", "error", err, "share_id", shareID) + apivalidate.WriteInternal(w, r) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/admin/service.go b/internal/api/admin/service.go index cbd3d05..b155093 100644 --- a/internal/api/admin/service.go +++ b/internal/api/admin/service.go @@ -17,7 +17,11 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/ultisuite/ulti-backend/internal/api/query" + "github.com/ultisuite/ulti-backend/internal/config" + "github.com/ultisuite/ulti-backend/internal/nextcloud" + "github.com/ultisuite/ulti-backend/internal/permission" "github.com/ultisuite/ulti-backend/internal/securityaudit" + platformusers "github.com/ultisuite/ulti-backend/internal/users" ) var ErrNotFound = errors.New("not found") @@ -25,13 +29,17 @@ var ErrNotFound = errors.New("not found") type Service struct { db *pgxpool.Pool audit *securityaudit.Logger + cfg *config.Config + nc *nextcloud.Client logger *slog.Logger } -func NewService(db *pgxpool.Pool, audit *securityaudit.Logger) *Service { +func NewService(db *pgxpool.Pool, audit *securityaudit.Logger, cfg *config.Config, nc *nextcloud.Client) *Service { return &Service{ db: db, audit: audit, + cfg: cfg, + nc: nc, logger: slog.Default().With("component", "admin-service"), } } @@ -43,6 +51,7 @@ type UsersList struct { type UserFilter struct { Status string + Role string Q string } @@ -56,7 +65,7 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter } listSQL := ` - SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at + SELECT id, external_id, email, name, status, platform_admin, invited_at, disabled_at, created_at, updated_at FROM users` + whereSQL + ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2) @@ -70,27 +79,18 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter users := make([]map[string]any, 0) for rows.Next() { - var id, extID, email, name, status string - var invitedAt, disabledAt *time.Time - var createdAt, updatedAt time.Time - if err := rows.Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil { + user, err := scanUserRow(rows) + if err != nil { return UsersList{}, err } - users = append(users, map[string]any{ - "id": id, - "external_id": extID, - "email": email, - "name": name, - "status": status, - "invited_at": invitedAt, - "disabled_at": disabledAt, - "created_at": createdAt, - "updated_at": updatedAt, - }) + users = append(users, user) } if err := rows.Err(); err != nil { return UsersList{}, err } + if err := s.attachUsersStorage(ctx, users); err != nil { + return UsersList{}, err + } return UsersList{ Users: users, @@ -99,8 +99,20 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter } func buildUserFilter(filter UserFilter) (string, []any) { - clauses := make([]string, 0, 2) - args := make([]any, 0, 2) + clauses := make([]string, 0, 3) + args := make([]any, 0, 3) + if role := strings.TrimSpace(filter.Role); role != "" { + switch permission.AccountRole(role) { + case permission.AccountRoleAdmin: + clauses = append(clauses, "platform_admin = true AND status = 'active'") + case permission.AccountRoleUser: + clauses = append(clauses, "platform_admin = false AND status = 'active'") + case permission.AccountRoleGuest: + clauses = append(clauses, "status = 'invited'") + case permission.AccountRoleSuspended: + clauses = append(clauses, "status = 'disabled'") + } + } if strings.TrimSpace(filter.Status) != "" { args = append(args, strings.TrimSpace(filter.Status)) clauses = append(clauses, "status = $"+strconv.Itoa(len(args))) @@ -118,27 +130,19 @@ func buildUserFilter(filter UserFilter) (string, []any) { } func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) { - var id, extID, email, name, status string - var invitedAt, disabledAt *time.Time - var createdAt, updatedAt time.Time - err := s.db.QueryRow(ctx, ` - SELECT id, external_id, email, name, status, invited_at, disabled_at, created_at, updated_at + row := s.db.QueryRow(ctx, ` + SELECT id, external_id, email, name, status, platform_admin, invited_at, disabled_at, created_at, updated_at FROM users WHERE id = $1 - `, userID).Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt) + `, userID) + user, err := scanUserRow(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } - - var mailCount, mailUsedStorage int64 - if err := s.db.QueryRow(ctx, ` - SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(COALESCE(m.raw_size, 0)), 0) - FROM messages m - JOIN mail_accounts ma ON m.account_id = ma.id - WHERE ma.user_id = $1 - `, userID).Scan(&mailCount, &mailUsedStorage); err != nil { + mailCount, mailUsedStorage, err := s.mailUsageForUser(ctx, userID) + if err != nil { return nil, err } @@ -153,34 +157,136 @@ func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, e return nil, err } - return map[string]any{ - "id": id, - "external_id": extID, - "email": email, - "name": name, - "status": status, - "invited_at": invitedAt, - "disabled_at": disabledAt, - "created_at": createdAt, - "updated_at": updatedAt, - "quota": map[string]any{ + email, _ := user["email"].(string) + extID, _ := user["external_id"].(string) + driveUsedStorage := s.driveUsageForUser(ctx, email, extID) + + user["quota"] = map[string]any{ "mail": map[string]any{ "count": mailCount, "used_storage_bytes": mailUsedStorage, "max_storage_bytes": mailMax, }, "drive": map[string]any{ - "used_storage_bytes": int64(0), + "used_storage_bytes": driveUsedStorage, "max_storage_bytes": driveMax, }, "photos": map[string]any{ "used_storage_bytes": int64(0), "max_storage_bytes": photosMax, }, - }, + } + return user, nil +} + +type userRowScanner interface { + Scan(dest ...any) error +} + +func scanUserRow(row userRowScanner) (map[string]any, error) { + var id, extID, email, name, status string + var platformAdmin bool + var invitedAt, disabledAt *time.Time + var createdAt, updatedAt time.Time + if err := row.Scan(&id, &extID, &email, &name, &status, &platformAdmin, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil { + return nil, err + } + return map[string]any{ + "id": id, + "external_id": extID, + "email": email, + "name": name, + "status": status, + "platform_admin": platformAdmin, + "role": string(permission.DeriveAccountRole(platformAdmin, status)), + "invited_at": invitedAt, + "disabled_at": disabledAt, + "created_at": createdAt, + "updated_at": updatedAt, }, nil } +func (s *Service) SetUserRole(ctx context.Context, actorSub, userID, role string) (map[string]any, error) { + accountRole, ok := permission.ParseAccountRole(role) + if !ok { + return nil, fmt.Errorf("invalid role") + } + + var extID string + var currentAdmin bool + var currentStatus string + err := s.db.QueryRow(ctx, ` + SELECT external_id, platform_admin, status FROM users WHERE id = $1 + `, userID).Scan(&extID, ¤tAdmin, ¤tStatus) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if currentAdmin && accountRole != permission.AccountRoleAdmin { + count, err := platformusers.CountPlatformAdmins(ctx, s.db) + if err != nil { + return nil, err + } + if count <= 1 { + return nil, platformusers.ErrLastPlatformAdmin + } + } + + switch accountRole { + case permission.AccountRoleAdmin: + _, err = s.db.Exec(ctx, ` + UPDATE users + SET platform_admin = true, + status = 'active', + disabled_at = NULL, + updated_at = NOW() + WHERE id = $1 + `, userID) + case permission.AccountRoleUser: + _, err = s.db.Exec(ctx, ` + UPDATE users + SET platform_admin = false, + status = 'active', + disabled_at = NULL, + updated_at = NOW() + WHERE id = $1 + `, userID) + case permission.AccountRoleGuest: + _, err = s.db.Exec(ctx, ` + UPDATE users + SET platform_admin = false, + status = 'invited', + invited_at = COALESCE(invited_at, NOW()), + disabled_at = NULL, + updated_at = NOW() + WHERE id = $1 + `, userID) + case permission.AccountRoleSuspended: + _, err = s.db.Exec(ctx, ` + UPDATE users + SET platform_admin = false, + status = 'disabled', + disabled_at = NOW(), + updated_at = NOW() + WHERE id = $1 + `, userID) + } + if err != nil { + return nil, err + } + + s.logAudit(ctx, actorSub, "set_user_role", map[string]any{ + "target_user": userID, + "role": accountRole, + "from_status": currentStatus, + "from_admin": currentAdmin, + }) + return s.GetUser(ctx, userID) +} + func (s *Service) CreateUser(ctx context.Context, actorSub string, req createUserRequest) (map[string]any, error) { var id string if err := s.db.QueryRow(ctx, ` @@ -196,6 +302,9 @@ func (s *Service) CreateUser(ctx context.Context, actorSub string, req createUse "email": strings.ToLower(strings.TrimSpace(req.Email)), "initial_name": strings.TrimSpace(req.Name), }) + if err := s.applyOrgDefaultQuotas(ctx, id); err != nil { + return nil, err + } return s.GetUser(ctx, id) } @@ -213,6 +322,9 @@ func (s *Service) InviteUser(ctx context.Context, actorSub string, req inviteUse "target_user": id, "email": strings.ToLower(strings.TrimSpace(req.Email)), }) + if err := s.applyOrgDefaultQuotas(ctx, id); err != nil { + return nil, err + } return s.GetUser(ctx, id) } @@ -530,11 +642,20 @@ func (s *Service) GetStats(ctx context.Context) (map[string]any, error) { SELECT COUNT(*) FROM ( SELECT u.id, - COALESCE(SUM(COALESCE(m.raw_size, 0)), 0) AS used_storage, + COALESCE(SUM( + octet_length(COALESCE(m.body_text, '')) + + octet_length(COALESCE(m.body_html, '')) + + COALESCE(att.attachment_bytes, 0) + ), 0) AS used_storage, COALESCE((s.preferences->>'mail_max_storage_bytes')::bigint, 5368709120) AS max_storage FROM users u LEFT JOIN mail_accounts ma ON ma.user_id = u.id LEFT JOIN messages m ON m.account_id = ma.id + LEFT JOIN ( + SELECT message_id, SUM(size) AS attachment_bytes + FROM attachments + GROUP BY message_id + ) att ON att.message_id = m.id LEFT JOIN settings s ON s.user_id = u.id GROUP BY u.id, s.preferences ) q @@ -557,12 +678,202 @@ func (s *Service) GetStats(ctx context.Context) (map[string]any, error) { stats["quotas"] = map[string]any{ "users_near_mail_quota_90pct": usersNearMailQuota, } + + storage, err := s.globalStorageStats(ctx) + if err != nil { + return nil, err + } + stats["storage"] = storage + stats["audit"] = map[string]any{ "top_actors_7d": topActors, } return stats, nil } +const defaultQuotaBytes int64 = 5368709120 + +func (s *Service) globalStorageStats(ctx context.Context) (map[string]any, error) { + var mailUsed int64 + if err := s.db.QueryRow(ctx, ` + SELECT COALESCE(SUM( + octet_length(COALESCE(m.body_text, '')) + + octet_length(COALESCE(m.body_html, '')) + + COALESCE(att.attachment_bytes, 0) + ), 0) + FROM messages m + LEFT JOIN ( + SELECT message_id, SUM(size) AS attachment_bytes + FROM attachments + GROUP BY message_id + ) att ON att.message_id = m.id + `).Scan(&mailUsed); err != nil { + return nil, err + } + + var mailAllocated, driveAllocated, photosAllocated int64 + if err := s.db.QueryRow(ctx, ` + SELECT + COALESCE(SUM(COALESCE((s.preferences->>'mail_max_storage_bytes')::bigint, $1)), 0), + COALESCE(SUM(COALESCE((s.preferences->>'drive_max_storage_bytes')::bigint, $1)), 0), + COALESCE(SUM(COALESCE((s.preferences->>'photos_max_storage_bytes')::bigint, $1)), 0) + FROM users u + LEFT JOIN settings s ON s.user_id = u.id + `, defaultQuotaBytes).Scan(&mailAllocated, &driveAllocated, &photosAllocated); err != nil { + return nil, err + } + + driveUsed := s.sumDriveUsageAllUsers(ctx) + + return map[string]any{ + "mail": map[string]any{ + "used_bytes": mailUsed, + "allocated_bytes": mailAllocated, + }, + "drive": map[string]any{ + "used_bytes": driveUsed, + "allocated_bytes": driveAllocated, + "tracked": s.nc != nil, + }, + "photos": map[string]any{ + "used_bytes": int64(0), + "allocated_bytes": photosAllocated, + "tracked": false, + }, + }, nil +} + +func (s *Service) sumDriveUsageAllUsers(ctx context.Context) int64 { + if s.nc == nil { + return 0 + } + rows, err := s.db.Query(ctx, ` + SELECT email, external_id FROM users WHERE status != 'disabled' + `) + if err != nil { + s.logger.Debug("drive usage aggregate failed", "error", err) + return 0 + } + defer rows.Close() + + var total int64 + for rows.Next() { + var email, externalID string + if err := rows.Scan(&email, &externalID); err != nil { + continue + } + total += s.driveUsageForUser(ctx, email, externalID) + } + return total +} + +func (s *Service) attachUsersStorage(ctx context.Context, users []map[string]any) error { + if len(users) == 0 { + return nil + } + ids := make([]string, 0, len(users)) + byID := make(map[string]map[string]any, len(users)) + for _, user := range users { + id, _ := user["id"].(string) + if id == "" { + continue + } + ids = append(ids, id) + byID[id] = user + } + mailUsed, err := s.mailUsageByUserIDs(ctx, ids) + if err != nil { + return err + } + for id, user := range byID { + email, _ := user["email"].(string) + extID, _ := user["external_id"].(string) + user["storage"] = map[string]any{ + "mail_used_bytes": mailUsed[id], + "drive_used_bytes": s.driveUsageForUser(ctx, email, extID), + } + } + return nil +} + +func (s *Service) mailUsageByUserIDs(ctx context.Context, userIDs []string) (map[string]int64, error) { + out := make(map[string]int64, len(userIDs)) + if len(userIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` + SELECT + ma.user_id::text, + COALESCE(SUM( + octet_length(COALESCE(m.body_text, '')) + + octet_length(COALESCE(m.body_html, '')) + + COALESCE(att.attachment_bytes, 0) + ), 0) + FROM mail_accounts ma + LEFT JOIN messages m ON m.account_id = ma.id + LEFT JOIN ( + SELECT message_id, SUM(size) AS attachment_bytes + FROM attachments + GROUP BY message_id + ) att ON att.message_id = m.id + WHERE ma.user_id = ANY($1::uuid[]) + GROUP BY ma.user_id + `, userIDs) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var userID string + var used int64 + if err := rows.Scan(&userID, &used); err != nil { + return nil, err + } + out[userID] = used + } + return out, rows.Err() +} + +func (s *Service) mailUsageForUser(ctx context.Context, userID string) (count, bytes int64, err error) { + err = s.db.QueryRow(ctx, ` + SELECT + COALESCE(COUNT(m.id), 0), + COALESCE(SUM( + octet_length(COALESCE(m.body_text, '')) + + octet_length(COALESCE(m.body_html, '')) + + COALESCE(att.attachment_bytes, 0) + ), 0) + FROM messages m + JOIN mail_accounts ma ON m.account_id = ma.id + LEFT JOIN ( + SELECT message_id, SUM(size) AS attachment_bytes + FROM attachments + GROUP BY message_id + ) att ON att.message_id = m.id + WHERE ma.user_id = $1 + `, userID).Scan(&count, &bytes) + return count, bytes, err +} + +func (s *Service) driveUsageForUser(ctx context.Context, email, externalID string) int64 { + if s.nc == nil { + return 0 + } + ncUserID := nextcloud.UserIDFromClaims(email, externalID) + if ncUserID == "" { + return 0 + } + quota, err := s.nc.GetQuota(ctx, ncUserID) + if err != nil { + s.logger.Debug("drive quota lookup failed", "nc_user", ncUserID, "error", err) + return 0 + } + if quota.Used < 0 { + return 0 + } + return quota.Used +} + func (s *Service) logAudit(ctx context.Context, actor, action string, details map[string]any) { if s.audit == nil { return diff --git a/internal/api/admin/validate.go b/internal/api/admin/validate.go index 00986d9..e9455a8 100644 --- a/internal/api/admin/validate.go +++ b/internal/api/admin/validate.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/permission" ) const maxQuotaRequestBody = 4 << 10 @@ -126,3 +127,31 @@ func validateUserID(userID string) *apivalidate.ValidationError { } return nil } + +type setUserRoleRequest struct { + Role string `json:"role"` +} + +func validateSetUserRole(req *setUserRoleRequest) *apivalidate.ValidationError { + if _, ok := permission.ParseAccountRole(req.Role); !ok { + return apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "role", + Message: "must be one of: admin,user,guest,suspended", + }) + } + return nil +} + +func validateAccountRoleFilter(raw string) (string, *apivalidate.ValidationError) { + role := strings.ToLower(strings.TrimSpace(raw)) + if role == "" { + return "", nil + } + if _, ok := permission.ParseAccountRole(role); !ok { + return "", apivalidate.NewValidationError(apivalidate.FieldDetail{ + Field: "role", + Message: "must be one of: admin,user,guest,suspended", + }) + } + return role, nil +} diff --git a/internal/api/drive/public_service.go b/internal/api/drive/public_service.go index 73a2ac6..3952bd4 100644 --- a/internal/api/drive/public_service.go +++ b/internal/api/drive/public_service.go @@ -17,7 +17,11 @@ func (s *Service) UploadPublicShare(ctx context.Context, token, filePath, passwo if !nextcloud.PublicShareCanCreate(perms) && !nextcloud.PublicShareCanUpdate(perms) { return ErrForbidden } - return mapPublicShareError(s.nc.UploadPublicShare(ctx, token, filePath, password, body, contentType)) + if err := mapPublicShareError(s.nc.UploadPublicShare(ctx, token, filePath, password, body, contentType)); err != nil { + return err + } + s.recordPublicShareAccess(ctx, token) + return nil } func (s *Service) CreatePublicShareFolder(ctx context.Context, token, folderPath, password string) error { diff --git a/internal/api/drive/service.go b/internal/api/drive/service.go index c8d768c..0b05f69 100644 --- a/internal/api/drive/service.go +++ b/internal/api/drive/service.go @@ -20,6 +20,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/automation" "github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/nextcloud" + "github.com/ultisuite/ulti-backend/internal/publicshare" "github.com/ultisuite/ulti-backend/internal/realtime" ) @@ -362,6 +363,7 @@ func (s *Service) GetPublicShare(ctx context.Context, token, path, password stri if err != nil { return nil, mapPublicShareError(err) } + s.recordPublicShareAccess(ctx, token) return view, nil } @@ -370,6 +372,7 @@ func (s *Service) DownloadPublicShare(ctx context.Context, token, filePath, pass if err != nil { return nil, "", mapPublicShareError(err) } + s.recordPublicShareAccess(ctx, token) return body, contentType, nil } @@ -378,9 +381,14 @@ func (s *Service) PreviewPublicShare(ctx context.Context, token, filePath, passw if err != nil { return nil, "", mapPublicShareError(err) } + s.recordPublicShareAccess(ctx, token) return body, contentType, nil } +func (s *Service) recordPublicShareAccess(ctx context.Context, token string) { + publicshare.RecordAccess(ctx, s.db, token) +} + func (s *Service) rewriteShareURL(share *nextcloud.ShareInfo) { if share == nil || s.nc == nil { return diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index 94c44cf..2e078c1 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -118,14 +118,17 @@ func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger) } return } - claims.Groups = permission.WithSuiteDefaults(claims.Groups) - if db != nil { if _, err := users.EnsureUser(r.Context(), db, claims); err != nil { slog.Error("provision user", "sub", claims.Sub, "error", err) apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision user", nil) return } + if err := users.ApplyAccountGroups(r.Context(), db, claims); err != nil { + slog.Error("apply account groups", "sub", claims.Sub, "error", err) + apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to read user privileges", nil) + return + } var disabled bool if err := db.QueryRow(r.Context(), ` SELECT status = 'disabled' FROM users WHERE external_id = $1 @@ -145,6 +148,8 @@ func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger) } return } + } else { + claims.Groups = permission.WithSuiteDefaults(claims.Groups) } if audit != nil { diff --git a/internal/api/middleware/rbac.go b/internal/api/middleware/rbac.go index 4f3d6af..a6fd65a 100644 --- a/internal/api/middleware/rbac.go +++ b/internal/api/middleware/rbac.go @@ -45,6 +45,26 @@ func RequirePermission(resource permission.Resource, level permission.Level) fun } } +// RequireFullAccount blocks guest (invited) accounts from full-suite modules. +func RequireFullAccount(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ApiTokenFromContext(r.Context()) != nil { + next.ServeHTTP(w, r) + return + } + claims := ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + if permission.HasRole(claims.Groups, permission.RoleGuest) { + apiresponse.WriteError(w, r, http.StatusForbidden, apiresponse.CodeAuthForbidden, "guest accounts may only access shared drive content", nil) + return + } + next.ServeHTTP(w, r) + }) +} + func RequireAdminScope(scope permission.AdminScope) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/users/handlers.go b/internal/api/users/handlers.go new file mode 100644 index 0000000..adeef83 --- /dev/null +++ b/internal/api/users/handlers.go @@ -0,0 +1,59 @@ +package users + +import ( + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/api/apiresponse" + "github.com/ultisuite/ulti-backend/internal/api/apivalidate" + "github.com/ultisuite/ulti-backend/internal/api/middleware" + "github.com/ultisuite/ulti-backend/internal/permission" + platformusers "github.com/ultisuite/ulti-backend/internal/users" +) + +type Handler struct { + db *pgxpool.Pool + logger *slog.Logger +} + +func NewHandler(db *pgxpool.Pool) *Handler { + return &Handler{ + db: db, + logger: slog.Default().With("component", "users-api"), + } +} + +func (h *Handler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/me", h.Me) + return r +} + +func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + claims := middleware.ClaimsFromContext(r.Context()) + if claims == nil { + apiresponse.WriteError(w, r, http.StatusUnauthorized, apiresponse.CodeAuthUnauthorized, "unauthorized", nil) + return + } + + state, err := platformusers.GetAccountState(r.Context(), h.db, claims.Sub) + if err != nil { + h.logger.Error("read account state", "error", err) + apivalidate.WriteInternal(w, r) + return + } + role := permission.DeriveAccountRole(state.PlatformAdmin, state.Status) + + apiresponse.WriteJSON(w, http.StatusOK, map[string]any{ + "sub": claims.Sub, + "email": claims.Email, + "name": claims.Name, + "status": state.Status, + "platform_admin": state.PlatformAdmin, + "role": role, + "groups": claims.Groups, + }) +} diff --git a/internal/integrationtest/admin/org_settings_test.go b/internal/integrationtest/admin/org_settings_test.go new file mode 100644 index 0000000..8886aae --- /dev/null +++ b/internal/integrationtest/admin/org_settings_test.go @@ -0,0 +1,59 @@ +//go:build integration + +package admin_test + +import ( + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" +) + +func TestAdminOrgSettings(t *testing.T) { + h := integrationtest.RequireHarness(t) + adminClient, _ := integrationtest.RequireAdminClient(t, h) + + getResp, err := adminClient.Get("/api/v1/admin/org/settings") + integrationtest.FailIf(err, t, "get org settings") + integrationtest.FailUnlessStatus(t, getResp, 200) + + var initial map[string]any + integrationtest.DecodeJSON(t, getResp, &initial) + policy, ok := initial["policy"].(map[string]any) + if !ok { + t.Fatalf("missing policy: %#v", initial) + } + storage, ok := policy["storage_quotas"].(map[string]any) + if !ok { + t.Fatalf("missing storage_quotas: %#v", policy) + } + if storage["default_mail_gib"] == nil { + t.Fatalf("expected default_mail_gib in policy") + } + + putResp, err := adminClient.Put("/api/v1/admin/org/settings", map[string]any{ + "policy": map[string]any{ + "storage_quotas": map[string]any{ + "default_mail_gib": 10, + }, + "usage_quotas": map[string]any{ + "llm_requests_per_day": 200, + }, + }, + }) + integrationtest.FailIf(err, t, "put org settings") + integrationtest.FailUnlessStatus(t, putResp, 200) + + var updated map[string]any + integrationtest.DecodeJSON(t, putResp, &updated) + updatedPolicy, ok := updated["policy"].(map[string]any) + if !ok { + t.Fatalf("missing policy after update: %#v", updated) + } + updatedStorage, ok := updatedPolicy["storage_quotas"].(map[string]any) + if !ok { + t.Fatalf("missing storage_quotas after update") + } + if updatedStorage["default_mail_gib"] != float64(10) { + t.Fatalf("default_mail_gib = %v, want 10", updatedStorage["default_mail_gib"]) + } +} diff --git a/internal/integrationtest/auth/first_admin_test.go b/internal/integrationtest/auth/first_admin_test.go new file mode 100644 index 0000000..bd78255 --- /dev/null +++ b/internal/integrationtest/auth/first_admin_test.go @@ -0,0 +1,66 @@ +//go:build integration + +package auth_test + +import ( + "context" + "testing" + + "github.com/ultisuite/ulti-backend/internal/integrationtest" + "github.com/ultisuite/ulti-backend/internal/users" +) + +func TestPlatformAdminAccessAfterGrant(t *testing.T) { + h := integrationtest.RequireHarness(t) + externalID := integrationtest.NewExternalID("plat-admin") + claims := integrationtest.RegularUser(externalID) + if _, err := users.EnsureUser(context.Background(), h.Pool, claims); err != nil { + t.Fatalf("ensure user: %v", err) + } + if err := users.GrantPlatformAdmin(context.Background(), h.Pool, externalID); err != nil { + t.Fatalf("grant platform admin: %v", err) + } + + client, err := h.Client(claims) + integrationtest.FailIf(err, t, "client") + + meResp, err := client.Get("/api/v1/users/me") + integrationtest.FailIf(err, t, "GET /users/me") + integrationtest.FailUnlessStatus(t, meResp, 200) + + var me map[string]any + integrationtest.DecodeJSON(t, meResp, &me) + if me["platform_admin"] != true { + t.Fatalf("platform_admin = %v, want true", me["platform_admin"]) + } + + statsResp, err := client.Get("/api/v1/admin/stats") + integrationtest.FailIf(err, t, "GET /admin/stats") + integrationtest.FailUnlessStatus(t, statsResp, 200) +} + +func TestFirstProvisionedUserBecomesPlatformAdmin(t *testing.T) { + h := integrationtest.RequireHarness(t) + var count int64 + if err := h.Pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + t.Fatalf("count users: %v", err) + } + if count > 0 { + t.Skip("database already has users; first-user bootstrap not testable here") + } + + externalID := integrationtest.NewExternalID("bootstrap-admin") + claims := integrationtest.RegularUser(externalID) + client, err := h.Client(claims) + integrationtest.FailIf(err, t, "client") + + meResp, err := client.Get("/api/v1/users/me") + integrationtest.FailIf(err, t, "GET /users/me") + integrationtest.FailUnlessStatus(t, meResp, 200) + + var me map[string]any + integrationtest.DecodeJSON(t, meResp, &me) + if me["platform_admin"] != true { + t.Fatalf("platform_admin = %v, want true", me["platform_admin"]) + } +} diff --git a/internal/nextcloud/drive.go b/internal/nextcloud/drive.go index 2a71c75..cc75935 100644 --- a/internal/nextcloud/drive.go +++ b/internal/nextcloud/drive.go @@ -473,7 +473,11 @@ func pathBaseName(raw string) string { } func (c *Client) ListShares(ctx context.Context, userID, filePath string) ([]ShareInfo, error) { - path := "/ocs/v2.php/apps/files_sharing/api/v1/shares?path=" + url.QueryEscape(filePath) + apiPath := "/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json" + if strings.TrimSpace(filePath) != "" { + apiPath += "&path=" + url.QueryEscape(filePath) + } + path := apiPath resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, map[string]string{ "Accept": "application/json", }) diff --git a/internal/nextcloud/share_filter.go b/internal/nextcloud/share_filter.go new file mode 100644 index 0000000..756dfd0 --- /dev/null +++ b/internal/nextcloud/share_filter.go @@ -0,0 +1,21 @@ +package nextcloud + +import "strings" + +// IsExternalShare reports public or email link shares (not internal/user/group). +func IsExternalShare(share ShareInfo) bool { + switch share.ShareType { + case 4: + return true + case 3: + if strings.EqualFold(strings.TrimSpace(share.Label), "internal") { + return false + } + if strings.EqualFold(strings.TrimSpace(share.AccessMode), "internal") { + return false + } + return true + default: + return false + } +} diff --git a/internal/nextcloud/share_filter_test.go b/internal/nextcloud/share_filter_test.go new file mode 100644 index 0000000..d2f68d0 --- /dev/null +++ b/internal/nextcloud/share_filter_test.go @@ -0,0 +1,18 @@ +package nextcloud + +import "testing" + +func TestIsExternalShare(t *testing.T) { + if !IsExternalShare(ShareInfo{ShareType: 3, AccessMode: "public"}) { + t.Fatal("public link should be external") + } + if IsExternalShare(ShareInfo{ShareType: 3, Label: "internal", AccessMode: "internal"}) { + t.Fatal("internal link should not be external") + } + if !IsExternalShare(ShareInfo{ShareType: 4}) { + t.Fatal("email share should be external") + } + if IsExternalShare(ShareInfo{ShareType: 0}) { + t.Fatal("user share should not be external") + } +} diff --git a/internal/permission/permission.go b/internal/permission/permission.go index ef7749c..b76015a 100644 --- a/internal/permission/permission.go +++ b/internal/permission/permission.go @@ -8,9 +8,45 @@ type Role string const ( RoleAdmin Role = "admin" RoleUser Role = "user" + RoleGuest Role = "guest" RoleService Role = "service" ) +// AccountRole is the admin-facing lifecycle role stored as status + platform_admin. +type AccountRole string + +const ( + AccountRoleAdmin AccountRole = "admin" + AccountRoleUser AccountRole = "user" + AccountRoleGuest AccountRole = "guest" + AccountRoleSuspended AccountRole = "suspended" +) + +// DeriveAccountRole maps database fields to the admin UI role. +func DeriveAccountRole(platformAdmin bool, status string) AccountRole { + switch strings.ToLower(strings.TrimSpace(status)) { + case "disabled": + return AccountRoleSuspended + case "invited": + return AccountRoleGuest + default: + if platformAdmin { + return AccountRoleAdmin + } + return AccountRoleUser + } +} + +// ParseAccountRole validates an admin role string. +func ParseAccountRole(raw string) (AccountRole, bool) { + switch AccountRole(strings.ToLower(strings.TrimSpace(raw))) { + case AccountRoleAdmin, AccountRoleUser, AccountRoleGuest, AccountRoleSuspended: + return AccountRole(strings.ToLower(strings.TrimSpace(raw))), true + default: + return "", false + } +} + // Resource is a suite module protected by resource-scoped permissions. type Resource string @@ -120,6 +156,45 @@ func WithSuiteDefaults(groups []string) []string { return out } +// WithGuestAccess limits suite access to drive (shared + own files via Nextcloud). +// Guests must not receive suite-wide defaults (mail, contacts, calendar, photos). +func WithGuestAccess(groups []string) []string { + guestGroups := []string{ + string(RoleGuest), + string(ResourceDrive) + ":write", + } + + seen := make(map[string]struct{}, len(groups)+len(guestGroups)) + out := make([]string, 0, len(guestGroups)) + for _, g := range guestGroups { + key := strings.ToLower(strings.TrimSpace(g)) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, g) + } + return out +} + +// WithPlatformAdmin grants full platform admin groups when missing. +func WithPlatformAdmin(groups []string) []string { + if HasRole(groups, RoleAdmin) && HasAdminScope(groups, AdminScopeWrite) { + return groups + } + out := append([]string{}, groups...) + if !HasRole(groups, RoleAdmin) { + out = append(out, string(RoleAdmin)) + } + if !HasAdminScope(groups, AdminScopeWrite) { + out = append(out, GroupAdminWrite) + } + return out +} + // AdminScope is a fine-grained admin API permission with read < write ordering. type AdminScope int diff --git a/internal/permission/permission_test.go b/internal/permission/permission_test.go index 9673d1e..2f4a696 100644 --- a/internal/permission/permission_test.go +++ b/internal/permission/permission_test.go @@ -85,6 +85,37 @@ func TestWithSuiteDefaultsPreservesExplicitResource(t *testing.T) { } } +func TestDeriveAccountRole(t *testing.T) { + if got := DeriveAccountRole(true, "active"); got != AccountRoleAdmin { + t.Fatalf("admin = %q", got) + } + if got := DeriveAccountRole(false, "active"); got != AccountRoleUser { + t.Fatalf("user = %q", got) + } + if got := DeriveAccountRole(false, "invited"); got != AccountRoleGuest { + t.Fatalf("guest = %q", got) + } + if got := DeriveAccountRole(true, "disabled"); got != AccountRoleSuspended { + t.Fatalf("suspended = %q", got) + } +} + +func TestWithGuestAccess(t *testing.T) { + groups := WithGuestAccess(nil) + if !HasRole(groups, RoleGuest) { + t.Fatal("expected guest role") + } + if !HasPermission(groups, ResourceDrive, LevelWrite) { + t.Fatal("expected drive write for own uploads") + } + if HasPermission(groups, ResourceContacts, LevelRead) { + t.Fatal("guest must not get contacts") + } + if HasPermission(groups, ResourcePhotos, LevelRead) { + t.Fatal("guest must not get photos") + } +} + func TestWithSuiteDefaultsUserRoleOnly(t *testing.T) { groups := WithSuiteDefaults([]string{"role:user"}) @@ -190,6 +221,17 @@ func TestHasAdminScopePlatformAdminBypass(t *testing.T) { } } +func TestWithPlatformAdmin(t *testing.T) { + groups := WithSuiteDefaults(nil) + out := WithPlatformAdmin(groups) + if !HasRole(out, RoleAdmin) { + t.Fatal("expected admin role") + } + if !HasAdminScope(out, AdminScopeWrite) { + t.Fatal("expected admin:write scope") + } +} + func TestHasAdminScopeNoAccess(t *testing.T) { tests := []struct { name string diff --git a/internal/publicshare/access.go b/internal/publicshare/access.go new file mode 100644 index 0000000..ffac175 --- /dev/null +++ b/internal/publicshare/access.go @@ -0,0 +1,60 @@ +package publicshare + +import ( + "context" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// AccessStats tracks anonymous public link usage via Ulti API. +type AccessStats struct { + FirstAccessAt time.Time + LastAccessAt time.Time + AccessCount int64 +} + +// RecordAccess bumps usage counters for a public share token. +func RecordAccess(ctx context.Context, db *pgxpool.Pool, token string) { + if db == nil { + return + } + token = strings.TrimSpace(token) + if token == "" { + return + } + _, _ = db.Exec(ctx, ` + INSERT INTO drive_public_share_access (token, first_access_at, last_access_at, access_count) + VALUES ($1, NOW(), NOW(), 1) + ON CONFLICT (token) DO UPDATE SET + last_access_at = NOW(), + access_count = drive_public_share_access.access_count + 1 + `, token) +} + +// Lookup returns access stats keyed by token. +func Lookup(ctx context.Context, db *pgxpool.Pool, tokens []string) (map[string]AccessStats, error) { + out := make(map[string]AccessStats) + if db == nil || len(tokens) == 0 { + return out, nil + } + rows, err := db.Query(ctx, ` + SELECT token, first_access_at, last_access_at, access_count + FROM drive_public_share_access + WHERE token = ANY($1::text[]) + `, tokens) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var token string + var stats AccessStats + if err := rows.Scan(&token, &stats.FirstAccessAt, &stats.LastAccessAt, &stats.AccessCount); err != nil { + return nil, err + } + out[token] = stats + } + return out, rows.Err() +} diff --git a/internal/server/bootstrap.go b/internal/server/bootstrap.go index 4198f1f..20d50c4 100644 --- a/internal/server/bootstrap.go +++ b/internal/server/bootstrap.go @@ -27,6 +27,7 @@ import ( "github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/office" photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" + usersapi "github.com/ultisuite/ulti-backend/internal/api/users" "github.com/ultisuite/ulti-backend/internal/automation" "github.com/ultisuite/ulti-backend/internal/authentik" "github.com/ultisuite/ulti-backend/internal/auth" @@ -286,30 +287,36 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) { r.Use(middleware.Auth(verifierHolder, pool, auditLogger)) r.Use(middleware.EnforceApiTokenPolicy()) - r.Mount("/api/v1/mail", mailHandler.Routes()) - r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger).Routes()) - r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ - Nextcloud: ncClient, - Engine: cfg.SearchEngine, - MeilisearchURL: cfg.MeilisearchURL, - MeilisearchKey: cfg.MeilisearchKey, - MeilisearchIndex: cfg.MeilisearchIndex, - TypesenseURL: cfg.TypesenseURL, - TypesenseKey: cfg.TypesenseKey, - TypesenseCollection: cfg.TypesenseCollection, - }).Search) - + r.Mount("/api/v1/users", usersapi.NewHandler(pool).Routes()) + r.Mount("/api/v1/admin", admin.NewHandler(pool, auditLogger, cfg, ncClient).Routes()) if driveHandler != nil { r.Mount("/api/v1/drive", driveHandler.Routes()) - r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) - r.Mount("/api/v1/contacts", contactsHandler.Routes()) - } - if meetCfg != nil { - r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) - } - if photosClient != nil { - r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) } + + r.Group(func(r chi.Router) { + r.Use(middleware.RequireFullAccount) + r.Mount("/api/v1/mail", mailHandler.Routes()) + r.Get("/api/v1/search", search.NewHandler(pool, search.Options{ + Nextcloud: ncClient, + Engine: cfg.SearchEngine, + MeilisearchURL: cfg.MeilisearchURL, + MeilisearchKey: cfg.MeilisearchKey, + MeilisearchIndex: cfg.MeilisearchIndex, + TypesenseURL: cfg.TypesenseURL, + TypesenseKey: cfg.TypesenseKey, + TypesenseCollection: cfg.TypesenseCollection, + }).Search) + if driveHandler != nil { + r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) + r.Mount("/api/v1/contacts", contactsHandler.Routes()) + } + if meetCfg != nil { + r.Mount("/api/v1/meet", meetapi.NewHandler(meetCfg).Routes()) + } + if photosClient != nil { + r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) + } + }) }) slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) diff --git a/internal/users/admin.go b/internal/users/admin.go new file mode 100644 index 0000000..4f32da0 --- /dev/null +++ b/internal/users/admin.go @@ -0,0 +1,82 @@ +package users + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/permission" +) + +// IsPlatformAdmin reports whether the user row is marked platform_admin. +func IsPlatformAdmin(ctx context.Context, db *pgxpool.Pool, externalID string) (bool, error) { + if db == nil || externalID == "" { + return false, nil + } + var admin bool + err := db.QueryRow(ctx, ` + SELECT platform_admin FROM users WHERE external_id = $1 + `, externalID).Scan(&admin) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return admin, nil +} + +// GrantPlatformAdmin sets platform_admin for the given external_id. +func GrantPlatformAdmin(ctx context.Context, db *pgxpool.Pool, externalID string) error { + if db == nil { + return fmt.Errorf("database not configured") + } + if externalID == "" { + return fmt.Errorf("missing external id") + } + tag, err := db.Exec(ctx, ` + UPDATE users SET platform_admin = true, updated_at = NOW() + WHERE external_id = $1 + `, externalID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} + +// ApplyPlatformAdminGroups adds admin RBAC groups when the user is platform_admin. +func ApplyPlatformAdminGroups(ctx context.Context, db *pgxpool.Pool, claims *auth.Claims) error { + if claims == nil || db == nil { + return nil + } + admin, err := IsPlatformAdmin(ctx, db, claims.Sub) + if err != nil { + return err + } + if admin { + claims.Groups = permission.WithPlatformAdmin(claims.Groups) + } + return nil +} + +func bootstrapFirstUserAdmin(ctx context.Context, db *pgxpool.Pool, userID string) error { + var hasAdmin bool + if err := db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE platform_admin = true)`).Scan(&hasAdmin); err != nil { + return err + } + if hasAdmin { + return nil + } + _, err := db.Exec(ctx, ` + UPDATE users SET platform_admin = true, updated_at = NOW() + WHERE id = $1 + `, userID) + return err +} diff --git a/internal/users/provision.go b/internal/users/provision.go index 410e037..c0a6da4 100644 --- a/internal/users/provision.go +++ b/internal/users/provision.go @@ -34,18 +34,29 @@ func EnsureUser(ctx context.Context, db *pgxpool.Pool, claims *auth.Claims) (str email := ProvisionEmail(claims) name := strings.TrimSpace(claims.Name) + var userCount int64 + if err := db.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&userCount); err != nil { + return "", fmt.Errorf("count users: %w", err) + } + isFirstUser := userCount == 0 + var userID string err := db.QueryRow(ctx, ` - INSERT INTO users (external_id, email, name) - VALUES ($1, $2, $3) + INSERT INTO users (external_id, email, name, platform_admin) + VALUES ($1, $2, $3, $4) ON CONFLICT (external_id) DO UPDATE SET email = EXCLUDED.email, name = EXCLUDED.name, updated_at = NOW() RETURNING id - `, claims.Sub, email, name).Scan(&userID) + `, claims.Sub, email, name, isFirstUser).Scan(&userID) if err != nil { return "", fmt.Errorf("provision user: %w", err) } + if isFirstUser { + if err := bootstrapFirstUserAdmin(ctx, db, userID); err != nil { + return "", fmt.Errorf("bootstrap platform admin: %w", err) + } + } return userID, nil } diff --git a/internal/users/role.go b/internal/users/role.go new file mode 100644 index 0000000..1f9aca9 --- /dev/null +++ b/internal/users/role.go @@ -0,0 +1,96 @@ +package users + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/ultisuite/ulti-backend/internal/auth" + "github.com/ultisuite/ulti-backend/internal/permission" +) + +var ErrLastPlatformAdmin = errors.New("cannot remove the last platform admin") + +// AccountState is the persisted user lifecycle used for auth and admin APIs. +type AccountState struct { + Status string + PlatformAdmin bool +} + +// GetAccountState loads status and platform_admin for an external_id. +func GetAccountState(ctx context.Context, db *pgxpool.Pool, externalID string) (AccountState, error) { + if db == nil || externalID == "" { + return AccountState{Status: "active"}, nil + } + var state AccountState + err := db.QueryRow(ctx, ` + SELECT status, platform_admin FROM users WHERE external_id = $1 + `, externalID).Scan(&state.Status, &state.PlatformAdmin) + if errors.Is(err, pgx.ErrNoRows) { + return AccountState{Status: "active"}, nil + } + if err != nil { + return AccountState{}, err + } + return state, nil +} + +// ApplyAccountGroups adjusts OIDC groups from the persisted account role. +func ApplyAccountGroups(ctx context.Context, db *pgxpool.Pool, claims *auth.Claims) error { + if claims == nil || db == nil { + return nil + } + state, err := GetAccountState(ctx, db, claims.Sub) + if err != nil { + return err + } + switch permission.DeriveAccountRole(state.PlatformAdmin, state.Status) { + case permission.AccountRoleGuest: + claims.Groups = permission.WithGuestAccess(claims.Groups) + case permission.AccountRoleSuspended: + // Auth middleware rejects disabled accounts before handlers run. + default: + claims.Groups = permission.WithSuiteDefaults(claims.Groups) + } + if state.PlatformAdmin { + claims.Groups = permission.WithPlatformAdmin(claims.Groups) + } + return nil +} + +// CountPlatformAdmins returns active platform admins. +func CountPlatformAdmins(ctx context.Context, db *pgxpool.Pool) (int64, error) { + if db == nil { + return 0, fmt.Errorf("database not configured") + } + var count int64 + err := db.QueryRow(ctx, ` + SELECT COUNT(*) FROM users + WHERE platform_admin = true AND status = 'active' + `).Scan(&count) + return count, err +} + +// RevokePlatformAdmin clears platform_admin for external_id. +func RevokePlatformAdmin(ctx context.Context, db *pgxpool.Pool, externalID string) error { + if db == nil { + return fmt.Errorf("database not configured") + } + if externalID == "" { + return fmt.Errorf("missing external id") + } + tag, err := db.Exec(ctx, ` + UPDATE users SET platform_admin = false, updated_at = NOW() + WHERE external_id = $1 + `, externalID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} diff --git a/migrations/000031_org_settings.down.sql b/migrations/000031_org_settings.down.sql new file mode 100644 index 0000000..263382a --- /dev/null +++ b/migrations/000031_org_settings.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS org_settings; diff --git a/migrations/000031_org_settings.up.sql b/migrations/000031_org_settings.up.sql new file mode 100644 index 0000000..4820881 --- /dev/null +++ b/migrations/000031_org_settings.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE org_settings ( + id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + settings JSONB NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by TEXT NOT NULL DEFAULT '' +); + +INSERT INTO org_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING; diff --git a/migrations/000032_user_platform_admin.down.sql b/migrations/000032_user_platform_admin.down.sql new file mode 100644 index 0000000..6fb88e2 --- /dev/null +++ b/migrations/000032_user_platform_admin.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS platform_admin; diff --git a/migrations/000032_user_platform_admin.up.sql b/migrations/000032_user_platform_admin.up.sql new file mode 100644 index 0000000..a003299 --- /dev/null +++ b/migrations/000032_user_platform_admin.up.sql @@ -0,0 +1,10 @@ +ALTER TABLE users + ADD COLUMN platform_admin BOOLEAN NOT NULL DEFAULT false; + +-- Existing deployments: promote the earliest account if none is admin yet. +UPDATE users +SET platform_admin = true +WHERE id = ( + SELECT id FROM users ORDER BY created_at ASC LIMIT 1 +) +AND NOT EXISTS (SELECT 1 FROM users WHERE platform_admin = true); diff --git a/migrations/000033_drive_public_share_access.down.sql b/migrations/000033_drive_public_share_access.down.sql new file mode 100644 index 0000000..1119473 --- /dev/null +++ b/migrations/000033_drive_public_share_access.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS drive_public_share_access; diff --git a/migrations/000033_drive_public_share_access.up.sql b/migrations/000033_drive_public_share_access.up.sql new file mode 100644 index 0000000..34929a1 --- /dev/null +++ b/migrations/000033_drive_public_share_access.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE drive_public_share_access ( + token TEXT PRIMARY KEY, + first_access_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_access_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + access_count BIGINT NOT NULL DEFAULT 0 +); + +CREATE INDEX idx_drive_public_share_access_last ON drive_public_share_access (last_access_at DESC);