Files
archivmail/internal/api/ldap_tenants.go
T
sysops ac91dceac2 feat(PROJ-22): LDAP Web-GUI + feat(PROJ-21): Multi-Tenancy Phase 1
PROJ-22 – LDAP Web-GUI Konfiguration & Test:
- internal/ldapconfig/store.go: AES-256-GCM Passwortspeicherung, CRUD Upsert (id=1)
- internal/ldapauth/client.go: TestConnection (RootDSE, UserCount) + Authenticate (2-step bind)
- internal/auth/auth.go: LDAP-Fallback in Login(), Gruppen-Rollenzuordnung, issueToken helper
- internal/api/ldap_tenants.go: GET/PUT/DELETE/POST-test /api/admin/ldap mit Audit-Log
- go.mod: github.com/go-ldap/ldap/v3 v3.4.8 hinzugefügt
- Frontend: LDAPConfig/LDAPTestResult Typen, LDAP-Tab mit Gruppen-Mappings + Testergebnis

PROJ-21 Phase 1+6+7 – Multi-Tenancy Grundstruktur:
- internal/tenantstore/store.go: tenants, tenant_domains, tenant_ldap Schema; Migration users/audit_log
- API: 8 Tenant-Routen (CRUD + Domain-Management) via SetTenants()
- cmd/archivmail/main.go: ldapSt + tenantSt initialisiert
- Frontend: Mandanten-Tab mit Tabelle, Domain-Dialog, Deaktivieren/Löschen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 20:27:56 +01:00

394 lines
12 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/archivmail/internal/audit"
"github.com/archivmail/internal/ldapauth"
ldapcfg "github.com/archivmail/internal/ldapconfig"
"github.com/archivmail/internal/tenantstore"
"github.com/archivmail/internal/userstore"
)
// ── Server extension fields and wiring ──────────────────────────────────────
// ldapStore and tenantStore are added to the Server struct via SetLDAP / SetTenants.
// They are declared separately from server.go to keep that file unmodified.
// Access is via the embedded pointer fields on *Server.
// SetLDAP wires the LDAP config store into the API server.
func (s *Server) SetLDAP(store *ldapcfg.Store) {
s.ldapStore = store
// Register LDAP routes only after the store is available.
s.mux.HandleFunc("GET /api/admin/ldap", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleGetLDAP)))
s.mux.HandleFunc("PUT /api/admin/ldap", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSaveLDAP)))
s.mux.HandleFunc("DELETE /api/admin/ldap", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteLDAP)))
s.mux.HandleFunc("POST /api/admin/ldap/test", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleTestLDAP)))
}
// SetTenants wires the tenant store into the API server.
func (s *Server) SetTenants(store *tenantstore.Store) {
s.tenantStore = store
// Register tenant routes only after the store is available.
s.mux.HandleFunc("GET /api/tenants", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenants)))
s.mux.HandleFunc("POST /api/tenants", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateTenant)))
s.mux.HandleFunc("GET /api/tenants/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleGetTenant)))
s.mux.HandleFunc("PATCH /api/tenants/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateTenant)))
s.mux.HandleFunc("DELETE /api/tenants/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteTenant)))
s.mux.HandleFunc("GET /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantDomains)))
s.mux.HandleFunc("POST /api/tenants/{id}/domains", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleAddTenantDomain)))
s.mux.HandleFunc("DELETE /api/tenants/{id}/domains/{did}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleRemoveTenantDomain)))
}
// ── LDAP handlers ────────────────────────────────────────────────────────────
func (s *Server) handleGetLDAP(w http.ResponseWriter, r *http.Request) {
if s.ldapStore == nil {
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
return
}
cfg, err := s.ldapStore.Get(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load ldap config")
return
}
if cfg == nil {
writeError(w, http.StatusNotFound, "no ldap config")
return
}
writeJSON(w, http.StatusOK, cfg)
}
func (s *Server) handleSaveLDAP(w http.ResponseWriter, r *http.Request) {
if s.ldapStore == nil {
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
return
}
var cfg ldapcfg.LDAPConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
sess := sessionFromCtx(r.Context())
if err := s.ldapStore.Save(r.Context(), cfg, sess.Username); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save ldap config")
return
}
s.audlog.Log(audit.Entry{
EventType: "ldap_config_saved",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "LDAP-Konfiguration gespeichert",
})
// Return the saved config (with masked password)
saved, err := s.ldapStore.Get(r.Context())
if err != nil || saved == nil {
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteLDAP(w http.ResponseWriter, r *http.Request) {
if s.ldapStore == nil {
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
return
}
if err := s.ldapStore.Delete(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete ldap config")
return
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: "ldap_config_deleted",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "LDAP-Konfiguration gelöscht",
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleTestLDAP(w http.ResponseWriter, r *http.Request) {
if s.ldapStore == nil {
writeError(w, http.StatusServiceUnavailable, "ldap store not available")
return
}
var body struct {
UseSaved bool `json:"use_saved"`
ldapcfg.LDAPConfig
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var testCfg ldapauth.Config
if body.UseSaved {
saved, err := s.ldapStore.GetWithPassword(r.Context())
if err != nil || saved == nil {
writeError(w, http.StatusNotFound, "no ldap config saved")
return
}
testCfg = ldapauth.Config{
URL: saved.URL,
BindDN: saved.BindDN,
BindPassword: saved.BindPassword,
BaseDN: saved.BaseDN,
UserFilter: saved.UserFilter,
TLS: saved.TLS,
TLSSkipVerify: saved.TLSSkipVerify,
}
} else {
testCfg = ldapauth.Config{
URL: body.URL,
BindDN: body.BindDN,
BindPassword: body.BindPassword,
BaseDN: body.BaseDN,
UserFilter: body.UserFilter,
TLS: body.TLS,
TLSSkipVerify: body.TLSSkipVerify,
}
}
result := ldapauth.TestConnection(testCfg)
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: "ldap_connection_test",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: result.OK,
Detail: result.Message,
})
writeJSON(w, http.StatusOK, result)
}
// ── Tenant handlers ──────────────────────────────────────────────────────────
func (s *Server) handleListTenants(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
tenants, err := s.tenantStore.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list tenants")
return
}
writeJSON(w, http.StatusOK, tenants)
}
func (s *Server) handleCreateTenant(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Slug == "" {
writeError(w, http.StatusBadRequest, "name and slug are required")
return
}
tenant, err := s.tenantStore.Create(r.Context(), req.Name, req.Slug)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create tenant")
return
}
sess := sessionFromCtx(r.Context())
s.audlog.Log(audit.Entry{
EventType: "tenant_created",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "Mandant erstellt: " + req.Name,
})
writeJSON(w, http.StatusCreated, tenant)
}
func (s *Server) handleGetTenant(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
tenant, err := s.tenantStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "tenant not found")
return
}
writeJSON(w, http.StatusOK, tenant)
}
func (s *Server) handleUpdateTenant(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var req struct {
Name string `json:"name"`
Active *bool `json:"active"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Load existing to keep unset fields
existing, err := s.tenantStore.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "tenant not found")
return
}
name := existing.Name
active := existing.Active
if req.Name != "" {
name = req.Name
}
if req.Active != nil {
active = *req.Active
}
tenant, err := s.tenantStore.Update(r.Context(), id, name, active)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update tenant")
return
}
writeJSON(w, http.StatusOK, tenant)
}
func (s *Server) handleDeleteTenant(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
sess := sessionFromCtx(r.Context())
if err := s.tenantStore.Delete(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete tenant")
return
}
s.audlog.Log(audit.Entry{
EventType: "tenant_deleted",
Username: sess.Username,
IPAddress: remoteIP(r),
Success: true,
Detail: "Mandant gelöscht: " + strconv.FormatInt(id, 10),
})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListTenantDomains(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
domains, err := s.tenantStore.ListDomains(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list domains")
return
}
writeJSON(w, http.StatusOK, domains)
}
func (s *Server) handleAddTenantDomain(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
id, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
var req struct {
Domain string `json:"domain"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
writeError(w, http.StatusBadRequest, "domain is required")
return
}
domain, err := s.tenantStore.AddDomain(r.Context(), id, req.Domain)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to add domain")
return
}
writeJSON(w, http.StatusCreated, domain)
}
func (s *Server) handleRemoveTenantDomain(w http.ResponseWriter, r *http.Request) {
if s.tenantStore == nil {
writeError(w, http.StatusServiceUnavailable, "tenant store not available")
return
}
tenantID, err := parseTenantID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid tenant id")
return
}
didStr := r.PathValue("did")
domainID, err := strconv.ParseInt(didStr, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid domain id")
return
}
if err := s.tenantStore.RemoveDomain(r.Context(), tenantID, domainID); err != nil {
writeError(w, http.StatusInternalServerError, "failed to remove domain")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ── helpers ──────────────────────────────────────────────────────────────────
func parseTenantID(r *http.Request) (int64, error) {
return strconv.ParseInt(r.PathValue("id"), 10, 64)
}