feat(PROJ-21/23): Pro-Tenant Xapian-Index + Tenant-LDAP Backend

PROJ-21 Phase 4:
- internal/index/tenant_manager.go: TenantIndexManager mit lazy-loading Pool
- internal/index/tenant_worker.go: TenantIndexWorker leitet Submit an richtigen Index
- Jeder Mandant bekommt eigenes Xapian-Verzeichnis (tenant-<id>/)
- handleSearch nutzt direkt Tenant-Index statt nachgelagertem Post-Filter
- runBackfill re-indexiert pro Mandant beim Start

PROJ-23 / PROJ-16 Phase B:
- internal/ldapconfig/tenant_store.go: TenantStore mit AES-256-GCM für tenant_ldap
- internal/api/ldap_tenants.go: 8 neue Handler (GET/PUT/DELETE/test für
  /api/tenant/ldap und /api/admin/tenants/{id}/ldap)
- internal/auth/auth.go: Login-Fallback prüft tenant_ldap nach globalem LDAP
  (Domain-Extraktion → tenant_ldap config → UpsertLDAPUser mit tenant_id)
- internal/api/server.go: SetTenantLDAP(), neue Routen registriert
- internal/tenantstore/store.go: GetByDomain() Interface für auth-Package
- cmd/archivmail/main.go: TenantLDAPStore + TenantIndexManager verdrahtet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-18 00:18:35 +01:00
parent 46d7bfe608
commit 78d83d3e98
9 changed files with 977 additions and 24 deletions
+96 -14
View File
@@ -104,7 +104,7 @@ func main() {
} }
defer mailStore.Close() defer mailStore.Close()
// Index // Index — per-tenant index manager (PROJ-21 Phase 4)
indexBackend := cfg.Index.Backend indexBackend := cfg.Index.Backend
if indexBackend == "" { if indexBackend == "" {
indexBackend = "xapian" indexBackend = "xapian"
@@ -113,21 +113,24 @@ func main() {
if batchSize <= 0 { if batchSize <= 0 {
batchSize = 100 batchSize = 100
} }
idx, err := index.New(cfg.Index.Path, batchSize, indexBackend) idxMgr, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, indexBackend)
if err != nil { if err != nil {
logger.Error("index init failed", "err", err) logger.Error("index manager init failed", "err", err)
os.Exit(1) os.Exit(1)
} }
defer idx.Close() defer idxMgr.Close()
// Async index worker // Global index reference for backward compatibility (IMAP importer, etc.)
idx := idxMgr.Global()
// Async index worker — tenant-aware (routes docs to correct per-tenant index)
asyncQueueSize := cfg.Index.AsyncQueueSize asyncQueueSize := cfg.Index.AsyncQueueSize
if asyncQueueSize <= 0 { if asyncQueueSize <= 0 {
asyncQueueSize = 1000 asyncQueueSize = 1000
} }
worker := index.NewWorker(idx, asyncQueueSize, logger) tenantWorker := index.NewTenantWorker(idxMgr, asyncQueueSize, logger)
worker.Start() tenantWorker.Start()
defer worker.Stop() defer tenantWorker.Stop()
// User store // User store
users, err := userstore.New(cfg.Database.DSN()) users, err := userstore.New(cfg.Database.DSN())
@@ -186,6 +189,7 @@ func main() {
} }
defer tenantSt.Close() defer tenantSt.Close()
srv.SetTenants(tenantSt) srv.SetTenants(tenantSt)
srv.SetIndexManager(idxMgr)
// Start SMTP daemon with index worker integration // Start SMTP daemon with index worker integration
if cfg.SMTP.Bind == "" { if cfg.SMTP.Bind == "" {
@@ -193,7 +197,9 @@ func main() {
} }
smtpDaemon := smtpd.New(cfg.SMTP, mailStore, logger) smtpDaemon := smtpd.New(cfg.SMTP, mailStore, logger)
smtpDaemon.SetIndexCallback(func(raw []byte, id string) { smtpDaemon.SetIndexCallback(func(raw []byte, id string) {
submitToWorker(worker, mailStore, raw, id, logger) // Look up the tenant_id for this email from DB metadata.
tenantID, _ := mailStore.GetTenantForMail(context.Background(), id)
submitToWorker(tenantWorker, mailStore, raw, id, tenantID, logger)
}) })
// Wire tenant routing into SMTP daemon // Wire tenant routing into SMTP daemon
if cfg.SMTP.TenantRouting == "domain" { if cfg.SMTP.TenantRouting == "domain" {
@@ -220,6 +226,16 @@ func main() {
// Wire LDAP config store into API server // Wire LDAP config store into API server
srv.SetLDAP(ldapSt) srv.SetLDAP(ldapSt)
// PROJ-23: Per-tenant LDAP config store
tenantLdapSt, err := ldapcfg.NewTenantStore(cfg.Database.DSN(), aesKey)
if err != nil {
logger.Error("tenant ldap store init failed", "err", err)
os.Exit(1)
}
defer tenantLdapSt.Close()
srv.SetTenantLDAP(tenantLdapSt)
authMgr.SetTenantLDAP(tenantLdapSt, tenantSt)
// Wire SMTP daemon into API server for status endpoint // Wire SMTP daemon into API server for status endpoint
srv.SetSMTPDaemon(smtpDaemon) srv.SetSMTPDaemon(smtpDaemon)
@@ -247,7 +263,7 @@ func main() {
srv.SetPop3(pop3St, pop3Imp) srv.SetPop3(pop3St, pop3Imp)
// Backfill in background: migrate existing files into DB metadata + re-index // Backfill in background: migrate existing files into DB metadata + re-index
go runBackfill(context.Background(), mailStore, idx, worker, logger) go runBackfill(context.Background(), mailStore, idx, tenantWorker, logger)
// Background integrity verification — runs every 5 minutes // Background integrity verification — runs every 5 minutes
go runIntegrityCheck(context.Background(), mailStore, logger) go runIntegrityCheck(context.Background(), mailStore, logger)
@@ -272,7 +288,8 @@ func main() {
} }
// submitToWorker parses a raw email and submits it to the async index worker. // submitToWorker parses a raw email and submits it to the async index worker.
func submitToWorker(worker *index.IndexWorker, store *storage.Store, raw []byte, id string, logger *slog.Logger) { // tenantID may be nil for global context.
func submitToWorker(worker *index.TenantIndexWorker, store *storage.Store, raw []byte, id string, tenantID *int64, logger *slog.Logger) {
pm, err := mailparser.Parse(raw) pm, err := mailparser.Parse(raw)
if err != nil { if err != nil {
logger.Warn("index: parse failed, skipping indexing", "id", id, "err", err) logger.Warn("index: parse failed, skipping indexing", "id", id, "err", err)
@@ -296,6 +313,7 @@ func submitToWorker(worker *index.IndexWorker, store *storage.Store, raw []byte,
HasAttachment: len(pm.Attachments) > 0, HasAttachment: len(pm.Attachments) > 0,
Date: pm.Date, Date: pm.Date,
Size: int64(len(raw)), Size: int64(len(raw)),
TenantID: tenantID,
} }
worker.Submit(doc) worker.Submit(doc)
@@ -307,8 +325,9 @@ func submitToWorker(worker *index.IndexWorker, store *storage.Store, raw []byte,
} }
// runBackfill walks the store, inserts missing DB metadata, and indexes // runBackfill walks the store, inserts missing DB metadata, and indexes
// emails that have not yet been indexed. // emails that have not yet been indexed. Per-tenant indexing is handled by
func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, worker *index.IndexWorker, logger *slog.Logger) { // looking up each email's tenant_id from the DB.
func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, worker *index.TenantIndexWorker, logger *slog.Logger) {
logger.Info("backfill: starting") logger.Info("backfill: starting")
count := 0 count := 0
@@ -345,7 +364,8 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
if !alreadyIndexed { if !alreadyIndexed {
needIndex++ needIndex++
submitToWorker(worker, store, raw, id, logger) tenantID, _ := store.GetTenantForMail(ctx, id)
submitToWorker(worker, store, raw, id, tenantID, logger)
} }
if count%100 == 0 { if count%100 == 0 {
@@ -363,6 +383,68 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
logger.Info("backfill: complete", "total", count, "submitted_for_index", needIndex, "errors", errCount) logger.Info("backfill: complete", "total", count, "submitted_for_index", needIndex, "errors", errCount)
} }
// reindexTenant re-indexes all emails belonging to a specific tenant.
// Used during migration when switching from global index to per-tenant indexes.
func reindexTenant(ctx context.Context, store *storage.Store, mgr *index.TenantIndexManager, tenantID int64, logger *slog.Logger) error {
tid := tenantID
ids, err := store.GetAllIDsByTenant(ctx, &tid)
if err != nil {
return fmt.Errorf("reindex tenant %d: get IDs: %w", tenantID, err)
}
logger.Info("reindex tenant: starting", "tenant_id", tenantID, "count", len(ids))
idx := mgr.ForTenant(&tid)
indexed := 0
errCount := 0
for _, id := range ids {
raw, err := store.Load(id)
if err != nil {
logger.Warn("reindex tenant: load failed", "tenant_id", tenantID, "id", id, "err", err)
errCount++
continue
}
pm, parseErr := mailparser.Parse(raw)
if parseErr != nil {
logger.Warn("reindex tenant: parse failed", "tenant_id", tenantID, "id", id, "err", parseErr)
errCount++
continue
}
var attachNames []string
for _, a := range pm.Attachments {
if a.Filename != "" {
attachNames = append(attachNames, a.Filename)
}
}
doc := index.MailDocument{
ID: id,
From: pm.From,
To: strings.Join(pm.To, ", "),
Subject: pm.Subject,
Body: pm.TextBody,
AttachNames: strings.Join(attachNames, " "),
HasAttachment: len(pm.Attachments) > 0,
Date: pm.Date,
Size: int64(len(raw)),
TenantID: &tid,
}
if err := idx.IndexSync(doc); err != nil {
logger.Warn("reindex tenant: index failed", "tenant_id", tenantID, "id", id, "err", err)
errCount++
continue
}
indexed++
}
logger.Info("reindex tenant: complete", "tenant_id", tenantID, "indexed", indexed, "errors", errCount)
return nil
}
// runIntegrityCheck verifies all stored emails every 5 minutes by re-computing // runIntegrityCheck verifies all stored emails every 5 minutes by re-computing
// their SHA-256 and comparing it to the stored file ID. // their SHA-256 and comparing it to the stored file ID.
func runIntegrityCheck(ctx context.Context, store *storage.Store, logger *slog.Logger) { func runIntegrityCheck(ctx context.Context, store *storage.Store, logger *slog.Logger) {
+308
View File
@@ -385,6 +385,314 @@ func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// ── PROJ-23: Per-Tenant LDAP handlers (Phase B) ─────────────────────────────
// SetTenantLDAP wires the per-tenant LDAP config store into the API server and
// registers the tenant LDAP routes.
func (s *Server) SetTenantLDAP(store *ldapcfg.TenantStore) {
s.tenantLdapStore = store
// domain_admin routes — tenant_id comes from JWT session, NOT from URL
s.mux.HandleFunc("GET /api/tenant/ldap", s.authAdmin(s.handleGetTenantLDAP))
s.mux.HandleFunc("PUT /api/tenant/ldap", s.authAdmin(s.handleSaveTenantLDAP))
s.mux.HandleFunc("DELETE /api/tenant/ldap", s.authAdmin(s.handleDeleteTenantLDAP))
s.mux.HandleFunc("POST /api/tenant/ldap/test", s.authAdmin(s.handleTestTenantLDAP))
// superadmin routes — tenant_id from URL parameter
s.mux.HandleFunc("GET /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminGetTenantLDAP)))
s.mux.HandleFunc("PUT /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminSaveTenantLDAP)))
s.mux.HandleFunc("DELETE /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminDeleteTenantLDAP)))
s.mux.HandleFunc("POST /api/admin/tenants/{id}/ldap/test", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminTestTenantLDAP)))
}
// ── domain_admin handlers (own tenant) ──────────────────────────────────────
func (s *Server) handleGetTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "no tenant context")
return
}
cfg, err := s.tenantLdapStore.Get(r.Context(), *sess.TenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load tenant ldap config")
return
}
if cfg == nil {
writeError(w, http.StatusNotFound, "no tenant ldap config")
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "no tenant context")
return
}
var cfg ldapcfg.TenantLDAPConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
cfg.TenantID = *sess.TenantID
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
return
}
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_saved",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gespeichert",
})
saved, err := s.tenantLdapStore.Get(r.Context(), *sess.TenantID)
if err != nil || saved == nil {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "no tenant context")
return
}
if err := s.tenantLdapStore.Delete(r.Context(), *sess.TenantID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete tenant ldap config")
return
}
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_deleted",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gelöscht",
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleTestTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusBadRequest, "no tenant context")
return
}
var body struct {
UseSaved bool `json:"use_saved"`
ldapcfg.TenantLDAPConfig
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
testCfg := s.buildTenantTestConfig(r, body.UseSaved, *sess.TenantID, body.TenantLDAPConfig)
if testCfg == nil {
writeError(w, http.StatusNotFound, "no tenant ldap config saved")
return
}
result := ldapauth.TestConnection(*testCfg)
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_connection_test",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: result.OK,
Detail: result.Message,
})
writeJSON(w, http.StatusOK, result)
}
// ── superadmin handlers (arbitrary tenant) ──────────────────────────────────
func (s *Server) handleAdminGetTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
cfg, err := s.tenantLdapStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load tenant ldap config")
return
}
if cfg == nil {
writeError(w, http.StatusNotFound, "no tenant ldap config")
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var cfg ldapcfg.TenantLDAPConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
cfg.TenantID = id
sess := sessionFromCtx(r.Context())
if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config")
return
}
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_saved",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gespeichert (tenant " + strconv.FormatInt(id, 10) + ")",
})
saved, err := s.tenantLdapStore.Get(r.Context(), id)
if err != nil || saved == nil {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleAdminDeleteTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
if err := s.tenantLdapStore.Delete(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete tenant ldap config")
return
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_config_deleted",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "Mandant-LDAP-Konfiguration gelöscht (tenant " + strconv.FormatInt(id, 10) + ")",
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Request) {
if s.tenantLdapStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var body struct {
UseSaved bool `json:"use_saved"`
ldapcfg.TenantLDAPConfig
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
testCfg := s.buildTenantTestConfig(r, body.UseSaved, id, body.TenantLDAPConfig)
if testCfg == nil {
writeError(w, http.StatusNotFound, "no tenant ldap config saved")
return
}
result := ldapauth.TestConnection(*testCfg)
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: "tenant_ldap_connection_test",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: result.OK,
Detail: result.Message + " (tenant " + strconv.FormatInt(id, 10) + ")",
})
writeJSON(w, http.StatusOK, result)
}
// buildTenantTestConfig constructs an ldapauth.Config for testing, either from
// the saved tenant config or from the provided request body.
func (s *Server) buildTenantTestConfig(r *http.Request, useSaved bool, tenantID int64, provided ldapcfg.TenantLDAPConfig) *ldapauth.Config {
if useSaved {
saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID)
if err != nil || saved == nil {
return nil
}
return &ldapauth.Config{
URL: saved.URL,
BindDN: saved.BindDN,
BindPassword: saved.BindPassword,
BaseDN: saved.BaseDN,
UserFilter: saved.UserFilter,
TLS: saved.TLS,
TLSSkipVerify: saved.TLSSkipVerify,
}
}
return &ldapauth.Config{
URL: provided.URL,
BindDN: provided.BindDN,
BindPassword: provided.BindPassword,
BaseDN: provided.BaseDN,
UserFilter: provided.UserFilter,
TLS: provided.TLS,
TLSSkipVerify: provided.TLSSkipVerify,
}
}
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
func parseTenantID(r *http.Request) (int64, error) { func parseTenantID(r *http.Request) (int64, error) {
+24 -6
View File
@@ -78,8 +78,10 @@ type Server struct {
pop3Store *pop3store.Store pop3Store *pop3store.Store
pop3Importer *pop3store.Importer pop3Importer *pop3store.Importer
uploadJobs sync.Map // jobID → *UploadJob uploadJobs sync.Map // jobID → *UploadJob
ldapStore *ldapcfg.Store ldapStore *ldapcfg.Store
tenantStore *tenantstore.Store tenantStore *tenantstore.Store
tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config
idxMgr *index.TenantIndexManager // PROJ-21 Phase 4: per-tenant Xapian index
} }
// SetSMTPDaemon wires the SMTP daemon into the API server after construction. // SetSMTPDaemon wires the SMTP daemon into the API server after construction.
@@ -100,6 +102,11 @@ func (s *Server) SetPop3(store *pop3store.Store, importer *pop3store.Importer) {
s.pop3Importer = importer s.pop3Importer = importer
} }
// SetIndexManager wires the per-tenant index manager into the API server (PROJ-21 Phase 4).
func (s *Server) SetIndexManager(mgr *index.TenantIndexManager) {
s.idxMgr = mgr
}
// New creates and wires up a new API server. // New creates and wires up a new API server.
func New( func New(
cfg config.APIConfig, cfg config.APIConfig,
@@ -615,15 +622,26 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
} }
} }
result, err := s.idx.Search(req) // PROJ-21 Phase 4: Use per-tenant index when available; fall back to
// global index + post-filter when the tenant index manager is not wired.
tenantID := tenantFromCtx(r.Context())
searchIdx := s.idx
usedTenantIndex := false
if s.idxMgr != nil && tenantID != nil {
searchIdx = s.idxMgr.ForTenant(tenantID)
usedTenantIndex = true
}
result, err := searchIdx.Search(req)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "search failed") writeError(w, http.StatusInternalServerError, "search failed")
return return
} }
// Tenant isolation: filter results to only this tenant's emails. // Fallback tenant isolation: post-filter when we used the global index
tenantID := tenantFromCtx(r.Context()) // but the user belongs to a tenant. This is the legacy path; the per-tenant
if tenantID != nil && len(result.Hits) > 0 { // index path above makes this unnecessary.
if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 {
allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID) allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID)
if idErr == nil { if idErr == nil {
allowed := make(map[string]struct{}, len(allowedIDs)) allowed := make(map[string]struct{}, len(allowedIDs))
+79 -4
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@@ -24,11 +25,20 @@ type Session struct {
TenantSlug string TenantSlug string
} }
// TenantDomainLookup is the interface used by Manager to resolve an email domain
// to a tenant ID. Implemented by tenantstore.Store.
type TenantDomainLookup interface {
// GetTenantIDByDomain returns the tenant_id for a given domain, or nil if not found.
GetTenantIDByDomain(ctx context.Context, domain string) (*int64, error)
}
// Manager handles login, token issuance, validation, and logout. // Manager handles login, token issuance, validation, and logout.
type Manager struct { type Manager struct {
store *userstore.Store store *userstore.Store
ldapStore *ldapcfg.Store ldapStore *ldapcfg.Store
jwtSecret []byte jwtSecret []byte
tenantLdapStore *ldapcfg.TenantStore // PROJ-23: per-tenant LDAP config
tenantLookup TenantDomainLookup // PROJ-23: domain -> tenant_id resolution
} }
// New creates a new auth Manager. // New creates a new auth Manager.
@@ -41,6 +51,13 @@ func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Ma
} }
} }
// SetTenantLDAP wires the per-tenant LDAP config store and tenant domain lookup
// into the auth manager. Both may be nil to disable per-tenant LDAP.
func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLookup TenantDomainLookup) {
m.tenantLdapStore = tenantLdapStore
m.tenantLookup = tenantLookup
}
// Login verifies credentials and returns a signed JWT token. // Login verifies credentials and returns a signed JWT token.
// It first attempts a local password check. If that fails and LDAP is // It first attempts a local password check. If that fails and LDAP is
// configured and enabled, it falls back to LDAP authentication. // configured and enabled, it falls back to LDAP authentication.
@@ -51,7 +68,7 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
return m.issueToken(user) return m.issueToken(user)
} }
// 2. LDAP fallback when the store is wired and the config is enabled. // 2. Global LDAP fallback when the store is wired and the config is enabled.
if m.ldapStore != nil { if m.ldapStore != nil {
cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background()) cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
if ldapErr == nil && cfg != nil && cfg.Enabled { if ldapErr == nil && cfg != nil && cfg.Enabled {
@@ -93,6 +110,54 @@ func (m *Manager) Login(username, password string) (string, *userstore.User, err
} }
} }
// 3. PROJ-23: Per-tenant LDAP fallback — extract domain from username,
// look up the tenant, and try LDAP auth with that tenant's config.
if m.tenantLdapStore != nil && m.tenantLookup != nil {
if domain := extractDomain(username); domain != "" {
ctx := context.Background()
tenantID, lookupErr := m.tenantLookup.GetTenantIDByDomain(ctx, domain)
if lookupErr == nil && tenantID != nil {
tcfg, tErr := m.tenantLdapStore.GetWithPassword(ctx, *tenantID)
if tErr == nil && tcfg != nil && tcfg.Enabled {
attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
URL: tcfg.URL,
BindDN: tcfg.BindDN,
BindPassword: tcfg.BindPassword,
BaseDN: tcfg.BaseDN,
UserFilter: tcfg.UserFilter,
TLS: tcfg.TLS,
TLSSkipVerify: tcfg.TLSSkipVerify,
}, username, password)
if authErr == nil {
role := tcfg.DefaultRole
if role == "" {
role = userstore.RoleUser
}
memberOf := attrs["memberOf"]
if memberOf != "" {
for _, gm := range tcfg.GroupMappings {
if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
role = gm.Role
break
}
}
}
email := attrs["mail"]
if email == "" {
email = username
}
ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
if upsertErr == nil {
return m.issueToken(ldapUser)
}
}
}
}
}
}
return "", nil, fmt.Errorf("auth: login: invalid credentials") return "", nil, fmt.Errorf("auth: login: invalid credentials")
} }
@@ -281,6 +346,16 @@ func trimSpace(s string) string {
return s[start:end] return s[start:end]
} }
// extractDomain returns the domain part from a username containing '@'.
// Returns empty string if no '@' is found.
func extractDomain(username string) string {
idx := strings.LastIndex(username, "@")
if idx < 0 || idx == len(username)-1 {
return ""
}
return strings.ToLower(username[idx+1:])
}
// generateJTI returns a cryptographically random identifier for a JWT. // generateJTI returns a cryptographically random identifier for a JWT.
func generateJTI() string { func generateJTI() string {
b := make([]byte, 16) b := make([]byte, 16)
+1
View File
@@ -16,6 +16,7 @@ type MailDocument struct {
HasAttachment bool HasAttachment bool
Date time.Time Date time.Time
Size int64 Size int64
TenantID *int64 // nil = global / superadmin context
} }
// SearchRequest specifies search parameters. // SearchRequest specifies search parameters.
+110
View File
@@ -0,0 +1,110 @@
package index
import (
"fmt"
"os"
"path/filepath"
"sync"
)
// TenantIndexManager manages a pool of Xapian indexes, one per tenant.
// Tenant 0 / nil maps to the global index used by superadmin and as a fallback.
type TenantIndexManager struct {
basePath string
batchSize int
backend string
mu sync.RWMutex
pool map[int64]Indexer // tenant_id -> Indexer
global Indexer // tenant_id == 0 / nil (superadmin, fallback)
}
// NewTenantIndexManager creates a new TenantIndexManager.
// The global index lives at basePath directly; per-tenant indexes at basePath/tenant-<id>/.
func NewTenantIndexManager(basePath string, batchSize int, backend string) (*TenantIndexManager, error) {
// Ensure the base path directory exists.
if err := os.MkdirAll(basePath, 0o750); err != nil {
return nil, fmt.Errorf("tenant index manager: mkdir base: %w", err)
}
global, err := New(basePath, batchSize, backend)
if err != nil {
return nil, fmt.Errorf("tenant index manager: open global: %w", err)
}
return &TenantIndexManager{
basePath: basePath,
batchSize: batchSize,
backend: backend,
pool: make(map[int64]Indexer),
global: global,
}, nil
}
// ForTenant returns the Indexer for the given tenant.
// nil or pointer to 0 returns the global index.
// Other tenant IDs lazily open a per-tenant index at basePath/tenant-<id>/.
func (m *TenantIndexManager) ForTenant(tenantID *int64) Indexer {
if tenantID == nil || *tenantID == 0 {
return m.global
}
id := *tenantID
// Fast path: read lock.
m.mu.RLock()
if idx, ok := m.pool[id]; ok {
m.mu.RUnlock()
return idx
}
m.mu.RUnlock()
// Slow path: write lock, create if not exists.
m.mu.Lock()
defer m.mu.Unlock()
// Double check after acquiring write lock.
if idx, ok := m.pool[id]; ok {
return idx
}
dir := filepath.Join(m.basePath, fmt.Sprintf("tenant-%d", id))
if err := os.MkdirAll(dir, 0o750); err != nil {
// Return global as fallback on error.
return m.global
}
idx, err := New(dir, m.batchSize, m.backend)
if err != nil {
// Return global as fallback on error.
return m.global
}
m.pool[id] = idx
return idx
}
// Global returns the global (non-tenant) index.
func (m *TenantIndexManager) Global() Indexer {
return m.global
}
// Close closes all indexes (global + per-tenant).
func (m *TenantIndexManager) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
var firstErr error
for id, idx := range m.pool {
if err := idx.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("close tenant-%d index: %w", id, err)
}
delete(m.pool, id)
}
if m.global != nil {
if err := m.global.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("close global index: %w", err)
}
}
return firstErr
}
+90
View File
@@ -0,0 +1,90 @@
package index
import (
"log/slog"
"sync"
)
// TenantIndexWorker processes MailDocument indexing requests asynchronously,
// routing each document to the correct per-tenant Xapian index via TenantIndexManager.
type TenantIndexWorker struct {
mgr *TenantIndexManager
queue chan MailDocument
done chan struct{}
wg sync.WaitGroup
logger *slog.Logger
}
// NewTenantWorker creates a new TenantIndexWorker with the given queue capacity.
func NewTenantWorker(mgr *TenantIndexManager, queueSize int, logger *slog.Logger) *TenantIndexWorker {
if queueSize <= 0 {
queueSize = 1000
}
return &TenantIndexWorker{
mgr: mgr,
queue: make(chan MailDocument, queueSize),
done: make(chan struct{}),
logger: logger,
}
}
// Submit enqueues a document for background indexing. If the queue is full,
// the document is dropped and a warning is logged.
func (w *TenantIndexWorker) Submit(doc MailDocument) {
select {
case w.queue <- doc:
// queued
default:
w.logger.Warn("tenant index worker: queue full, dropping document", "id", doc.ID)
}
}
// Start launches the background goroutine that processes the queue.
func (w *TenantIndexWorker) Start() {
w.wg.Add(1)
go func() {
defer w.wg.Done()
w.logger.Info("tenant index worker: started", "queue_size", cap(w.queue))
for {
select {
case doc, ok := <-w.queue:
if !ok {
return
}
w.indexDoc(doc)
case <-w.done:
// Drain remaining items in the queue before exiting.
for {
select {
case doc, ok := <-w.queue:
if !ok {
return
}
w.indexDoc(doc)
default:
return
}
}
}
}
}()
}
// Stop signals the worker to drain remaining items and stop.
func (w *TenantIndexWorker) Stop() {
close(w.done)
w.wg.Wait()
w.logger.Info("tenant index worker: stopped")
}
// QueueLen returns the current number of items waiting in the queue.
func (w *TenantIndexWorker) QueueLen() int {
return len(w.queue)
}
func (w *TenantIndexWorker) indexDoc(doc MailDocument) {
idx := w.mgr.ForTenant(doc.TenantID)
if err := idx.IndexSync(doc); err != nil {
w.logger.Error("tenant index worker: index failed", "id", doc.ID, "tenant_id", doc.TenantID, "err", err)
}
}
+255
View File
@@ -0,0 +1,255 @@
// Package ldapconfig — TenantStore manages per-tenant LDAP/AD configuration
// stored in the tenant_ldap table. The bind password is encrypted with
// AES-256-GCM using a SHA-256 derived key, identical to the global Store.
package ldapconfig
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// TenantLDAPConfig is the persisted LDAP/AD configuration for a single tenant.
type TenantLDAPConfig struct {
TenantID int64 `json:"tenant_id"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
BaseDN string `json:"base_dn"`
UserFilter string `json:"user_filter"`
TLS bool `json:"tls"`
TLSSkipVerify bool `json:"tls_skip_verify"`
DefaultRole string `json:"default_role"`
GroupMappings []GroupMapping `json:"group_mappings"`
}
// TenantLDAPSummary is the abbreviated view returned by ListAll (for superadmin).
type TenantLDAPSummary struct {
TenantID int64 `json:"tenant_id"`
TenantName string `json:"tenant_name"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
}
// TenantStore manages per-tenant LDAP configuration persistence.
type TenantStore struct {
pool *pgxpool.Pool
encKey [32]byte
}
// NewTenantStore connects to PostgreSQL and returns a TenantStore.
// The tenant_ldap table is created by the tenantstore package schema migration.
// keyHex is the hex-encoded AES key (same as used for the global LDAP store).
func NewTenantStore(dsn, keyHex string) (*TenantStore, error) {
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("tenant ldap store: connect: %w", err)
}
key := sha256.Sum256([]byte(keyHex))
return &TenantStore{pool: pool, encKey: key}, nil
}
// Close releases the underlying connection pool.
func (s *TenantStore) Close() {
s.pool.Close()
}
// Get returns the LDAP configuration for a tenant with the bind password masked.
// Returns nil, nil when no configuration exists.
func (s *TenantStore) Get(ctx context.Context, tenantID int64) (*TenantLDAPConfig, error) {
cfg, err := s.query(ctx, tenantID)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
if cfg.BindPassword != "" {
cfg.BindPassword = "\u2022\u2022\u2022\u2022\u2022\u2022"
}
return cfg, nil
}
// GetWithPassword returns the LDAP configuration including the decrypted bind password.
// Returns nil, nil when no configuration exists.
func (s *TenantStore) GetWithPassword(ctx context.Context, tenantID int64) (*TenantLDAPConfig, error) {
return s.query(ctx, tenantID)
}
// Save upserts the LDAP configuration for a tenant.
// When bindPassword is empty the existing stored password is preserved.
func (s *TenantStore) Save(ctx context.Context, cfg TenantLDAPConfig, updatedBy string) error {
mappingsJSON, err := json.Marshal(cfg.GroupMappings)
if err != nil {
return fmt.Errorf("tenant ldap store: marshal group_mappings: %w", err)
}
var encryptedPw []byte
if cfg.BindPassword != "" {
encryptedPw, err = s.encrypt(cfg.BindPassword)
if err != nil {
return fmt.Errorf("tenant ldap store: encrypt password: %w", err)
}
} else {
// Preserve existing password.
existing, qErr := s.query(ctx, cfg.TenantID)
if qErr == nil && existing != nil && existing.BindPassword != "" {
encryptedPw, err = s.encrypt(existing.BindPassword)
if err != nil {
return fmt.Errorf("tenant ldap store: re-encrypt existing password: %w", err)
}
}
}
_, err = s.pool.Exec(ctx, `
INSERT INTO tenant_ldap
(tenant_id, enabled, url, bind_dn, bind_password, base_dn, user_filter,
tls, tls_skip_verify, default_role, group_mappings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
ON CONFLICT (tenant_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
url = EXCLUDED.url,
bind_dn = EXCLUDED.bind_dn,
bind_password = CASE WHEN EXCLUDED.bind_password IS NULL THEN tenant_ldap.bind_password ELSE EXCLUDED.bind_password END,
base_dn = EXCLUDED.base_dn,
user_filter = EXCLUDED.user_filter,
tls = EXCLUDED.tls,
tls_skip_verify = EXCLUDED.tls_skip_verify,
default_role = EXCLUDED.default_role,
group_mappings = EXCLUDED.group_mappings
`,
cfg.TenantID, cfg.Enabled, cfg.URL, cfg.BindDN, encryptedPw, cfg.BaseDN,
cfg.UserFilter, cfg.TLS, cfg.TLSSkipVerify, cfg.DefaultRole,
string(mappingsJSON),
)
if err != nil {
return fmt.Errorf("tenant ldap store: upsert: %w", err)
}
return nil
}
// Delete removes the LDAP configuration for a tenant.
func (s *TenantStore) Delete(ctx context.Context, tenantID int64) error {
_, err := s.pool.Exec(ctx, `DELETE FROM tenant_ldap WHERE tenant_id = $1`, tenantID)
return err
}
// ListAll returns a summary of all tenant LDAP configurations (for superadmin).
func (s *TenantStore) ListAll(ctx context.Context) ([]TenantLDAPSummary, error) {
rows, err := s.pool.Query(ctx, `
SELECT tl.tenant_id, t.name, tl.enabled, tl.url
FROM tenant_ldap tl
JOIN tenants t ON t.id = tl.tenant_id
ORDER BY tl.tenant_id
`)
if err != nil {
return nil, fmt.Errorf("tenant ldap store: list all: %w", err)
}
defer rows.Close()
var summaries []TenantLDAPSummary
for rows.Next() {
var s TenantLDAPSummary
if err := rows.Scan(&s.TenantID, &s.TenantName, &s.Enabled, &s.URL); err != nil {
return nil, fmt.Errorf("tenant ldap store: scan: %w", err)
}
summaries = append(summaries, s)
}
if summaries == nil {
summaries = []TenantLDAPSummary{}
}
return summaries, rows.Err()
}
// query reads the tenant_ldap row for a tenant and decrypts the bind password.
func (s *TenantStore) query(ctx context.Context, tenantID int64) (*TenantLDAPConfig, error) {
row := s.pool.QueryRow(ctx, `
SELECT tenant_id, enabled, url, bind_dn, bind_password, base_dn, user_filter,
tls, tls_skip_verify, default_role, group_mappings
FROM tenant_ldap WHERE tenant_id = $1
`, tenantID)
var cfg TenantLDAPConfig
var encPw []byte
var mappingsRaw []byte
err := row.Scan(
&cfg.TenantID, &cfg.Enabled, &cfg.URL, &cfg.BindDN, &encPw,
&cfg.BaseDN, &cfg.UserFilter, &cfg.TLS, &cfg.TLSSkipVerify,
&cfg.DefaultRole, &mappingsRaw,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("tenant ldap store: query: %w", err)
}
if len(encPw) > 0 {
plain, err := s.decrypt(encPw)
if err != nil {
return nil, fmt.Errorf("tenant ldap store: decrypt password: %w", err)
}
cfg.BindPassword = plain
}
if len(mappingsRaw) > 0 {
if err := json.Unmarshal(mappingsRaw, &cfg.GroupMappings); err != nil {
return nil, fmt.Errorf("tenant ldap store: unmarshal group_mappings: %w", err)
}
}
if cfg.GroupMappings == nil {
cfg.GroupMappings = []GroupMapping{}
}
return &cfg, nil
}
// encrypt encrypts plaintext with AES-256-GCM using the store's key.
func (s *TenantStore) encrypt(plaintext string) ([]byte, error) {
block, err := aes.NewCipher(s.encKey[:])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil
}
// decrypt decrypts ciphertext produced by encrypt.
func (s *TenantStore) decrypt(ciphertext []byte) (string, error) {
block, err := aes.NewCipher(s.encKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(ciphertext) < gcm.NonceSize() {
return "", fmt.Errorf("tenant ldap store: ciphertext too short")
}
nonce, data := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, data, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
+14
View File
@@ -252,6 +252,20 @@ func (s *Store) GetByDomain(ctx context.Context, domain string) (*Tenant, error)
return &t, nil 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. // getDomain is a private helper to load a TenantDomain by its primary key.
func (s *Store) getDomain(ctx context.Context, id int64) (*TenantDomain, error) { func (s *Store) getDomain(ctx context.Context, id int64) (*TenantDomain, error) {
row := s.pool.QueryRow(ctx, row := s.pool.QueryRow(ctx,