feat(PROJ-29): Tenant-Quotas & Usage-Limits

- DB: max_storage_bytes, max_users, max_emails per Tenant (NULL = unlimited)
- storage/quota.go: CheckQuota() mit 60s-Cache, ErrQuotaExceeded
- Save() prüft Quota vor dem Schreiben — Ablehnung bei Hard-Limit
- tenantstore/quota.go: SetQuota(), GetQuota(), GetUsage()
- API: GET/PUT /api/admin/tenant/{id}/quota, GET /api/admin/quotas
- QuotaTab: Usage-Balken (Speicher/Nutzer/Mails), Edit-Dialog, Warnung ab 80%
- InvalidateQuotaCache() nach Quota-Änderung für sofortige Wirkung

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 21:21:11 +02:00
parent ebc9e278ea
commit 7930b85cde
10 changed files with 592 additions and 3 deletions
+119
View File
@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/storage"
"github.com/archivmail/internal/tenantstore"
)
// handleGetTenantUsage returns current quota config and usage for a tenant.
// GET /api/admin/tenant/{id}/quota — superadmin only (PROJ-29).
func (s *Server) handleGetTenantUsage(w http.ResponseWriter, r *http.Request) {
tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
quota, err := s.tenantStore.GetQuota(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
usage, err := s.tenantStore.GetUsage(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"quota": quota,
"usage": usage,
})
}
// handleSetTenantQuota sets quota limits for a tenant.
// PUT /api/admin/tenant/{id}/quota — superadmin only (PROJ-29).
func (s *Server) handleSetTenantQuota(w http.ResponseWriter, r *http.Request) {
tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var body tenantstore.TenantQuota
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if err := s.tenantStore.SetQuota(r.Context(), tenantID, body); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Invalidate quota cache so the new limit takes effect immediately
storage.InvalidateQuotaCache(tenantID)
sess := sessionFromCtx(r.Context())
if s.audlog != nil {
s.audlog.Log(audit.Entry{
EventType: "tenant_quota_changed",
Username: sess.Username,
IPAddress: s.remoteIP(r),
Success: true,
Detail: fmt.Sprintf("tenant_id=%d", tenantID),
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
}
// handleGetAllTenantUsage returns quota and usage for all tenants.
// GET /api/admin/quotas — superadmin only (PROJ-29).
func (s *Server) handleGetAllTenantUsage(w http.ResponseWriter, r *http.Request) {
tenants, err := s.tenantStore.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type tenantWithUsage struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
MaxStorageBytes *int64 `json:"max_storage_bytes"`
MaxUsers *int `json:"max_users"`
MaxEmails *int64 `json:"max_emails"`
StorageBytes int64 `json:"storage_bytes"`
UserCount int64 `json:"user_count"`
EmailCount int64 `json:"email_count"`
}
result := make([]tenantWithUsage, 0, len(tenants))
for _, t := range tenants {
usage, err := s.tenantStore.GetUsage(r.Context(), t.ID)
if err != nil {
usage = &tenantstore.TenantUsage{}
}
result = append(result, tenantWithUsage{
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
MaxStorageBytes: t.MaxStorageBytes,
MaxUsers: t.MaxUsers,
MaxEmails: t.MaxEmails,
StorageBytes: usage.StorageBytes,
UserCount: usage.UserCount,
EmailCount: usage.EmailCount,
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"tenants": result})
}
+5
View File
@@ -181,6 +181,11 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /api/admin/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetRetention)))
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/retention", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantRetention)))
// PROJ-29: Quotas — superadmin only
s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage)))
s.mux.HandleFunc("GET /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantQuota)))
// PROJ-33: IMAP mode settings — domain_admin only
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode))