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:
@@ -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})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrQuotaExceeded is returned when a tenant's storage or email quota is exceeded.
|
||||
var ErrQuotaExceeded = errors.New("storage: tenant quota exceeded")
|
||||
|
||||
const quotaCacheTTL = 60 * time.Second
|
||||
|
||||
type quotaEntry struct {
|
||||
exceeded bool
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
type quotaCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[int64]quotaEntry
|
||||
}
|
||||
|
||||
var qCache = "aCache{entries: make(map[int64]quotaEntry)}
|
||||
|
||||
// CheckQuota checks whether a tenant has exceeded its storage or email quota.
|
||||
// Returns ErrQuotaExceeded if exceeded. Results are cached for 60 seconds.
|
||||
// If tenantID is nil (no-tenant mail), quota is not enforced.
|
||||
func (s *Store) CheckQuota(ctx context.Context, tenantID *int64) error {
|
||||
if s.db == nil || tenantID == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := *tenantID
|
||||
|
||||
// Check cache first
|
||||
qCache.mu.Lock()
|
||||
if e, ok := qCache.entries[id]; ok && time.Now().Before(e.expiry) {
|
||||
exceeded := e.exceeded
|
||||
qCache.mu.Unlock()
|
||||
if exceeded {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
qCache.mu.Unlock()
|
||||
|
||||
// Query quota and current usage in one statement
|
||||
var maxStorageBytes *int64
|
||||
var maxEmails *int64
|
||||
var currentBytes int64
|
||||
var currentEmails int64
|
||||
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT t.max_storage_bytes, t.max_emails,
|
||||
COALESCE(SUM(e.size), 0) AS total_bytes,
|
||||
COUNT(e.id) AS total_emails
|
||||
FROM tenants t
|
||||
LEFT JOIN emails e ON e.tenant_id = t.id
|
||||
WHERE t.id = $1
|
||||
GROUP BY t.id
|
||||
`, id).Scan(&maxStorageBytes, &maxEmails, ¤tBytes, ¤tEmails)
|
||||
if err != nil {
|
||||
// Tenant not found or DB error — allow the mail to pass through
|
||||
return nil
|
||||
}
|
||||
|
||||
exceeded := false
|
||||
if maxStorageBytes != nil && currentBytes >= *maxStorageBytes {
|
||||
exceeded = true
|
||||
}
|
||||
if maxEmails != nil && currentEmails >= *maxEmails {
|
||||
exceeded = true
|
||||
}
|
||||
|
||||
// Write to cache
|
||||
qCache.mu.Lock()
|
||||
qCache.entries[id] = quotaEntry{exceeded: exceeded, expiry: time.Now().Add(quotaCacheTTL)}
|
||||
qCache.mu.Unlock()
|
||||
|
||||
if exceeded {
|
||||
return ErrQuotaExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidateQuotaCache removes the cached quota entry for a tenant.
|
||||
// Call this after quota changes to ensure fresh values on the next check.
|
||||
func InvalidateQuotaCache(tenantID int64) {
|
||||
qCache.mu.Lock()
|
||||
delete(qCache.entries, tenantID)
|
||||
qCache.mu.Unlock()
|
||||
}
|
||||
@@ -270,7 +270,12 @@ func (s *Store) Save(ctx context.Context, raw []byte, _ time.Time, tenantID *int
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: SHA-256-based dedup (fallback / no Message-ID)
|
||||
// Step 3: Quota check (PROJ-29) — reject before writing
|
||||
if err := s.CheckQuota(ctx, tenantID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Step 4: SHA-256-based dedup (fallback / no Message-ID)
|
||||
sum := sha256.Sum256(raw)
|
||||
id := fmt.Sprintf("%x", sum[:]) // 64 hex chars
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package tenantstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// TenantQuota holds the configurable limits for a tenant (nil = unlimited).
|
||||
type TenantQuota struct {
|
||||
MaxStorageBytes *int64 `json:"max_storage_bytes"`
|
||||
MaxUsers *int `json:"max_users"`
|
||||
MaxEmails *int64 `json:"max_emails"`
|
||||
}
|
||||
|
||||
// TenantUsage holds the current resource usage for a tenant.
|
||||
type TenantUsage struct {
|
||||
StorageBytes int64 `json:"storage_bytes"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
EmailCount int64 `json:"email_count"`
|
||||
}
|
||||
|
||||
// SetQuota updates the quota fields for a tenant.
|
||||
// Pass nil to remove a limit (unlimited).
|
||||
func (s *Store) SetQuota(ctx context.Context, tenantID int64, q TenantQuota) error {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
`UPDATE tenants SET max_storage_bytes=$1, max_users=$2, max_emails=$3 WHERE id=$4`,
|
||||
q.MaxStorageBytes, q.MaxUsers, q.MaxEmails, tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tenantstore: set quota: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("tenantstore: tenant %d not found", tenantID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQuota returns the quota configuration for a tenant.
|
||||
func (s *Store) GetQuota(ctx context.Context, tenantID int64) (TenantQuota, error) {
|
||||
var q TenantQuota
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT max_storage_bytes, max_users, max_emails FROM tenants WHERE id=$1`, tenantID,
|
||||
).Scan(&q.MaxStorageBytes, &q.MaxUsers, &q.MaxEmails)
|
||||
if err != nil {
|
||||
return q, fmt.Errorf("tenantstore: get quota: %w", err)
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// GetUsage returns the current resource usage for a tenant.
|
||||
func (s *Store) GetUsage(ctx context.Context, tenantID int64) (*TenantUsage, error) {
|
||||
u := &TenantUsage{}
|
||||
|
||||
// Email count and storage bytes from emails table
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM emails
|
||||
WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&u.EmailCount, &u.StorageBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenantstore: get usage emails: %w", err)
|
||||
}
|
||||
|
||||
// User count from users table
|
||||
err = s.pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM users WHERE tenant_id = $1`, tenantID,
|
||||
).Scan(&u.UserCount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tenantstore: get usage users: %w", err)
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
@@ -26,6 +26,10 @@ type Tenant struct {
|
||||
LDAPURL string `json:"ldap_url,omitempty"`
|
||||
HasLogo bool `json:"has_logo,omitempty"`
|
||||
RetentionDays int `json:"retention_days"` // 0 = use global config
|
||||
// Quota fields (PROJ-29) — nil = unlimited
|
||||
MaxStorageBytes *int64 `json:"max_storage_bytes,omitempty"`
|
||||
MaxUsers *int `json:"max_users,omitempty"`
|
||||
MaxEmails *int64 `json:"max_emails,omitempty"`
|
||||
}
|
||||
|
||||
// TenantDomain is an e-mail domain assigned to a tenant.
|
||||
@@ -77,6 +81,9 @@ ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT '';
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal';
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS retention_days INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_storage_bytes BIGINT;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_users INT;
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS max_emails BIGINT;
|
||||
`
|
||||
|
||||
// New connects to PostgreSQL and initialises the tenant schema.
|
||||
@@ -128,7 +135,10 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
||||
tl.enabled AS ldap_enabled,
|
||||
tl.url AS ldap_url,
|
||||
(t.logo_data IS NOT NULL) AS has_logo,
|
||||
t.retention_days
|
||||
t.retention_days,
|
||||
t.max_storage_bytes,
|
||||
t.max_users,
|
||||
t.max_emails
|
||||
FROM tenants t
|
||||
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
||||
LEFT JOIN users u ON u.tenant_id = t.id
|
||||
@@ -144,7 +154,7 @@ func (s *Store) List(ctx context.Context) ([]Tenant, error) {
|
||||
var tenants []Tenant
|
||||
for rows.Next() {
|
||||
var t Tenant
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount, &t.LDAPEnabled, &t.LDAPURL, &t.HasLogo, &t.RetentionDays, &t.MaxStorageBytes, &t.MaxUsers, &t.MaxEmails); err != nil {
|
||||
return nil, fmt.Errorf("tenantstore: scan: %w", err)
|
||||
}
|
||||
tenants = append(tenants, t)
|
||||
|
||||
Reference in New Issue
Block a user