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()
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+24
-6
@@ -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))
|
||||
|
||||
+79
-4
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@ type MailDocument struct {
|
||||
HasAttachment bool
|
||||
Date time.Time
|
||||
Size int64
|
||||
TenantID *int64 // nil = global / superadmin context
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user