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
+73
View File
@@ -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
}
+12 -2
View File
@@ -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)