diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 8a2f49c..3b98293 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -104,7 +104,7 @@ func main() { } defer mailStore.Close() - // Index + // Index — per-tenant index manager (PROJ-21 Phase 4) indexBackend := cfg.Index.Backend if indexBackend == "" { indexBackend = "xapian" @@ -113,21 +113,24 @@ func main() { if batchSize <= 0 { batchSize = 100 } - idx, err := index.New(cfg.Index.Path, batchSize, indexBackend) + idxMgr, err := index.NewTenantIndexManager(cfg.Index.Path, batchSize, indexBackend) if err != nil { - logger.Error("index init failed", "err", err) + logger.Error("index manager init failed", "err", err) 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 if asyncQueueSize <= 0 { asyncQueueSize = 1000 } - worker := index.NewWorker(idx, asyncQueueSize, logger) - worker.Start() - defer worker.Stop() + tenantWorker := index.NewTenantWorker(idxMgr, asyncQueueSize, logger) + tenantWorker.Start() + defer tenantWorker.Stop() // User store users, err := userstore.New(cfg.Database.DSN()) @@ -186,6 +189,7 @@ func main() { } defer tenantSt.Close() srv.SetTenants(tenantSt) + srv.SetIndexManager(idxMgr) // Start SMTP daemon with index worker integration if cfg.SMTP.Bind == "" { @@ -193,7 +197,9 @@ func main() { } smtpDaemon := smtpd.New(cfg.SMTP, mailStore, logger) 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 if cfg.SMTP.TenantRouting == "domain" { @@ -220,6 +226,16 @@ func main() { // Wire LDAP config store into API server 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 srv.SetSMTPDaemon(smtpDaemon) @@ -247,7 +263,7 @@ func main() { srv.SetPop3(pop3St, pop3Imp) // 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 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. -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) if err != nil { 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, Date: pm.Date, Size: int64(len(raw)), + TenantID: tenantID, } 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 -// emails that have not yet been indexed. -func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, worker *index.IndexWorker, logger *slog.Logger) { +// emails that have not yet been indexed. Per-tenant indexing is handled by +// 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") count := 0 @@ -345,7 +364,8 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w if !alreadyIndexed { needIndex++ - submitToWorker(worker, store, raw, id, logger) + tenantID, _ := store.GetTenantForMail(ctx, id) + submitToWorker(worker, store, raw, id, tenantID, logger) } 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) } +// 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 // their SHA-256 and comparing it to the stored file ID. func runIntegrityCheck(ctx context.Context, store *storage.Store, logger *slog.Logger) { diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index 9872a56..2534acd 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -385,6 +385,314 @@ func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request 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 ────────────────────────────────────────────────────────────────── func parseTenantID(r *http.Request) (int64, error) { diff --git a/internal/api/server.go b/internal/api/server.go index 20bbded..1d91d45 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -78,8 +78,10 @@ type Server struct { pop3Store *pop3store.Store pop3Importer *pop3store.Importer uploadJobs sync.Map // jobID → *UploadJob - ldapStore *ldapcfg.Store - tenantStore *tenantstore.Store + ldapStore *ldapcfg.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. @@ -100,6 +102,11 @@ func (s *Server) SetPop3(store *pop3store.Store, importer *pop3store.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. func New( 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 { writeError(w, http.StatusInternalServerError, "search failed") return } - // Tenant isolation: filter results to only this tenant's emails. - tenantID := tenantFromCtx(r.Context()) - if tenantID != nil && len(result.Hits) > 0 { + // Fallback tenant isolation: post-filter when we used the global index + // but the user belongs to a tenant. This is the legacy path; the per-tenant + // index path above makes this unnecessary. + if tenantID != nil && !usedTenantIndex && len(result.Hits) > 0 { allowedIDs, idErr := s.store.GetAllIDsByTenant(r.Context(), tenantID) if idErr == nil { allowed := make(map[string]struct{}, len(allowedIDs)) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index e4c90e8..66b8254 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -24,11 +25,20 @@ type Session struct { 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. type Manager struct { - store *userstore.Store - ldapStore *ldapcfg.Store - jwtSecret []byte + store *userstore.Store + ldapStore *ldapcfg.Store + 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. @@ -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. // It first attempts a local password check. If that fails and LDAP is // 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) } - // 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 { cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background()) 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") } @@ -281,6 +346,16 @@ func trimSpace(s string) string { 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. func generateJTI() string { b := make([]byte, 16) diff --git a/internal/index/index.go b/internal/index/index.go index 4cbfa9f..a19d8ce 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -16,6 +16,7 @@ type MailDocument struct { HasAttachment bool Date time.Time Size int64 + TenantID *int64 // nil = global / superadmin context } // SearchRequest specifies search parameters. diff --git a/internal/index/tenant_manager.go b/internal/index/tenant_manager.go new file mode 100644 index 0000000..82c8fa7 --- /dev/null +++ b/internal/index/tenant_manager.go @@ -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-/. +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-/. +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 +} diff --git a/internal/index/tenant_worker.go b/internal/index/tenant_worker.go new file mode 100644 index 0000000..680f994 --- /dev/null +++ b/internal/index/tenant_worker.go @@ -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) + } +} diff --git a/internal/ldapconfig/tenant_store.go b/internal/ldapconfig/tenant_store.go new file mode 100644 index 0000000..c8e9f57 --- /dev/null +++ b/internal/ldapconfig/tenant_store.go @@ -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 +} diff --git a/internal/tenantstore/store.go b/internal/tenantstore/store.go index 0420caa..7a3249b 100644 --- a/internal/tenantstore/store.go +++ b/internal/tenantstore/store.go @@ -252,6 +252,20 @@ func (s *Store) GetByDomain(ctx context.Context, domain string) (*Tenant, error) 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,