Files
archivmail/internal/tenantstore/store.go
T
sysops 5bbf6d0ff3 feat(PROJ-34): Retention-Tab + pro-Mandant Aufbewahrungsfristen
- tenantstore: retention_days Spalte, GetRetentionDays/SetRetentionDays
- storage.Save(): per-tenant retention überschreibt globale config
- API: GET /api/admin/retention, PUT /api/admin/tenant/{id}/retention
- Frontend: RetentionTab mit globaler Policy-Anzeige, Mandanten-Tabelle,
  Bearbeiten-Dialog und Purge-Button (superadmin only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 10:37:15 +02:00

381 lines
13 KiB
Go

// Package tenantstore manages multi-tenancy data: tenants, their domains, and
// per-tenant LDAP configuration. Phase 1 implements the core data layer;
// tenant isolation of mail data is handled in later phases.
package tenantstore
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Tenant represents an organisational unit (company / department) in the system.
type Tenant struct {
ID int64 `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
// Computed fields populated by List.
DomainCount int `json:"domain_count,omitempty"`
UserCount int `json:"user_count,omitempty"`
LDAPEnabled *bool `json:"ldap_enabled,omitempty"`
LDAPURL string `json:"ldap_url,omitempty"`
HasLogo bool `json:"has_logo,omitempty"`
RetentionDays int `json:"retention_days"` // 0 = use global config
}
// TenantDomain is an e-mail domain assigned to a tenant.
type TenantDomain struct {
ID int64 `json:"id"`
TenantID int64 `json:"tenant_id"`
Domain string `json:"domain"`
CreatedAt time.Time `json:"created_at"`
}
// Store manages tenant data in PostgreSQL.
type Store struct {
pool *pgxpool.Pool
}
const schemaSQL = `
CREATE TABLE IF NOT EXISTS tenants (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tenant_domains (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
domain VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tenant_ldap (
tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
url TEXT NOT NULL DEFAULT '',
bind_dn TEXT NOT NULL DEFAULT '',
bind_password BYTEA,
base_dn TEXT NOT NULL DEFAULT '',
user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)',
tls BOOLEAN NOT NULL DEFAULT false,
tls_skip_verify BOOLEAN NOT NULL DEFAULT false,
default_role VARCHAR(20) NOT NULL DEFAULT 'user',
group_mappings JSONB NOT NULL DEFAULT '[]'
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id);
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id);
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;
`
// New connects to PostgreSQL and initialises the tenant schema.
func New(dsn string) (*Store, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("tenantstore: connect: %w", err)
}
s := &Store{pool: pool}
if err := s.initSchema(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("tenantstore: init schema: %w", err)
}
return s, nil
}
// Close releases the connection pool.
func (s *Store) Close() {
s.pool.Close()
}
func (s *Store) initSchema(ctx context.Context) error {
_, err := s.pool.Exec(ctx, schemaSQL)
return err
}
// Create inserts a new tenant. name and slug must be unique.
func (s *Store) Create(ctx context.Context, name, slug string) (*Tenant, error) {
var id int64
err := s.pool.QueryRow(ctx,
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
name, slug,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("tenantstore: create: %w", err)
}
return s.Get(ctx, id)
}
// List returns all tenants with computed domain_count, user_count, and LDAP status.
func (s *Store) List(ctx context.Context) ([]Tenant, error) {
rows, err := s.pool.Query(ctx, `
SELECT t.id, t.name, t.slug, t.active, t.created_at,
COUNT(DISTINCT td.id) AS domain_count,
COUNT(DISTINCT u.id) AS user_count,
tl.enabled AS ldap_enabled,
tl.url AS ldap_url,
(t.logo_data IS NOT NULL) AS has_logo,
t.retention_days
FROM tenants t
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
LEFT JOIN users u ON u.tenant_id = t.id
LEFT JOIN tenant_ldap tl ON tl.tenant_id = t.id
GROUP BY t.id, tl.enabled, tl.url
ORDER BY t.id
`)
if err != nil {
return nil, fmt.Errorf("tenantstore: list: %w", err)
}
defer rows.Close()
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 {
return nil, fmt.Errorf("tenantstore: scan: %w", err)
}
tenants = append(tenants, t)
}
if tenants == nil {
tenants = []Tenant{}
}
return tenants, rows.Err()
}
// Get returns a single tenant by ID.
func (s *Store) Get(ctx context.Context, id int64) (*Tenant, error) {
row := s.pool.QueryRow(ctx,
`SELECT id, name, slug, active, created_at, (logo_data IS NOT NULL) AS has_logo FROM tenants WHERE id = $1`, id,
)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.HasLogo); err == pgx.ErrNoRows {
return nil, fmt.Errorf("tenantstore: not found: %d", id)
} else if err != nil {
return nil, fmt.Errorf("tenantstore: get: %w", err)
}
return &t, nil
}
// SetLogo stores a logo image for a tenant (replaces any existing logo).
func (s *Store) SetLogo(ctx context.Context, tenantID int64, data []byte, contentType string) error {
_, err := s.pool.Exec(ctx,
`UPDATE tenants SET logo_data = $1, logo_content_type = $2 WHERE id = $3`,
data, contentType, tenantID,
)
if err != nil {
return fmt.Errorf("tenantstore: set logo: %w", err)
}
return nil
}
// GetLogo returns the raw logo bytes and content type for a tenant.
// Returns nil data if no logo is set.
func (s *Store) GetLogo(ctx context.Context, tenantID int64) ([]byte, string, error) {
var data []byte
var contentType string
err := s.pool.QueryRow(ctx,
`SELECT logo_data, logo_content_type FROM tenants WHERE id = $1`, tenantID,
).Scan(&data, &contentType)
if err == pgx.ErrNoRows {
return nil, "", nil
}
if err != nil {
return nil, "", fmt.Errorf("tenantstore: get logo: %w", err)
}
return data, contentType, nil
}
// DeleteLogo removes the logo from a tenant.
func (s *Store) DeleteLogo(ctx context.Context, tenantID int64) error {
_, err := s.pool.Exec(ctx,
`UPDATE tenants SET logo_data = NULL, logo_content_type = '' WHERE id = $1`, tenantID,
)
if err != nil {
return fmt.Errorf("tenantstore: delete logo: %w", err)
}
return nil
}
// Update sets name and active for a tenant.
func (s *Store) Update(ctx context.Context, id int64, name string, active bool) (*Tenant, error) {
_, err := s.pool.Exec(ctx,
`UPDATE tenants SET name = $1, active = $2 WHERE id = $3`,
name, active, id,
)
if err != nil {
return nil, fmt.Errorf("tenantstore: update: %w", err)
}
return s.Get(ctx, id)
}
// Delete removes a tenant and cascades to tenant_domains and tenant_ldap.
func (s *Store) Delete(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("tenantstore: delete: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tenantstore: tenant %d not found", id)
}
return nil
}
// AddDomain associates a domain name with a tenant.
func (s *Store) AddDomain(ctx context.Context, tenantID int64, domain string) (*TenantDomain, error) {
var id int64
err := s.pool.QueryRow(ctx,
`INSERT INTO tenant_domains (tenant_id, domain) VALUES ($1, $2) RETURNING id`,
tenantID, domain,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("tenantstore: add domain: %w", err)
}
return s.getDomain(ctx, id)
}
// RemoveDomain removes a domain from a tenant.
func (s *Store) RemoveDomain(ctx context.Context, tenantID, domainID int64) error {
tag, err := s.pool.Exec(ctx,
`DELETE FROM tenant_domains WHERE id = $1 AND tenant_id = $2`,
domainID, tenantID,
)
if err != nil {
return fmt.Errorf("tenantstore: remove domain: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tenantstore: domain %d not found for tenant %d", domainID, tenantID)
}
return nil
}
// ListDomains returns all domains assigned to a tenant.
func (s *Store) ListDomains(ctx context.Context, tenantID int64) ([]TenantDomain, error) {
rows, err := s.pool.Query(ctx,
`SELECT id, tenant_id, domain, created_at FROM tenant_domains WHERE tenant_id = $1 ORDER BY id`,
tenantID,
)
if err != nil {
return nil, fmt.Errorf("tenantstore: list domains: %w", err)
}
defer rows.Close()
var domains []TenantDomain
for rows.Next() {
var d TenantDomain
if err := rows.Scan(&d.ID, &d.TenantID, &d.Domain, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("tenantstore: scan domain: %w", err)
}
domains = append(domains, d)
}
if domains == nil {
domains = []TenantDomain{}
}
return domains, rows.Err()
}
// GetByDomain returns the tenant that owns the given e-mail domain.
// Used by the SMTP daemon for routing decisions.
func (s *Store) GetByDomain(ctx context.Context, domain string) (*Tenant, error) {
row := s.pool.QueryRow(ctx, `
SELECT t.id, t.name, t.slug, t.active, t.created_at
FROM tenants t
JOIN tenant_domains td ON td.tenant_id = t.id
WHERE td.domain = $1 AND t.active = true
LIMIT 1
`, domain)
var t Tenant
if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt); err == pgx.ErrNoRows {
return nil, nil // no tenant for this domain is a valid state
} else if err != nil {
return nil, fmt.Errorf("tenantstore: get by domain: %w", err)
}
return &t, nil
}
// GetTenantIDByDomain returns the tenant_id for a given email domain.
// Returns nil if no tenant is found. Satisfies the auth.TenantDomainLookup interface.
func (s *Store) GetTenantIDByDomain(ctx context.Context, domain string) (*int64, error) {
t, err := s.GetByDomain(ctx, domain)
if err != nil {
return nil, err
}
if t == nil {
return nil, nil
}
id := t.ID
return &id, nil
}
// getDomain is a private helper to load a TenantDomain by its primary key.
func (s *Store) getDomain(ctx context.Context, id int64) (*TenantDomain, error) {
row := s.pool.QueryRow(ctx,
`SELECT id, tenant_id, domain, created_at FROM tenant_domains WHERE id = $1`, id,
)
var d TenantDomain
if err := row.Scan(&d.ID, &d.TenantID, &d.Domain, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("tenantstore: get domain: %w", err)
}
return &d, nil
}
// ── IMAP Mode ─────────────────────────────────────────────────────────────
// GetIMAPMode returns the imap_mode for a tenant ("personal" or "shared").
func (s *Store) GetIMAPMode(ctx context.Context, tenantID int64) (string, error) {
var mode string
err := s.pool.QueryRow(ctx, `SELECT imap_mode FROM tenants WHERE id = $1`, tenantID).Scan(&mode)
if err != nil {
return "personal", nil // safe default
}
return mode, nil
}
// SetIMAPMode updates the imap_mode for a tenant. Valid values: "personal", "shared".
func (s *Store) SetIMAPMode(ctx context.Context, tenantID int64, mode string) error {
if mode != "personal" && mode != "shared" {
return fmt.Errorf("tenantstore: invalid imap_mode %q", mode)
}
_, err := s.pool.Exec(ctx, `UPDATE tenants SET imap_mode = $1 WHERE id = $2`, mode, tenantID)
return err
}
// ── Retention ─────────────────────────────────────────────────────────────────
// GetRetentionDays returns the per-tenant retention_days (0 = use global config).
func (s *Store) GetRetentionDays(ctx context.Context, tenantID int64) (int, error) {
var days int
err := s.pool.QueryRow(ctx, `SELECT retention_days FROM tenants WHERE id = $1`, tenantID).Scan(&days)
if err != nil {
return 0, nil // safe default: fall back to global
}
return days, nil
}
// SetRetentionDays sets the per-tenant retention_days. 0 means "use global config".
func (s *Store) SetRetentionDays(ctx context.Context, tenantID int64, days int) error {
if days < 0 {
return fmt.Errorf("tenantstore: retention_days must be >= 0")
}
tag, err := s.pool.Exec(ctx, `UPDATE tenants SET retention_days = $1 WHERE id = $2`, days, tenantID)
if err != nil {
return fmt.Errorf("tenantstore: set retention: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tenantstore: tenant %d not found", tenantID)
}
return nil
}