// 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 }