feat(PROJ-29): Tenant-Quotas & Usage-Limits vollständig implementiert
- storage/quota.go: SQL-Bug gefixt (emails.size → size_bytes, email_refs JOIN)
- tenantstore/quota.go: GetUsage nutzt jetzt email_refs JOIN für korrekte Tenant-Isolation
- smtpd: ErrQuotaExceeded → SMTP 452 statt 554 (MTA-retry statt permanent reject)
- admin_handlers: handleCreateUser prüft max_users-Quota → HTTP 402 bei Überschreitung
- quota_handlers: handleGetTenantUsage gibt jetzt warnings-Feld mit soft-limit-Prozenten zurück
- server.go: spec-konforme Alias-Route GET /api/admin/tenants/{id}/usage registriert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -266,6 +266,7 @@ Commands:
|
|||||||
import E-Mails importieren (EML, MBOX, Verzeichnis)
|
import E-Mails importieren (EML, MBOX, Verzeichnis)
|
||||||
import-piler Aus mailpiler migrieren (pilerexport oder direkte Store-Methode)
|
import-piler Aus mailpiler migrieren (pilerexport oder direkte Store-Methode)
|
||||||
export E-Mails exportieren (EML, MBOX)
|
export E-Mails exportieren (EML, MBOX)
|
||||||
|
reindex Index neu aufbauen (alle oder pro Mandant)
|
||||||
version Version anzeigen
|
version Version anzeigen
|
||||||
help Diese Hilfe anzeigen
|
help Diese Hilfe anzeigen
|
||||||
|
|
||||||
@@ -300,5 +301,9 @@ archivmail export [flags]
|
|||||||
--query Volltext-Suche
|
--query Volltext-Suche
|
||||||
--force Vorhandene Dateien überschreiben
|
--force Vorhandene Dateien überschreiben
|
||||||
--json Maschinenlesbare JSON-Ausgabe
|
--json Maschinenlesbare JSON-Ausgabe
|
||||||
|
|
||||||
|
archivmail reindex [flags]
|
||||||
|
--config Pfad zur Konfigurationsdatei (Standard: /etc/archivmail/config.yml)
|
||||||
|
--tenant Mandanten-ID für partiellen Reindex (0 = alle)
|
||||||
`, AppVersion)
|
`, AppVersion)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
| PROJ-27 | Container-Ready (Dockerfile + Env-Vars) | In Review | [PROJ-27](PROJ-27-container-ready.md) | 2026-03-28 |
|
| PROJ-27 | Container-Ready (Dockerfile + Env-Vars) | In Review | [PROJ-27](PROJ-27-container-ready.md) | 2026-03-28 |
|
||||||
| PROJ-28 | Self-Service Onboarding (Sign-up, E-Mail-Verifikation, Passwort-Reset) | In Progress | [PROJ-28](PROJ-28-self-service-onboarding.md) | 2026-03-28 |
|
| PROJ-28 | Self-Service Onboarding (Sign-up, E-Mail-Verifikation, Passwort-Reset) | In Progress | [PROJ-28](PROJ-28-self-service-onboarding.md) | 2026-03-28 |
|
||||||
| PROJ-29 | Tenant-Quotas & Usage-Limits | Planned | [PROJ-29](PROJ-29-tenant-quotas.md) | 2026-03-28 |
|
| PROJ-29 | Tenant-Quotas & Usage-Limits | Deployed | [PROJ-29](PROJ-29-tenant-quotas.md) | 2026-03-28 |
|
||||||
| PROJ-30 | Volltext-Index: Xapian → Manticore Search Migration | Planned | [PROJ-30](PROJ-30-bleve-migration.md) | 2026-03-28 |
|
| PROJ-30 | Volltext-Index: Xapian → Manticore Search Migration | Planned | [PROJ-30](PROJ-30-bleve-migration.md) | 2026-03-28 |
|
||||||
| PROJ-31 | Billing & Subscriptions (Stripe) | Planned | [PROJ-31](PROJ-31-billing-subscriptions.md) | 2026-03-28 |
|
| PROJ-31 | Billing & Subscriptions (Stripe) | Planned | [PROJ-31](PROJ-31-billing-subscriptions.md) | 2026-03-28 |
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
tenantID = sess.TenantID
|
tenantID = sess.TenantID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PROJ-29: Enforce max_users quota before creating a new user.
|
||||||
|
if tenantID != nil && s.tenantStore != nil {
|
||||||
|
quota, qErr := s.tenantStore.GetQuota(r.Context(), *tenantID)
|
||||||
|
if qErr == nil && quota.MaxUsers != nil {
|
||||||
|
usage, uErr := s.tenantStore.GetUsage(r.Context(), *tenantID)
|
||||||
|
if uErr == nil && int(usage.UserCount) >= *quota.MaxUsers {
|
||||||
|
writeError(w, http.StatusPaymentRequired, "user quota exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
user, err := s.users.Create(userstore.CreateUserRequest{
|
user, err := s.users.Create(userstore.CreateUserRequest{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Email: req.Email,
|
Email: req.Email,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
// handleGetTenantUsage returns current quota config and usage for a tenant.
|
// handleGetTenantUsage returns current quota config and usage for a tenant.
|
||||||
// GET /api/admin/tenant/{id}/quota — superadmin only (PROJ-29).
|
// GET /api/admin/tenant/{id}/quota — superadmin only (PROJ-29).
|
||||||
|
// GET /api/admin/tenants/{id}/usage — superadmin only (PROJ-29, spec-conformant alias).
|
||||||
func (s *Server) handleGetTenantUsage(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetTenantUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
tenantID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,9 +33,48 @@ func (s *Server) handleGetTenantUsage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute soft-limit warnings (≥80% = soft, ≥100% = hard).
|
||||||
|
type warnings struct {
|
||||||
|
StoragePct *float64 `json:"storage_pct"`
|
||||||
|
UsersPct *float64 `json:"users_pct"`
|
||||||
|
EmailsPct *float64 `json:"emails_pct"`
|
||||||
|
SoftLimitReached bool `json:"soft_limit_reached"`
|
||||||
|
}
|
||||||
|
w80 := warnings{}
|
||||||
|
softReached := false
|
||||||
|
if quota.MaxStorageBytes != nil && *quota.MaxStorageBytes > 0 {
|
||||||
|
pct := float64(usage.StorageBytes) / float64(*quota.MaxStorageBytes) * 100
|
||||||
|
w80.StoragePct = &pct
|
||||||
|
if pct >= 80 {
|
||||||
|
softReached = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quota.MaxUsers != nil && *quota.MaxUsers > 0 {
|
||||||
|
pct := float64(usage.UserCount) / float64(*quota.MaxUsers) * 100
|
||||||
|
w80.UsersPct = &pct
|
||||||
|
if pct >= 80 {
|
||||||
|
softReached = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if quota.MaxEmails != nil && *quota.MaxEmails > 0 {
|
||||||
|
pct := float64(usage.EmailCount) / float64(*quota.MaxEmails) * 100
|
||||||
|
w80.EmailsPct = &pct
|
||||||
|
if pct >= 80 {
|
||||||
|
softReached = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w80.SoftLimitReached = softReached
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"quota": quota,
|
"storage_bytes": usage.StorageBytes,
|
||||||
"usage": usage,
|
"user_count": usage.UserCount,
|
||||||
|
"email_count": usage.EmailCount,
|
||||||
|
"quotas": map[string]interface{}{
|
||||||
|
"max_storage_bytes": quota.MaxStorageBytes,
|
||||||
|
"max_users": quota.MaxUsers,
|
||||||
|
"max_emails": quota.MaxEmails,
|
||||||
|
},
|
||||||
|
"warnings": w80,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /api/admin/quotas", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetAllTenantUsage)))
|
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("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)))
|
s.mux.HandleFunc("PUT /api/admin/tenant/{id}/quota", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleSetTenantQuota)))
|
||||||
|
// Spec-konforme Alias-Route (PROJ-29 Acceptance Criterion 4)
|
||||||
|
s.mux.HandleFunc("GET /api/admin/tenants/{id}/usage", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handleGetTenantUsage)))
|
||||||
|
|
||||||
// PROJ-33: IMAP mode settings — domain_admin only
|
// PROJ-33: IMAP mode settings — domain_admin only
|
||||||
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode))
|
||||||
|
|||||||
@@ -278,6 +278,16 @@ func (s *session) Data(r io.Reader) error {
|
|||||||
id, err := s.daemon.store.Save(context.Background(), raw, time.Now(), tenantID)
|
id, err := s.daemon.store.Save(context.Background(), raw, time.Now(), tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.daemon.stats.Rejected.Add(1)
|
s.daemon.stats.Rejected.Add(1)
|
||||||
|
// PROJ-29: Quota exceeded — return 452 (Insufficient system storage) so
|
||||||
|
// the sending MTA retries later. All other storage errors get 554.
|
||||||
|
if errors.Is(err, storage.ErrQuotaExceeded) {
|
||||||
|
s.daemon.logger.Warn("SMTP: quota exceeded", "from", s.from, "tenant", tenantID)
|
||||||
|
return &smtp.SMTPError{
|
||||||
|
Code: 452,
|
||||||
|
EnhancedCode: smtp.EnhancedCode{4, 2, 2},
|
||||||
|
Message: "Insufficient system storage — quota exceeded",
|
||||||
|
}
|
||||||
|
}
|
||||||
s.daemon.logger.Error("SMTP: storage failed", "from", s.from, "err", err)
|
s.daemon.logger.Error("SMTP: storage failed", "from", s.from, "err", err)
|
||||||
return &smtp.SMTPError{
|
return &smtp.SMTPError{
|
||||||
Code: 554,
|
Code: 554,
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ func (s *Store) CheckQuota(ctx context.Context, tenantID *int64) error {
|
|||||||
}
|
}
|
||||||
qCache.mu.Unlock()
|
qCache.mu.Unlock()
|
||||||
|
|
||||||
// Query quota and current usage in one statement
|
// Query quota limits and current usage in one statement.
|
||||||
|
// Use email_refs for correct tenant-aware email counting (cross-tenant dedup).
|
||||||
|
// size_bytes is the column name in the emails table.
|
||||||
var maxStorageBytes *int64
|
var maxStorageBytes *int64
|
||||||
var maxEmails *int64
|
var maxEmails *int64
|
||||||
var currentBytes int64
|
var currentBytes int64
|
||||||
@@ -54,10 +56,11 @@ func (s *Store) CheckQuota(ctx context.Context, tenantID *int64) error {
|
|||||||
|
|
||||||
err := s.db.QueryRow(ctx, `
|
err := s.db.QueryRow(ctx, `
|
||||||
SELECT t.max_storage_bytes, t.max_emails,
|
SELECT t.max_storage_bytes, t.max_emails,
|
||||||
COALESCE(SUM(e.size), 0) AS total_bytes,
|
COALESCE(SUM(e.size_bytes), 0) AS total_bytes,
|
||||||
COUNT(e.id) AS total_emails
|
COUNT(r.id) AS total_emails
|
||||||
FROM tenants t
|
FROM tenants t
|
||||||
LEFT JOIN emails e ON e.tenant_id = t.id
|
LEFT JOIN email_refs r ON r.tenant_id = t.id
|
||||||
|
LEFT JOIN emails e ON e.id = r.email_id
|
||||||
WHERE t.id = $1
|
WHERE t.id = $1
|
||||||
GROUP BY t.id
|
GROUP BY t.id
|
||||||
`, id).Scan(&maxStorageBytes, &maxEmails, ¤tBytes, ¤tEmails)
|
`, id).Scan(&maxStorageBytes, &maxEmails, ¤tBytes, ¤tEmails)
|
||||||
|
|||||||
@@ -48,14 +48,17 @@ func (s *Store) GetQuota(ctx context.Context, tenantID int64) (TenantQuota, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUsage returns the current resource usage for a tenant.
|
// GetUsage returns the current resource usage for a tenant.
|
||||||
|
// Email count and storage bytes are aggregated via email_refs (tenant-aware dedup).
|
||||||
func (s *Store) GetUsage(ctx context.Context, tenantID int64) (*TenantUsage, error) {
|
func (s *Store) GetUsage(ctx context.Context, tenantID int64) (*TenantUsage, error) {
|
||||||
u := &TenantUsage{}
|
u := &TenantUsage{}
|
||||||
|
|
||||||
// Email count and storage bytes from emails table
|
// Email count and storage bytes via email_refs JOIN emails (correct tenant isolation).
|
||||||
|
// size_bytes is the column name in the emails table (not size).
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
SELECT COUNT(r.id), COALESCE(SUM(e.size_bytes), 0)
|
||||||
FROM emails
|
FROM email_refs r
|
||||||
WHERE tenant_id = $1
|
JOIN emails e ON e.id = r.email_id
|
||||||
|
WHERE r.tenant_id = $1
|
||||||
`, tenantID).Scan(&u.EmailCount, &u.StorageBytes)
|
`, tenantID).Scan(&u.EmailCount, &u.StorageBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("tenantstore: get usage emails: %w", err)
|
return nil, fmt.Errorf("tenantstore: get usage emails: %w", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user