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:
+96
-14
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+22
-4
@@ -80,6 +80,8 @@ type Server struct {
|
|||||||
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))
|
||||||
|
|||||||
+76
-1
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user