d07e65021f
ldap_url kommt via LEFT JOIN tenant_ldap und ist NULL für Mandanten
ohne LDAP-Konfiguration. Scan in *string schlug fehl ("cannot scan
NULL into *string") und ließ GET /api/admin/quotas mit 500 fehlschlagen
("Quota-Daten konnten nicht geladen werden").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
392 lines
13 KiB
Go
392 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
|
|
// 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.
|
|
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;
|
|
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;
|
|
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS ocr_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
|
`
|
|
|
|
// 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,
|
|
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
|
|
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, &t.MaxStorageBytes, &t.MaxUsers, &t.MaxEmails); 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
|
|
}
|