ac91dceac2
PROJ-22 – LDAP Web-GUI Konfiguration & Test: - internal/ldapconfig/store.go: AES-256-GCM Passwortspeicherung, CRUD Upsert (id=1) - internal/ldapauth/client.go: TestConnection (RootDSE, UserCount) + Authenticate (2-step bind) - internal/auth/auth.go: LDAP-Fallback in Login(), Gruppen-Rollenzuordnung, issueToken helper - internal/api/ldap_tenants.go: GET/PUT/DELETE/POST-test /api/admin/ldap mit Audit-Log - go.mod: github.com/go-ldap/ldap/v3 v3.4.8 hinzugefügt - Frontend: LDAPConfig/LDAPTestResult Typen, LDAP-Tab mit Gruppen-Mappings + Testergebnis PROJ-21 Phase 1+6+7 – Multi-Tenancy Grundstruktur: - internal/tenantstore/store.go: tenants, tenant_domains, tenant_ldap Schema; Migration users/audit_log - API: 8 Tenant-Routen (CRUD + Domain-Management) via SetTenants() - cmd/archivmail/main.go: ldapSt + tenantSt initialisiert - Frontend: Mandanten-Tab mit Tabelle, Domain-Dialog, Deaktivieren/Löschen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
8.1 KiB
Go
266 lines
8.1 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"`
|
|
}
|
|
|
|
// 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);
|
|
`
|
|
|
|
// 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 and user_count.
|
|
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
|
|
FROM tenants t
|
|
LEFT JOIN tenant_domains td ON td.tenant_id = t.id
|
|
LEFT JOIN users u ON u.tenant_id = t.id
|
|
GROUP BY t.id
|
|
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); 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 FROM tenants WHERE id = $1`, id,
|
|
)
|
|
var t Tenant
|
|
if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt); 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|