ac91dceac2
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>
394 lines
12 KiB
Go
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)
|
|
}
|
|
|