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