Admin interface

This commit is contained in:
R3D347HR4Y 2026-06-07 21:55:22 +02:00
parent fa5394e10d
commit f67c109f2f
33 changed files with 2086 additions and 87 deletions

View File

@ -104,7 +104,7 @@ Un seul **nginx** expose lentrée HTTP (`:80`) et route :
| `/auth/*` | Authentik | | `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) | | `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_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`). 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). Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).

View File

@ -5,6 +5,14 @@ metadata:
labels: labels:
blueprints.goauthentik.io/instantiate: "true" blueprints.goauthentik.io/instantiate: "true"
entries: 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 - model: authentik_providers_oauth2.scopemapping
id: ulti-suite-groups-mapping id: ulti-suite-groups-mapping
identifiers: identifiers:
@ -14,15 +22,18 @@ entries:
scope_name: profile scope_name: profile
description: Suite RBAC groups for Ultimail API description: Suite RBAC groups for Ultimail API
expression: | expression: |
return { groups = [
"groups": [
"role:user", "role:user",
"contacts:write", "contacts:write",
"calendar:write", "calendar:write",
"drive:write", "drive:write",
"photos: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 - model: authentik_providers_oauth2.oauth2provider
identifiers: identifiers:

View File

@ -254,6 +254,20 @@ server {
proxy_set_header Connection $connection_upgrade; 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/ { location ^~ /_next/ {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;
set $mail_upstream ${MAIL_FRONTEND_UPSTREAM}; set $mail_upstream ${MAIL_FRONTEND_UPSTREAM};

View File

@ -14,8 +14,11 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/query" "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/permission"
"github.com/ultisuite/ulti-backend/internal/securityaudit" "github.com/ultisuite/ulti-backend/internal/securityaudit"
platformusers "github.com/ultisuite/ulti-backend/internal/users"
) )
type Handler struct { type Handler struct {
@ -23,9 +26,9 @@ type Handler struct {
logger *slog.Logger 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{ return &Handler{
svc: NewService(db, audit), svc: NewService(db, audit, cfg, nc),
logger: slog.Default().With("component", "admin-api"), 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}/disable", h.DisableUser)
r.With(write).Post("/users/{userID}/reactivate", h.ReactivateUser) r.With(write).Post("/users/{userID}/reactivate", h.ReactivateUser)
r.With(write).Put("/users/{userID}/quota", h.SetQuota) 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(write).Delete("/users/{userID}", h.DeleteUser)
r.With(read).Get("/audit", h.ListAuditLogs) r.With(read).Get("/audit", h.ListAuditLogs)
r.With(read).Get("/audit/export", h.ExportAuditLogs) r.With(read).Get("/audit/export", h.ExportAuditLogs)
r.With(read).Get("/stats", h.GetStats) 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 return r
} }
@ -64,9 +74,15 @@ func (h *Handler) ListUsers(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteValidationError(w, r, verr) apivalidate.WriteValidationError(w, r, verr)
return 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{ result, err := h.svc.ListUsers(r.Context(), params, UserFilter{
Status: status, Status: status,
Role: role,
Q: strings.TrimSpace(params.Q), Q: strings.TrimSpace(params.Q),
}) })
if err != nil { if err != nil {
@ -169,6 +185,42 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) {
apiresponse.WriteJSON(w, http.StatusOK, user) 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) { func (h *Handler) SetQuota(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "userID") userID := chi.URLParam(r, "userID")
if verr := validateUserID(userID); verr != nil { 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) 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)
}

View File

@ -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
}

View File

@ -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) != ""
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -17,7 +17,11 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/api/query" "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" "github.com/ultisuite/ulti-backend/internal/securityaudit"
platformusers "github.com/ultisuite/ulti-backend/internal/users"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
@ -25,13 +29,17 @@ var ErrNotFound = errors.New("not found")
type Service struct { type Service struct {
db *pgxpool.Pool db *pgxpool.Pool
audit *securityaudit.Logger audit *securityaudit.Logger
cfg *config.Config
nc *nextcloud.Client
logger *slog.Logger 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{ return &Service{
db: db, db: db,
audit: audit, audit: audit,
cfg: cfg,
nc: nc,
logger: slog.Default().With("component", "admin-service"), logger: slog.Default().With("component", "admin-service"),
} }
} }
@ -43,6 +51,7 @@ type UsersList struct {
type UserFilter struct { type UserFilter struct {
Status string Status string
Role string
Q string Q string
} }
@ -56,7 +65,7 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter
} }
listSQL := ` 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 + ` FROM users` + whereSQL + `
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $` + strconv.Itoa(len(args)+1) + ` OFFSET $` + strconv.Itoa(len(args)+2) 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) users := make([]map[string]any, 0)
for rows.Next() { for rows.Next() {
var id, extID, email, name, status string user, err := scanUserRow(rows)
var invitedAt, disabledAt *time.Time if err != nil {
var createdAt, updatedAt time.Time
if err := rows.Scan(&id, &extID, &email, &name, &status, &invitedAt, &disabledAt, &createdAt, &updatedAt); err != nil {
return UsersList{}, err return UsersList{}, err
} }
users = append(users, map[string]any{ users = append(users, user)
"id": id,
"external_id": extID,
"email": email,
"name": name,
"status": status,
"invited_at": invitedAt,
"disabled_at": disabledAt,
"created_at": createdAt,
"updated_at": updatedAt,
})
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return UsersList{}, err return UsersList{}, err
} }
if err := s.attachUsersStorage(ctx, users); err != nil {
return UsersList{}, err
}
return UsersList{ return UsersList{
Users: users, Users: users,
@ -99,8 +99,20 @@ func (s *Service) ListUsers(ctx context.Context, params query.ListParams, filter
} }
func buildUserFilter(filter UserFilter) (string, []any) { func buildUserFilter(filter UserFilter) (string, []any) {
clauses := make([]string, 0, 2) clauses := make([]string, 0, 3)
args := make([]any, 0, 2) 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) != "" { if strings.TrimSpace(filter.Status) != "" {
args = append(args, strings.TrimSpace(filter.Status)) args = append(args, strings.TrimSpace(filter.Status))
clauses = append(clauses, "status = $"+strconv.Itoa(len(args))) 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) { func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, error) {
var id, extID, email, name, status string row := s.db.QueryRow(ctx, `
var invitedAt, disabledAt *time.Time SELECT id, external_id, email, name, status, platform_admin, invited_at, disabled_at, created_at, updated_at
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
FROM users WHERE id = $1 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
return nil, err return nil, err
} }
mailCount, mailUsedStorage, err := s.mailUsageForUser(ctx, userID)
var mailCount, mailUsedStorage int64 if err != nil {
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 {
return nil, err return nil, err
} }
@ -153,34 +157,136 @@ func (s *Service) GetUser(ctx context.Context, userID string) (map[string]any, e
return nil, err return nil, err
} }
return map[string]any{ email, _ := user["email"].(string)
"id": id, extID, _ := user["external_id"].(string)
"external_id": extID, driveUsedStorage := s.driveUsageForUser(ctx, email, extID)
"email": email,
"name": name, user["quota"] = map[string]any{
"status": status,
"invited_at": invitedAt,
"disabled_at": disabledAt,
"created_at": createdAt,
"updated_at": updatedAt,
"quota": map[string]any{
"mail": map[string]any{ "mail": map[string]any{
"count": mailCount, "count": mailCount,
"used_storage_bytes": mailUsedStorage, "used_storage_bytes": mailUsedStorage,
"max_storage_bytes": mailMax, "max_storage_bytes": mailMax,
}, },
"drive": map[string]any{ "drive": map[string]any{
"used_storage_bytes": int64(0), "used_storage_bytes": driveUsedStorage,
"max_storage_bytes": driveMax, "max_storage_bytes": driveMax,
}, },
"photos": map[string]any{ "photos": map[string]any{
"used_storage_bytes": int64(0), "used_storage_bytes": int64(0),
"max_storage_bytes": photosMax, "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 }, 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, &currentAdmin, &currentStatus)
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) { func (s *Service) CreateUser(ctx context.Context, actorSub string, req createUserRequest) (map[string]any, error) {
var id string var id string
if err := s.db.QueryRow(ctx, ` 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)), "email": strings.ToLower(strings.TrimSpace(req.Email)),
"initial_name": strings.TrimSpace(req.Name), "initial_name": strings.TrimSpace(req.Name),
}) })
if err := s.applyOrgDefaultQuotas(ctx, id); err != nil {
return nil, err
}
return s.GetUser(ctx, id) return s.GetUser(ctx, id)
} }
@ -213,6 +322,9 @@ func (s *Service) InviteUser(ctx context.Context, actorSub string, req inviteUse
"target_user": id, "target_user": id,
"email": strings.ToLower(strings.TrimSpace(req.Email)), "email": strings.ToLower(strings.TrimSpace(req.Email)),
}) })
if err := s.applyOrgDefaultQuotas(ctx, id); err != nil {
return nil, err
}
return s.GetUser(ctx, id) return s.GetUser(ctx, id)
} }
@ -530,11 +642,20 @@ func (s *Service) GetStats(ctx context.Context) (map[string]any, error) {
SELECT COUNT(*) FROM ( SELECT COUNT(*) FROM (
SELECT SELECT
u.id, 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 COALESCE((s.preferences->>'mail_max_storage_bytes')::bigint, 5368709120) AS max_storage
FROM users u FROM users u
LEFT JOIN mail_accounts ma ON ma.user_id = u.id LEFT JOIN mail_accounts ma ON ma.user_id = u.id
LEFT JOIN messages m ON m.account_id = ma.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 LEFT JOIN settings s ON s.user_id = u.id
GROUP BY u.id, s.preferences GROUP BY u.id, s.preferences
) q ) q
@ -557,12 +678,202 @@ func (s *Service) GetStats(ctx context.Context) (map[string]any, error) {
stats["quotas"] = map[string]any{ stats["quotas"] = map[string]any{
"users_near_mail_quota_90pct": usersNearMailQuota, "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{ stats["audit"] = map[string]any{
"top_actors_7d": topActors, "top_actors_7d": topActors,
} }
return stats, nil 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) { func (s *Service) logAudit(ctx context.Context, actor, action string, details map[string]any) {
if s.audit == nil { if s.audit == nil {
return return

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/ultisuite/ulti-backend/internal/api/apivalidate" "github.com/ultisuite/ulti-backend/internal/api/apivalidate"
"github.com/ultisuite/ulti-backend/internal/permission"
) )
const maxQuotaRequestBody = 4 << 10 const maxQuotaRequestBody = 4 << 10
@ -126,3 +127,31 @@ func validateUserID(userID string) *apivalidate.ValidationError {
} }
return nil 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
}

View File

@ -17,7 +17,11 @@ func (s *Service) UploadPublicShare(ctx context.Context, token, filePath, passwo
if !nextcloud.PublicShareCanCreate(perms) && !nextcloud.PublicShareCanUpdate(perms) { if !nextcloud.PublicShareCanCreate(perms) && !nextcloud.PublicShareCanUpdate(perms) {
return ErrForbidden 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 { func (s *Service) CreatePublicShareFolder(ctx context.Context, token, folderPath, password string) error {

View File

@ -20,6 +20,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/automation" "github.com/ultisuite/ulti-backend/internal/automation"
"github.com/ultisuite/ulti-backend/internal/mail/rules" "github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/nextcloud" "github.com/ultisuite/ulti-backend/internal/nextcloud"
"github.com/ultisuite/ulti-backend/internal/publicshare"
"github.com/ultisuite/ulti-backend/internal/realtime" "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 { if err != nil {
return nil, mapPublicShareError(err) return nil, mapPublicShareError(err)
} }
s.recordPublicShareAccess(ctx, token)
return view, nil return view, nil
} }
@ -370,6 +372,7 @@ func (s *Service) DownloadPublicShare(ctx context.Context, token, filePath, pass
if err != nil { if err != nil {
return nil, "", mapPublicShareError(err) return nil, "", mapPublicShareError(err)
} }
s.recordPublicShareAccess(ctx, token)
return body, contentType, nil return body, contentType, nil
} }
@ -378,9 +381,14 @@ func (s *Service) PreviewPublicShare(ctx context.Context, token, filePath, passw
if err != nil { if err != nil {
return nil, "", mapPublicShareError(err) return nil, "", mapPublicShareError(err)
} }
s.recordPublicShareAccess(ctx, token)
return body, contentType, nil 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) { func (s *Service) rewriteShareURL(share *nextcloud.ShareInfo) {
if share == nil || s.nc == nil { if share == nil || s.nc == nil {
return return

View File

@ -118,14 +118,17 @@ func Auth(verifier *auth.Holder, db *pgxpool.Pool, audit *securityaudit.Logger)
} }
return return
} }
claims.Groups = permission.WithSuiteDefaults(claims.Groups)
if db != nil { if db != nil {
if _, err := users.EnsureUser(r.Context(), db, claims); err != nil { if _, err := users.EnsureUser(r.Context(), db, claims); err != nil {
slog.Error("provision user", "sub", claims.Sub, "error", err) slog.Error("provision user", "sub", claims.Sub, "error", err)
apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision user", nil) apiresponse.WriteError(w, r, http.StatusInternalServerError, apiresponse.CodeInternal, "failed to provision user", nil)
return 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 var disabled bool
if err := db.QueryRow(r.Context(), ` if err := db.QueryRow(r.Context(), `
SELECT status = 'disabled' FROM users WHERE external_id = $1 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 return
} }
} else {
claims.Groups = permission.WithSuiteDefaults(claims.Groups)
} }
if audit != nil { if audit != nil {

View File

@ -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 { func RequireAdminScope(scope permission.AdminScope) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@ -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,
})
}

View File

@ -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"])
}
}

View File

@ -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"])
}
}

View File

@ -473,7 +473,11 @@ func pathBaseName(raw string) string {
} }
func (c *Client) ListShares(ctx context.Context, userID, filePath string) ([]ShareInfo, error) { 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{ resp, err := c.DoAsUser(ctx, "GET", path, nil, userID, map[string]string{
"Accept": "application/json", "Accept": "application/json",
}) })

View File

@ -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
}
}

View File

@ -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")
}
}

View File

@ -8,9 +8,45 @@ type Role string
const ( const (
RoleAdmin Role = "admin" RoleAdmin Role = "admin"
RoleUser Role = "user" RoleUser Role = "user"
RoleGuest Role = "guest"
RoleService Role = "service" 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. // Resource is a suite module protected by resource-scoped permissions.
type Resource string type Resource string
@ -120,6 +156,45 @@ func WithSuiteDefaults(groups []string) []string {
return out 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. // AdminScope is a fine-grained admin API permission with read < write ordering.
type AdminScope int type AdminScope int

View File

@ -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) { func TestWithSuiteDefaultsUserRoleOnly(t *testing.T) {
groups := WithSuiteDefaults([]string{"role:user"}) 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) { func TestHasAdminScopeNoAccess(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@ -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()
}

View File

@ -27,6 +27,7 @@ import (
"github.com/ultisuite/ulti-backend/internal/api/middleware" "github.com/ultisuite/ulti-backend/internal/api/middleware"
"github.com/ultisuite/ulti-backend/internal/api/office" "github.com/ultisuite/ulti-backend/internal/api/office"
photosapi "github.com/ultisuite/ulti-backend/internal/api/photos" 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/automation"
"github.com/ultisuite/ulti-backend/internal/authentik" "github.com/ultisuite/ulti-backend/internal/authentik"
"github.com/ultisuite/ulti-backend/internal/auth" "github.com/ultisuite/ulti-backend/internal/auth"
@ -286,8 +287,15 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
r.Use(middleware.Auth(verifierHolder, pool, auditLogger)) r.Use(middleware.Auth(verifierHolder, pool, auditLogger))
r.Use(middleware.EnforceApiTokenPolicy()) r.Use(middleware.EnforceApiTokenPolicy())
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.Group(func(r chi.Router) {
r.Use(middleware.RequireFullAccount)
r.Mount("/api/v1/mail", mailHandler.Routes()) 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{ r.Get("/api/v1/search", search.NewHandler(pool, search.Options{
Nextcloud: ncClient, Nextcloud: ncClient,
Engine: cfg.SearchEngine, Engine: cfg.SearchEngine,
@ -298,9 +306,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
TypesenseKey: cfg.TypesenseKey, TypesenseKey: cfg.TypesenseKey,
TypesenseCollection: cfg.TypesenseCollection, TypesenseCollection: cfg.TypesenseCollection,
}).Search) }).Search)
if driveHandler != nil { if driveHandler != nil {
r.Mount("/api/v1/drive", driveHandler.Routes())
r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes()) r.Mount("/api/v1/calendar", calendar.NewHandler(ncClient, meetCfg).Routes())
r.Mount("/api/v1/contacts", contactsHandler.Routes()) r.Mount("/api/v1/contacts", contactsHandler.Routes())
} }
@ -311,6 +317,7 @@ func New(ctx context.Context, cfg *config.Config, opts Options) (*App, error) {
r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes()) r.Mount("/api/v1/photos", photosapi.NewHandler(photosClient, ncClient).Routes())
} }
}) })
})
slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect) slog.Info("mail oauth providers", "enabled", mailOAuthSvc.EnabledProviders(), "redirect", oauthRedirect)

82
internal/users/admin.go Normal file
View File

@ -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
}

View File

@ -34,18 +34,29 @@ func EnsureUser(ctx context.Context, db *pgxpool.Pool, claims *auth.Claims) (str
email := ProvisionEmail(claims) email := ProvisionEmail(claims)
name := strings.TrimSpace(claims.Name) 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 var userID string
err := db.QueryRow(ctx, ` err := db.QueryRow(ctx, `
INSERT INTO users (external_id, email, name) INSERT INTO users (external_id, email, name, platform_admin)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
ON CONFLICT (external_id) DO UPDATE SET ON CONFLICT (external_id) DO UPDATE SET
email = EXCLUDED.email, email = EXCLUDED.email,
name = EXCLUDED.name, name = EXCLUDED.name,
updated_at = NOW() updated_at = NOW()
RETURNING id RETURNING id
`, claims.Sub, email, name).Scan(&userID) `, claims.Sub, email, name, isFirstUser).Scan(&userID)
if err != nil { if err != nil {
return "", fmt.Errorf("provision user: %w", err) 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 return userID, nil
} }

96
internal/users/role.go Normal file
View File

@ -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
}

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS org_settings;

View File

@ -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;

View File

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN IF EXISTS platform_admin;

View File

@ -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);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS drive_public_share_access;

View File

@ -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);