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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user