feat(PROJ-22): LDAP Web-GUI + feat(PROJ-21): Multi-Tenancy Phase 1
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>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user