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>
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ import (
|
||||
"github.com/archivmail/internal/auth"
|
||||
imapstore "github.com/archivmail/internal/imap"
|
||||
"github.com/archivmail/internal/index"
|
||||
ldapcfg "github.com/archivmail/internal/ldapconfig"
|
||||
pop3store "github.com/archivmail/internal/pop3"
|
||||
"github.com/archivmail/internal/smtpd"
|
||||
"github.com/archivmail/internal/storage"
|
||||
"github.com/archivmail/internal/tenantstore"
|
||||
"github.com/archivmail/internal/userstore"
|
||||
"github.com/archivmail/pkg/mailparser"
|
||||
)
|
||||
@@ -50,6 +52,8 @@ type Server struct {
|
||||
pop3Store *pop3store.Store
|
||||
pop3Importer *pop3store.Importer
|
||||
uploadJobs sync.Map // jobID → *UploadJob
|
||||
ldapStore *ldapcfg.Store
|
||||
tenantStore *tenantstore.Store
|
||||
}
|
||||
|
||||
// SetSMTPDaemon wires the SMTP daemon into the API server after construction.
|
||||
|
||||
Reference in New Issue
Block a user