Admin interface
This commit is contained in:
parent
fa5394e10d
commit
f67c109f2f
@ -104,7 +104,7 @@ Un seul **nginx** expose l’entré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).
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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};
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
520
internal/api/admin/org_settings.go
Normal file
520
internal/api/admin/org_settings.go
Normal 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
|
||||||
|
}
|
||||||
109
internal/api/admin/org_settings_env.go
Normal file
109
internal/api/admin/org_settings_env.go
Normal 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) != ""
|
||||||
|
}
|
||||||
194
internal/api/admin/public_shares.go
Normal file
194
internal/api/admin/public_shares.go
Normal 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
|
||||||
|
}
|
||||||
59
internal/api/admin/public_shares_handlers.go
Normal file
59
internal/api/admin/public_shares_handlers.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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, ¤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) {
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
59
internal/api/users/handlers.go
Normal file
59
internal/api/users/handlers.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
59
internal/integrationtest/admin/org_settings_test.go
Normal file
59
internal/integrationtest/admin/org_settings_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
66
internal/integrationtest/auth/first_admin_test.go
Normal file
66
internal/integrationtest/auth/first_admin_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
21
internal/nextcloud/share_filter.go
Normal file
21
internal/nextcloud/share_filter.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
18
internal/nextcloud/share_filter_test.go
Normal file
18
internal/nextcloud/share_filter_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
60
internal/publicshare/access.go
Normal file
60
internal/publicshare/access.go
Normal 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()
|
||||||
|
}
|
||||||
@ -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
82
internal/users/admin.go
Normal 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
|
||||||
|
}
|
||||||
@ -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
96
internal/users/role.go
Normal 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
|
||||||
|
}
|
||||||
1
migrations/000031_org_settings.down.sql
Normal file
1
migrations/000031_org_settings.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS org_settings;
|
||||||
8
migrations/000031_org_settings.up.sql
Normal file
8
migrations/000031_org_settings.up.sql
Normal 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;
|
||||||
1
migrations/000032_user_platform_admin.down.sql
Normal file
1
migrations/000032_user_platform_admin.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS platform_admin;
|
||||||
10
migrations/000032_user_platform_admin.up.sql
Normal file
10
migrations/000032_user_platform_admin.up.sql
Normal 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);
|
||||||
1
migrations/000033_drive_public_share_access.down.sql
Normal file
1
migrations/000033_drive_public_share_access.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS drive_public_share_access;
|
||||||
8
migrations/000033_drive_public_share_access.up.sql
Normal file
8
migrations/000033_drive_public_share_access.up.sql
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user