From ac91dceac27a599b552c8743bcb2a287afb8a172 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 17 Mar 2026 20:27:56 +0100 Subject: [PATCH] feat(PROJ-22): LDAP Web-GUI + feat(PROJ-21): Multi-Tenancy Phase 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/archivmail/main.go | 26 +- features/INDEX.md | 4 +- features/PROJ-21-multi-tenancy.md | 12 +- features/PROJ-22-ldap-webgui.md | 14 +- go.mod | 1 + internal/api/ldap_tenants.go | 393 ++++++++++++++++++ internal/api/server.go | 4 + internal/auth/auth.go | 114 +++++- internal/ldapauth/client.go | 195 +++++++++ internal/ldapconfig/store.go | 257 ++++++++++++ internal/tenantstore/store.go | 265 ++++++++++++ src/app/admin/page.tsx | 660 ++++++++++++++++++++++++++++++ src/lib/api.ts | 129 ++++++ 13 files changed, 2063 insertions(+), 11 deletions(-) create mode 100644 internal/api/ldap_tenants.go create mode 100644 internal/ldapauth/client.go create mode 100644 internal/ldapconfig/store.go create mode 100644 internal/tenantstore/store.go diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index eda8b89..79e5e39 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -20,9 +20,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" + tenantstore "github.com/archivmail/internal/tenantstore" "github.com/archivmail/internal/userstore" "github.com/archivmail/pkg/mailparser" ) @@ -121,8 +123,16 @@ func main() { logger.Error("seed users failed", "err", err) } - // Auth manager - authMgr := auth.New(users, nil, cfg.API.Secret) + // LDAP config store + ldapSt, err := ldapcfg.New(cfg.Database.DSN(), cfg.API.Secret) + if err != nil { + logger.Error("ldap config store init failed", "err", err) + os.Exit(1) + } + defer ldapSt.Close() + + // Auth manager (with LDAP fallback) + authMgr := auth.New(users, ldapSt, cfg.API.Secret) // API server apiCfg := config.APIConfig{ @@ -155,6 +165,9 @@ func main() { } defer smtpDaemon.Stop() + // Wire LDAP config store into API server + srv.SetLDAP(ldapSt) + // Wire SMTP daemon into API server for status endpoint srv.SetSMTPDaemon(smtpDaemon) @@ -171,6 +184,15 @@ func main() { defer imapSched.Stop() srv.SetImap(imapSt, imapImp, imapSched) + // Tenant store (Multi-Tenancy Phase 1) + tenantSt, err := tenantstore.New(cfg.Database.DSN()) + if err != nil { + logger.Error("tenant store init failed", "err", err) + os.Exit(1) + } + defer tenantSt.Close() + srv.SetTenants(tenantSt) + // POP3 store + importer pop3St, err := pop3store.New(cfg.Database.DSN(), cfg.API.Secret) if err != nil { diff --git a/features/INDEX.md b/features/INDEX.md index 6139611..c8e65d0 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -33,8 +33,8 @@ | PROJ-18 | E-Mail Integritätsprüfung | Deployed | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 | | PROJ-19 | Mailpiler → archivmail Migrationstool | Deployed | [PROJ-19](PROJ-19-import-piler.md) | 2026-03-17 | | PROJ-20 | Nutzer-Löschung & E-Mail-Verbleib (GoBD-konform) | Deployed | [PROJ-20](PROJ-20-nutzer-loeschung.md) | 2026-03-17 | -| PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | Planned | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 | -| PROJ-22 | LDAP / AD – Web-GUI Konfiguration & Test | Planned | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 | +| PROJ-21 | Multi-Mandanten-Fähigkeit (Multi-Tenancy) | In Progress | [PROJ-21](PROJ-21-multi-tenancy.md) | 2026-03-17 | +| PROJ-22 | LDAP / AD – Web-GUI Konfiguration & Test | Deployed | [PROJ-22](PROJ-22-ldap-webgui.md) | 2026-03-17 | diff --git a/features/PROJ-21-multi-tenancy.md b/features/PROJ-21-multi-tenancy.md index a263115..7b06170 100644 --- a/features/PROJ-21-multi-tenancy.md +++ b/features/PROJ-21-multi-tenancy.md @@ -1,10 +1,20 @@ --- id: PROJ-21 title: Multi-Mandanten-Fähigkeit (Multi-Tenancy) -status: Planned +status: In Progress created: 2026-03-17 --- +## Phase 1 implementiert (2026-03-17) + +- `internal/tenantstore/store.go` — DB-Schema (`tenants`, `tenant_domains`, `tenant_ldap`); Migration: `tenant_id` zu `users` + `audit_log`; CRUD + Domain-Management + `GetByDomain` für SMTP-Routing +- `internal/api/ldap_tenants.go` — 8 Tenant-API-Routen (LIST/CREATE/GET/PATCH/DELETE Tenants + Domain-Verwaltung); wired via `SetTenants()` +- `cmd/archivmail/main.go` — `tenantstore.New()` + `srv.SetTenants()` +- `src/lib/api.ts` — `Tenant`, `TenantDomain`, alle 7 API-Funktionen +- `src/app/admin/page.tsx` — Mandanten-Tab mit Tabelle, Domain-Dialog, Löschen-Bestätigung + +**Offene Phasen:** Phase 2 (userstore/storage tenant-aware), Phase 3 (Middleware), Phase 4 (Xapian-Index), Phase 5 (SMTP-Routing), Phase 8 (Migration) + ## Ziel Das System soll mehrere Kunden (Mandanten/Tenants) auf einer einzigen Instanz betreiben können. Jeder Mandant verwaltet seine eigene Domain, seine eigenen Nutzer und sieht ausschließlich seine eigenen E-Mails. Kunden-Admins können ihre Domain selbst verwalten und weitere Domain-Admins ernennen. diff --git a/features/PROJ-22-ldap-webgui.md b/features/PROJ-22-ldap-webgui.md index 2ff2c7b..5ecb6fa 100644 --- a/features/PROJ-22-ldap-webgui.md +++ b/features/PROJ-22-ldap-webgui.md @@ -1,11 +1,23 @@ --- id: PROJ-22 title: LDAP / Active Directory – Web-GUI Konfiguration & Test -status: Planned +status: Deployed created: 2026-03-17 depends_on: PROJ-16 --- +## Implementiert (2026-03-17) + +- `internal/ldapconfig/store.go` — CRUD für `ldap_config` Tabelle, AES-256-GCM Passwortverschlüsselung (identisch zu imap/store.go) +- `internal/ldapauth/client.go` — `TestConnection` + `Authenticate` via go-ldap/ldap/v3; STARTTLS + LDAPS + RootDSE-Abfrage + User-Zählung +- `internal/auth/auth.go` — `Login` mit LDAP-Fallback; `issueToken` als privater Helfer; Gruppen-Rollenzuordnung via `group_mappings` +- `internal/api/ldap_tenants.go` — Handler: GET/PUT/DELETE/POST-test `/api/admin/ldap`; Audit-Log für alle Aktionen +- `internal/api/server.go` — `ldapStore`/`tenantStore` Felder + Imports ergänzt +- `cmd/archivmail/main.go` — `ldapcfg.New()` initialisiert; `auth.New()` mit ldapStore; `srv.SetLDAP()` +- `src/lib/api.ts` — `LDAPConfig`, `LDAPTestResult`, `getLDAPConfig/saveLDAPConfig/deleteLDAPConfig/testLDAPConfig` +- `src/app/admin/page.tsx` — LDAP-Tab mit Formular, Passwort-Schutz, Gruppen-Mappings, Testergebnis-Card +- `go.mod` — `github.com/go-ldap/ldap/v3 v3.4.8` hinzugefügt + ## Ziel LDAP/AD-Verbindungen sollen vollständig über die Web-GUI eingetragen, bearbeitet und diff --git a/go.mod b/go.mod index 8a7ca27..0bc8de6 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.4 require ( github.com/emersion/go-imap/v2 v2.0.0-beta.8 github.com/emersion/go-smtp v0.24.0 + github.com/go-ldap/ldap/v3 v3.4.8 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/jackc/pgx/v5 v5.6.0 golang.org/x/crypto v0.23.0 diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go new file mode 100644 index 0000000..9872a56 --- /dev/null +++ b/internal/api/ldap_tenants.go @@ -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) +} + diff --git a/internal/api/server.go b/internal/api/server.go index 4582a3a..210b78d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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. diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f2c7939..8a4f36c 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + "context" "crypto/rand" "encoding/hex" "errors" @@ -8,6 +9,8 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/archivmail/internal/ldapauth" + ldapcfg "github.com/archivmail/internal/ldapconfig" "github.com/archivmail/internal/userstore" ) @@ -22,26 +25,77 @@ type Session struct { // Manager handles login, token issuance, validation, and logout. type Manager struct { store *userstore.Store - ldap interface{} // placeholder for LDAP provider + ldapStore *ldapcfg.Store jwtSecret []byte } // New creates a new auth Manager. -func New(store *userstore.Store, ldap interface{}, jwtSecret string) *Manager { +// ldapStore may be nil; in that case LDAP fallback is disabled. +func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string) *Manager { return &Manager{ store: store, - ldap: ldap, + ldapStore: ldapStore, jwtSecret: []byte(jwtSecret), } } // Login verifies credentials and returns a signed JWT token. +// It first attempts a local password check. If that fails and LDAP is +// configured and enabled, it falls back to LDAP authentication. func (m *Manager) Login(username, password string) (string, *userstore.User, error) { + // 1. Try local authentication first. user, err := m.store.VerifyPassword(username, password) - if err != nil { - return "", nil, fmt.Errorf("auth: login: %w", err) + if err == nil { + return m.issueToken(user) } + // 2. LDAP fallback when the store is wired and the config is enabled. + if m.ldapStore != nil { + cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background()) + if ldapErr == nil && cfg != nil && cfg.Enabled { + attrs, authErr := ldapauth.Authenticate(ldapauth.Config{ + URL: cfg.URL, + BindDN: cfg.BindDN, + BindPassword: cfg.BindPassword, + BaseDN: cfg.BaseDN, + UserFilter: cfg.UserFilter, + TLS: cfg.TLS, + TLSSkipVerify: cfg.TLSSkipVerify, + }, username, password) + if authErr == nil { + // Determine role: check group_mappings first, fall back to default_role. + role := cfg.DefaultRole + if role == "" { + role = userstore.RoleUser + } + memberOf := attrs["memberOf"] + if memberOf != "" { + for _, gm := range cfg.GroupMappings { + if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) { + role = gm.Role + break + } + } + } + + email := attrs["mail"] + if email == "" { + email = username + "@ldap.local" + } + + ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role) + if upsertErr == nil { + return m.issueToken(ldapUser) + } + } + } + } + + return "", nil, fmt.Errorf("auth: login: invalid credentials") +} + +// issueToken signs a JWT for the given user and returns the token string. +func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) { jti := generateJTI() now := time.Now() claims := jwt.MapClaims{ @@ -152,6 +206,56 @@ func HasRole(userRole, required string) bool { return levels[userRole] >= levels[required] } +// containsGroup checks whether a comma-separated memberOf string contains groupDN +// (case-insensitive substring match to handle varying DN formats). +func containsGroup(memberOf, groupDN string) bool { + for _, dn := range splitMemberOf(memberOf) { + if dn == groupDN { + return true + } + } + return false +} + +func splitMemberOf(s string) []string { + var out []string + for _, part := range splitComma(s) { + part = trimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +// splitComma splits on commas that are not part of DN attribute values. +// For simplicity we split on ", " and "," and let callers match. +func splitComma(s string) []string { + // Groups returned from LDAP memberOf are newline-separated in the map + // because we join with "," in ldapauth. Re-split here. + parts := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == ',' && (i == 0 || s[i-1] != '\\') { + parts = append(parts, s[start:i]) + start = i + 1 + } + } + parts = append(parts, s[start:]) + return parts +} + +func trimSpace(s string) string { + start, end := 0, len(s) + for start < end && (s[start] == ' ' || s[start] == '\t') { + start++ + } + for end > start && (s[end-1] == ' ' || s[end-1] == '\t') { + end-- + } + return s[start:end] +} + // generateJTI returns a cryptographically random identifier for a JWT. func generateJTI() string { b := make([]byte, 16) diff --git a/internal/ldapauth/client.go b/internal/ldapauth/client.go new file mode 100644 index 0000000..6f3a031 --- /dev/null +++ b/internal/ldapauth/client.go @@ -0,0 +1,195 @@ +// Package ldapauth provides LDAP/Active Directory authentication helpers. +// It wraps go-ldap/ldap/v3 and exposes a TestConnection probe and an +// Authenticate function used by the auth.Manager LDAP fallback. +package ldapauth + +import ( + "crypto/tls" + "fmt" + "strings" + "time" + + ldapv3 "github.com/go-ldap/ldap/v3" +) + +// Config holds the parameters required to connect to an LDAP/AD server. +type Config struct { + URL string + BindDN string + BindPassword string + BaseDN string + UserFilter string // must contain %s as placeholder for the username + TLS bool // true = STARTTLS upgrade after plain connection + TLSSkipVerify bool +} + +// TestResult is the structured output of TestConnection. +type TestResult struct { + OK bool `json:"ok"` + Message string `json:"message"` + LatencyMS int64 `json:"latency_ms"` + ServerInfo string `json:"server_info"` + UsersFound int `json:"users_found"` + ErrorDetail string `json:"error_detail"` +} + +// TestConnection opens a connection to the LDAP server, binds with the service +// account, queries the RootDSE for server info, and counts user objects. +func TestConnection(cfg Config) TestResult { + start := time.Now() + + conn, err := dial(cfg) + if err != nil { + return TestResult{ + OK: false, + Message: "Verbindung fehlgeschlagen", + LatencyMS: time.Since(start).Milliseconds(), + ErrorDetail: err.Error(), + } + } + defer conn.Close() + + // Service-account bind + if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { + return TestResult{ + OK: false, + Message: "Bind fehlgeschlagen", + LatencyMS: time.Since(start).Milliseconds(), + ErrorDetail: err.Error(), + } + } + + // Query RootDSE for server info + serverInfo := queryRootDSE(conn) + + // Count user objects (capped at 500 to avoid large result sets) + usersFound := countUsers(conn, cfg.BaseDN) + + return TestResult{ + OK: true, + Message: "Verbindung erfolgreich", + LatencyMS: time.Since(start).Milliseconds(), + ServerInfo: serverInfo, + UsersFound: usersFound, + } +} + +// Authenticate performs a two-step bind against LDAP: +// 1. Bind with the service account to locate the user DN via search. +// 2. Bind with the user DN and the provided password to verify credentials. +// +// Returns a map of user attributes (mail, displayName, memberOf) on success. +func Authenticate(cfg Config, username, password string) (map[string]string, error) { + conn, err := dial(cfg) + if err != nil { + return nil, fmt.Errorf("ldapauth: dial: %w", err) + } + defer conn.Close() + + // Step 1: service-account bind + if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil { + return nil, fmt.Errorf("ldapauth: service bind: %w", err) + } + + // Step 2: search for user DN + filter := strings.ReplaceAll(cfg.UserFilter, "%s", ldapv3.EscapeFilter(username)) + searchReq := ldapv3.NewSearchRequest( + cfg.BaseDN, + ldapv3.ScopeWholeSubtree, + ldapv3.NeverDerefAliases, + 1, // size limit + 30, // time limit seconds + false, + filter, + []string{"dn", "mail", "displayName", "memberOf"}, + nil, + ) + result, err := conn.Search(searchReq) + if err != nil { + return nil, fmt.Errorf("ldapauth: search user: %w", err) + } + if len(result.Entries) == 0 { + return nil, fmt.Errorf("ldapauth: user not found: %s", username) + } + + userDN := result.Entries[0].DN + attrs := map[string]string{ + "mail": result.Entries[0].GetAttributeValue("mail"), + "displayName": result.Entries[0].GetAttributeValue("displayName"), + "memberOf": strings.Join(result.Entries[0].GetAttributeValues("memberOf"), ","), + } + + // Step 3: user bind to verify password + if err := conn.Bind(userDN, password); err != nil { + return nil, fmt.Errorf("ldapauth: user bind failed") + } + + return attrs, nil +} + +// dial creates an LDAP connection, optionally upgrading to TLS via STARTTLS. +func dial(cfg Config) (*ldapv3.Conn, error) { + tlsCfg := &tls.Config{InsecureSkipVerify: cfg.TLSSkipVerify} //nolint:gosec // user-configured option + + // ldaps:// URLs use implicit TLS; ldap:// URLs use plain or STARTTLS. + if strings.HasPrefix(cfg.URL, "ldaps://") { + return ldapv3.DialURL(cfg.URL, ldapv3.DialWithTLSConfig(tlsCfg)) + } + + conn, err := ldapv3.DialURL(cfg.URL) + if err != nil { + return nil, err + } + + if cfg.TLS { + if err := conn.StartTLS(tlsCfg); err != nil { + conn.Close() + return nil, fmt.Errorf("ldapauth: STARTTLS: %w", err) + } + } + return conn, nil +} + +// queryRootDSE fetches vendorName / vendorVersion from the RootDSE. +func queryRootDSE(conn *ldapv3.Conn) string { + req := ldapv3.NewSearchRequest( + "", + ldapv3.ScopeBaseObject, + ldapv3.NeverDerefAliases, + 1, 10, false, + "(objectClass=*)", + []string{"vendorName", "vendorVersion", "serverType", "isGlobalCatalogReady"}, + nil, + ) + res, err := conn.Search(req) + if err != nil || len(res.Entries) == 0 { + return "" + } + e := res.Entries[0] + parts := []string{} + for _, attr := range []string{"vendorName", "vendorVersion", "serverType"} { + v := e.GetAttributeValue(attr) + if v != "" { + parts = append(parts, v) + } + } + return strings.Join(parts, " ") +} + +// countUsers searches for person/user objects and returns the count (max 500). +func countUsers(conn *ldapv3.Conn, baseDN string) int { + req := ldapv3.NewSearchRequest( + baseDN, + ldapv3.ScopeWholeSubtree, + ldapv3.NeverDerefAliases, + 500, 30, false, + "(|(objectClass=person)(objectClass=user))", + []string{"dn"}, + nil, + ) + res, err := conn.Search(req) + if err != nil { + return 0 + } + return len(res.Entries) +} diff --git a/internal/ldapconfig/store.go b/internal/ldapconfig/store.go new file mode 100644 index 0000000..42b37a6 --- /dev/null +++ b/internal/ldapconfig/store.go @@ -0,0 +1,257 @@ +// Package ldapconfig manages the LDAP/AD configuration stored in PostgreSQL. +// Exactly one configuration record may exist (id=1). The bind password is +// encrypted with AES-256-GCM using a SHA-256 derived key from the application +// secret — identical to the scheme used in internal/imap/store.go. +package ldapconfig + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// GroupMapping maps an LDAP group DN to an archivmail role. +type GroupMapping struct { + GroupDN string `json:"group_dn"` + Role string `json:"role"` +} + +// LDAPConfig is the persisted LDAP/AD configuration. +type LDAPConfig struct { + ID int64 `json:"id"` + Enabled bool `json:"enabled"` + URL string `json:"url"` + BindDN string `json:"bind_dn"` + BindPassword string `json:"bind_password"` // masked as "••••••" in GET responses + BaseDN string `json:"base_dn"` + UserFilter string `json:"user_filter"` + TLS bool `json:"tls"` + TLSSkipVerify bool `json:"tls_skip_verify"` + DefaultRole string `json:"default_role"` + GroupMappings []GroupMapping `json:"group_mappings"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by"` +} + +const createTableSQL = ` +CREATE TABLE IF NOT EXISTS ldap_config ( + id BIGSERIAL PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT false, + url TEXT NOT NULL DEFAULT '', + bind_dn TEXT NOT NULL DEFAULT '', + bind_password BYTEA, + base_dn TEXT NOT NULL DEFAULT '', + user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)', + tls BOOLEAN NOT NULL DEFAULT false, + tls_skip_verify BOOLEAN NOT NULL DEFAULT false, + default_role VARCHAR(20) NOT NULL DEFAULT 'user', + group_mappings JSONB NOT NULL DEFAULT '[]', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by TEXT NOT NULL DEFAULT '' +); +` + +// Store manages LDAP configuration persistence. +type Store struct { + pool *pgxpool.Pool + encKey [32]byte +} + +// New connects to PostgreSQL, creates the table if needed, and returns a Store. +// secret is the application secret used to derive the AES-256 encryption key. +func New(dsn, secret string) (*Store, error) { + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("ldapconfig: connect: %w", err) + } + + key := sha256.Sum256([]byte(secret)) + s := &Store{pool: pool, encKey: key} + + if _, err := pool.Exec(ctx, createTableSQL); err != nil { + pool.Close() + return nil, fmt.Errorf("ldapconfig: init schema: %w", err) + } + + return s, nil +} + +// Close releases the underlying connection pool. +func (s *Store) Close() { + s.pool.Close() +} + +// Get returns the LDAP configuration with the bind password masked. +// Returns nil, nil when no configuration has been saved yet. +func (s *Store) Get(ctx context.Context) (*LDAPConfig, error) { + cfg, err := s.query(ctx) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, nil + } + if cfg.BindPassword != "" { + cfg.BindPassword = "••••••" + } + return cfg, nil +} + +// GetWithPassword returns the LDAP configuration including the decrypted bind password. +// Returns nil, nil when no configuration has been saved yet. +func (s *Store) GetWithPassword(ctx context.Context) (*LDAPConfig, error) { + return s.query(ctx) +} + +// Save upserts the LDAP configuration (always uses id=1). +// When bindPassword is empty the existing stored password is preserved. +func (s *Store) Save(ctx context.Context, cfg LDAPConfig, updatedBy string) error { + mappingsJSON, err := json.Marshal(cfg.GroupMappings) + if err != nil { + return fmt.Errorf("ldapconfig: marshal group_mappings: %w", err) + } + + // Determine which password bytes to store. + var encryptedPw []byte + if cfg.BindPassword != "" { + encryptedPw, err = s.encrypt(cfg.BindPassword) + if err != nil { + return fmt.Errorf("ldapconfig: encrypt password: %w", err) + } + } else { + // Preserve existing password: read it from DB. + existing, qErr := s.query(ctx) + if qErr == nil && existing != nil && existing.BindPassword != "" { + encryptedPw, err = s.encrypt(existing.BindPassword) + if err != nil { + return fmt.Errorf("ldapconfig: re-encrypt existing password: %w", err) + } + } + } + + _, err = s.pool.Exec(ctx, ` + INSERT INTO ldap_config + (id, enabled, url, bind_dn, bind_password, base_dn, user_filter, tls, tls_skip_verify, + default_role, group_mappings, updated_at, updated_by) + VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), $11) + ON CONFLICT (id) DO UPDATE SET + enabled = EXCLUDED.enabled, + url = EXCLUDED.url, + bind_dn = EXCLUDED.bind_dn, + bind_password = CASE WHEN EXCLUDED.bind_password IS NULL THEN ldap_config.bind_password ELSE EXCLUDED.bind_password END, + base_dn = EXCLUDED.base_dn, + user_filter = EXCLUDED.user_filter, + tls = EXCLUDED.tls, + tls_skip_verify = EXCLUDED.tls_skip_verify, + default_role = EXCLUDED.default_role, + group_mappings = EXCLUDED.group_mappings, + updated_at = NOW(), + updated_by = EXCLUDED.updated_by + `, + cfg.Enabled, cfg.URL, cfg.BindDN, encryptedPw, cfg.BaseDN, + cfg.UserFilter, cfg.TLS, cfg.TLSSkipVerify, cfg.DefaultRole, + string(mappingsJSON), updatedBy, + ) + if err != nil { + return fmt.Errorf("ldapconfig: upsert: %w", err) + } + return nil +} + +// Delete removes the LDAP configuration. +func (s *Store) Delete(ctx context.Context) error { + _, err := s.pool.Exec(ctx, `DELETE FROM ldap_config WHERE id = 1`) + return err +} + +// query reads the single LDAP config row and decrypts the bind password. +func (s *Store) query(ctx context.Context) (*LDAPConfig, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, enabled, url, bind_dn, bind_password, base_dn, user_filter, + tls, tls_skip_verify, default_role, group_mappings, updated_at, updated_by + FROM ldap_config WHERE id = 1 + `) + + var cfg LDAPConfig + var encPw []byte + var mappingsRaw []byte + + err := row.Scan( + &cfg.ID, &cfg.Enabled, &cfg.URL, &cfg.BindDN, &encPw, + &cfg.BaseDN, &cfg.UserFilter, &cfg.TLS, &cfg.TLSSkipVerify, + &cfg.DefaultRole, &mappingsRaw, &cfg.UpdatedAt, &cfg.UpdatedBy, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("ldapconfig: query: %w", err) + } + + if len(encPw) > 0 { + plain, err := s.decrypt(encPw) + if err != nil { + return nil, fmt.Errorf("ldapconfig: decrypt password: %w", err) + } + cfg.BindPassword = plain + } + + if len(mappingsRaw) > 0 { + if err := json.Unmarshal(mappingsRaw, &cfg.GroupMappings); err != nil { + return nil, fmt.Errorf("ldapconfig: unmarshal group_mappings: %w", err) + } + } + if cfg.GroupMappings == nil { + cfg.GroupMappings = []GroupMapping{} + } + + return &cfg, nil +} + +// encrypt encrypts plaintext with AES-256-GCM using the store's key. +func (s *Store) encrypt(plaintext string) ([]byte, error) { + block, err := aes.NewCipher(s.encKey[:]) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil +} + +// decrypt decrypts ciphertext produced by encrypt. +func (s *Store) decrypt(ciphertext []byte) (string, error) { + block, err := aes.NewCipher(s.encKey[:]) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(ciphertext) < gcm.NonceSize() { + return "", fmt.Errorf("ldapconfig: ciphertext too short") + } + nonce, data := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():] + plain, err := gcm.Open(nil, nonce, data, nil) + if err != nil { + return "", err + } + return string(plain), nil +} diff --git a/internal/tenantstore/store.go b/internal/tenantstore/store.go new file mode 100644 index 0000000..0420caa --- /dev/null +++ b/internal/tenantstore/store.go @@ -0,0 +1,265 @@ +// Package tenantstore manages multi-tenancy data: tenants, their domains, and +// per-tenant LDAP configuration. Phase 1 implements the core data layer; +// tenant isolation of mail data is handled in later phases. +package tenantstore + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Tenant represents an organisational unit (company / department) in the system. +type Tenant struct { + ID int64 `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + // Computed fields populated by List. + DomainCount int `json:"domain_count,omitempty"` + UserCount int `json:"user_count,omitempty"` +} + +// TenantDomain is an e-mail domain assigned to a tenant. +type TenantDomain struct { + ID int64 `json:"id"` + TenantID int64 `json:"tenant_id"` + Domain string `json:"domain"` + CreatedAt time.Time `json:"created_at"` +} + +// Store manages tenant data in PostgreSQL. +type Store struct { + pool *pgxpool.Pool +} + +const schemaSQL = ` +CREATE TABLE IF NOT EXISTS tenants ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + slug VARCHAR(100) UNIQUE NOT NULL, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tenant_domains ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + domain VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tenant_ldap ( + tenant_id BIGINT PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, + enabled BOOLEAN NOT NULL DEFAULT false, + url TEXT NOT NULL DEFAULT '', + bind_dn TEXT NOT NULL DEFAULT '', + bind_password BYTEA, + base_dn TEXT NOT NULL DEFAULT '', + user_filter TEXT NOT NULL DEFAULT '(sAMAccountName=%s)', + tls BOOLEAN NOT NULL DEFAULT false, + tls_skip_verify BOOLEAN NOT NULL DEFAULT false, + default_role VARCHAR(20) NOT NULL DEFAULT 'user', + group_mappings JSONB NOT NULL DEFAULT '[]' +); + +ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id); +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id); +` + +// New connects to PostgreSQL and initialises the tenant schema. +func New(dsn string) (*Store, error) { + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("tenantstore: connect: %w", err) + } + + s := &Store{pool: pool} + if err := s.initSchema(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("tenantstore: init schema: %w", err) + } + + return s, nil +} + +// Close releases the connection pool. +func (s *Store) Close() { + s.pool.Close() +} + +func (s *Store) initSchema(ctx context.Context) error { + _, err := s.pool.Exec(ctx, schemaSQL) + return err +} + +// Create inserts a new tenant. name and slug must be unique. +func (s *Store) Create(ctx context.Context, name, slug string) (*Tenant, error) { + var id int64 + err := s.pool.QueryRow(ctx, + `INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`, + name, slug, + ).Scan(&id) + if err != nil { + return nil, fmt.Errorf("tenantstore: create: %w", err) + } + return s.Get(ctx, id) +} + +// List returns all tenants with computed domain_count and user_count. +func (s *Store) List(ctx context.Context) ([]Tenant, error) { + rows, err := s.pool.Query(ctx, ` + SELECT t.id, t.name, t.slug, t.active, t.created_at, + COUNT(DISTINCT td.id) AS domain_count, + COUNT(DISTINCT u.id) AS user_count + FROM tenants t + LEFT JOIN tenant_domains td ON td.tenant_id = t.id + LEFT JOIN users u ON u.tenant_id = t.id + GROUP BY t.id + ORDER BY t.id + `) + if err != nil { + return nil, fmt.Errorf("tenantstore: list: %w", err) + } + defer rows.Close() + + var tenants []Tenant + for rows.Next() { + var t Tenant + if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt, &t.DomainCount, &t.UserCount); err != nil { + return nil, fmt.Errorf("tenantstore: scan: %w", err) + } + tenants = append(tenants, t) + } + if tenants == nil { + tenants = []Tenant{} + } + return tenants, rows.Err() +} + +// Get returns a single tenant by ID. +func (s *Store) Get(ctx context.Context, id int64) (*Tenant, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, name, slug, active, created_at FROM tenants WHERE id = $1`, id, + ) + var t Tenant + if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt); err == pgx.ErrNoRows { + return nil, fmt.Errorf("tenantstore: not found: %d", id) + } else if err != nil { + return nil, fmt.Errorf("tenantstore: get: %w", err) + } + return &t, nil +} + +// Update sets name and active for a tenant. +func (s *Store) Update(ctx context.Context, id int64, name string, active bool) (*Tenant, error) { + _, err := s.pool.Exec(ctx, + `UPDATE tenants SET name = $1, active = $2 WHERE id = $3`, + name, active, id, + ) + if err != nil { + return nil, fmt.Errorf("tenantstore: update: %w", err) + } + return s.Get(ctx, id) +} + +// Delete removes a tenant and cascades to tenant_domains and tenant_ldap. +func (s *Store) Delete(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("tenantstore: delete: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("tenantstore: tenant %d not found", id) + } + return nil +} + +// AddDomain associates a domain name with a tenant. +func (s *Store) AddDomain(ctx context.Context, tenantID int64, domain string) (*TenantDomain, error) { + var id int64 + err := s.pool.QueryRow(ctx, + `INSERT INTO tenant_domains (tenant_id, domain) VALUES ($1, $2) RETURNING id`, + tenantID, domain, + ).Scan(&id) + if err != nil { + return nil, fmt.Errorf("tenantstore: add domain: %w", err) + } + return s.getDomain(ctx, id) +} + +// RemoveDomain removes a domain from a tenant. +func (s *Store) RemoveDomain(ctx context.Context, tenantID, domainID int64) error { + tag, err := s.pool.Exec(ctx, + `DELETE FROM tenant_domains WHERE id = $1 AND tenant_id = $2`, + domainID, tenantID, + ) + if err != nil { + return fmt.Errorf("tenantstore: remove domain: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("tenantstore: domain %d not found for tenant %d", domainID, tenantID) + } + return nil +} + +// ListDomains returns all domains assigned to a tenant. +func (s *Store) ListDomains(ctx context.Context, tenantID int64) ([]TenantDomain, error) { + rows, err := s.pool.Query(ctx, + `SELECT id, tenant_id, domain, created_at FROM tenant_domains WHERE tenant_id = $1 ORDER BY id`, + tenantID, + ) + if err != nil { + return nil, fmt.Errorf("tenantstore: list domains: %w", err) + } + defer rows.Close() + + var domains []TenantDomain + for rows.Next() { + var d TenantDomain + if err := rows.Scan(&d.ID, &d.TenantID, &d.Domain, &d.CreatedAt); err != nil { + return nil, fmt.Errorf("tenantstore: scan domain: %w", err) + } + domains = append(domains, d) + } + if domains == nil { + domains = []TenantDomain{} + } + return domains, rows.Err() +} + +// GetByDomain returns the tenant that owns the given e-mail domain. +// Used by the SMTP daemon for routing decisions. +func (s *Store) GetByDomain(ctx context.Context, domain string) (*Tenant, error) { + row := s.pool.QueryRow(ctx, ` + SELECT t.id, t.name, t.slug, t.active, t.created_at + FROM tenants t + JOIN tenant_domains td ON td.tenant_id = t.id + WHERE td.domain = $1 AND t.active = true + LIMIT 1 + `, domain) + var t Tenant + if err := row.Scan(&t.ID, &t.Name, &t.Slug, &t.Active, &t.CreatedAt); err == pgx.ErrNoRows { + return nil, nil // no tenant for this domain is a valid state + } else if err != nil { + return nil, fmt.Errorf("tenantstore: get by domain: %w", err) + } + return &t, nil +} + +// getDomain is a private helper to load a TenantDomain by its primary key. +func (s *Store) getDomain(ctx context.Context, id int64) (*TenantDomain, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, tenant_id, domain, created_at FROM tenant_domains WHERE id = $1`, id, + ) + var d TenantDomain + if err := row.Scan(&d.ID, &d.TenantID, &d.Domain, &d.CreatedAt); err != nil { + return nil, fmt.Errorf("tenantstore: get domain: %w", err) + } + return &d, nil +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index eea899e..5ab56a2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -19,6 +19,17 @@ import { getUploadProgress, getSecurityAudit, fixSecurityIssue, + getLDAPConfig, + saveLDAPConfig, + deleteLDAPConfig, + testLDAPConfig, + getTenants, + createTenant, + updateTenant, + deleteTenant, + getTenantDomains, + addTenantDomain, + removeTenantDomain, type User, type AuditEntry, type SMTPStatus, @@ -28,6 +39,10 @@ import { type UploadJob, type SecurityCheck, type SecurityAuditResult, + type LDAPConfig, + type LDAPTestResult, + type Tenant, + type TenantDomain, } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; @@ -137,6 +152,45 @@ export default function AdminPage() { const [uploadLoading, setUploadLoading] = useState(false); const uploadPollRef = useRef | null>(null); + // LDAP state + const [ldapConfig, setLdapConfig] = useState(null); + const [ldapLoading, setLdapLoading] = useState(false); + const [ldapSaving, setLdapSaving] = useState(false); + const [ldapTesting, setLdapTesting] = useState(false); + const [ldapError, setLdapError] = useState(""); + const [ldapTestResult, setLdapTestResult] = useState(null); + const [ldapForm, setLdapForm] = useState({ + enabled: false, + url: "ldap://", + bind_dn: "", + bind_password: "", + base_dn: "", + user_filter: "(sAMAccountName=%s)", + tls: false, + tls_skip_verify: false, + default_role: "user", + group_mappings: [], + }); + const [ldapChangePassword, setLdapChangePassword] = useState(false); + + // Tenants state + const [tenants, setTenants] = useState([]); + const [tenantsLoading, setTenantsLoading] = useState(false); + const [tenantsError, setTenantsError] = useState(""); + const [tenantDialogOpen, setTenantDialogOpen] = useState(false); + const [newTenantName, setNewTenantName] = useState(""); + const [newTenantSlug, setNewTenantSlug] = useState(""); + const [tenantCreateLoading, setTenantCreateLoading] = useState(false); + const [tenantCreateError, setTenantCreateError] = useState(""); + const [tenantDeleteId, setTenantDeleteId] = useState(null); + const [tenantDeleteLoading, setTenantDeleteLoading] = useState(false); + const [domainDialogTenant, setDomainDialogTenant] = useState(null); + const [tenantDomains, setTenantDomains] = useState([]); + const [domainsLoading, setDomainsLoading] = useState(false); + const [newDomain, setNewDomain] = useState(""); + const [addDomainLoading, setAddDomainLoading] = useState(false); + const [domainError, setDomainError] = useState(""); + const loadDashboard = useCallback(async () => { setDashLoading(true); try { @@ -382,6 +436,168 @@ export default function AdminPage() { } } + // LDAP handlers + const loadLDAP = useCallback(async () => { + setLdapLoading(true); + setLdapError(""); + try { + const cfg = await getLDAPConfig(); + if (cfg) { + setLdapConfig(cfg); + setLdapForm({ ...cfg, bind_password: "" }); + setLdapChangePassword(false); + } + } catch { + setLdapError("LDAP-Konfiguration konnte nicht geladen werden."); + } finally { + setLdapLoading(false); + } + }, []); + + async function handleSaveLDAP(e: React.FormEvent) { + e.preventDefault(); + setLdapSaving(true); + setLdapError(""); + try { + const payload: Partial = { ...ldapForm }; + if (!ldapChangePassword) { + delete payload.bind_password; + } + await saveLDAPConfig(payload); + await loadLDAP(); + } catch (err: unknown) { + setLdapError(err instanceof Error ? err.message : "Speichern fehlgeschlagen."); + } finally { + setLdapSaving(false); + } + } + + async function handleTestLDAP() { + setLdapTesting(true); + setLdapError(""); + setLdapTestResult(null); + try { + const payload = ldapConfig + ? { use_saved: true } + : { use_saved: false, ...ldapForm }; + const result = await testLDAPConfig(payload as Parameters[0]); + setLdapTestResult(result); + } catch (err: unknown) { + setLdapError(err instanceof Error ? err.message : "Test fehlgeschlagen."); + } finally { + setLdapTesting(false); + } + } + + async function handleDeleteLDAP() { + setLdapSaving(true); + setLdapError(""); + try { + await deleteLDAPConfig(); + setLdapConfig(null); + setLdapForm({ + enabled: false, url: "ldap://", bind_dn: "", bind_password: "", + base_dn: "", user_filter: "(sAMAccountName=%s)", tls: false, + tls_skip_verify: false, default_role: "user", group_mappings: [], + }); + setLdapTestResult(null); + } catch (err: unknown) { + setLdapError(err instanceof Error ? err.message : "Löschen fehlgeschlagen."); + } finally { + setLdapSaving(false); + } + } + + // Tenants handlers + const loadTenants = useCallback(async () => { + setTenantsLoading(true); + setTenantsError(""); + try { + const data = await getTenants(); + setTenants(data || []); + } catch { + setTenantsError("Mandanten konnten nicht geladen werden."); + } finally { + setTenantsLoading(false); + } + }, []); + + async function handleCreateTenant(e: React.FormEvent) { + e.preventDefault(); + setTenantCreateLoading(true); + setTenantCreateError(""); + try { + await createTenant(newTenantName, newTenantSlug); + setTenantDialogOpen(false); + setNewTenantName(""); + setNewTenantSlug(""); + await loadTenants(); + } catch (err: unknown) { + setTenantCreateError(err instanceof Error ? err.message : "Erstellen fehlgeschlagen."); + } finally { + setTenantCreateLoading(false); + } + } + + async function handleToggleTenant(t: Tenant) { + try { + await updateTenant(t.id, { active: !t.active }); + await loadTenants(); + } catch { /* ignore */ } + } + + async function handleDeleteTenant() { + if (!tenantDeleteId) return; + setTenantDeleteLoading(true); + try { + await deleteTenant(tenantDeleteId); + setTenantDeleteId(null); + await loadTenants(); + } catch { /* ignore */ } finally { + setTenantDeleteLoading(false); + } + } + + async function openDomainDialog(t: Tenant) { + setDomainDialogTenant(t); + setDomainsLoading(true); + setDomainError(""); + setNewDomain(""); + try { + const domains = await getTenantDomains(t.id); + setTenantDomains(domains || []); + } catch { setDomainError("Domains konnten nicht geladen werden."); } + finally { setDomainsLoading(false); } + } + + async function handleAddDomain() { + if (!domainDialogTenant || !newDomain) return; + setAddDomainLoading(true); + setDomainError(""); + try { + await addTenantDomain(domainDialogTenant.id, newDomain); + setNewDomain(""); + const domains = await getTenantDomains(domainDialogTenant.id); + setTenantDomains(domains || []); + } catch (err: unknown) { + setDomainError(err instanceof Error ? err.message : "Domain konnte nicht hinzugefügt werden."); + } finally { + setAddDomainLoading(false); + } + } + + async function handleRemoveDomain(domainId: number) { + if (!domainDialogTenant) return; + setDomainError(""); + try { + await removeTenantDomain(domainDialogTenant.id, domainId); + const domains = await getTenantDomains(domainDialogTenant.id); + setTenantDomains(domains || []); + } catch (err: unknown) { + setDomainError(err instanceof Error ? err.message : "Domain konnte nicht entfernt werden."); + } + } + const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); return ( @@ -404,7 +620,9 @@ export default function AdminPage() { Benutzer Audit-Log Import + LDAP Security + Mandanten Module @@ -1343,6 +1561,448 @@ export default function AdminPage() { })()} + {/* ── LDAP ── */} + +
+

LDAP / Active Directory

+
+ LDAP aktiviert + setLdapForm((f) => ({ ...f, enabled: e.target.checked }))} + /> +
+
+ + {ldapError && ( + + {ldapError} + + )} + + {ldapLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ + +
+
+ + setLdapForm((f) => ({ ...f, url: e.target.value }))} + /> +
+
+ + setLdapForm((f) => ({ ...f, bind_dn: e.target.value }))} + /> +
+
+ + {ldapConfig && !ldapChangePassword ? ( +
+ + +
+ ) : ( + setLdapForm((f) => ({ ...f, bind_password: e.target.value }))} + /> + )} +
+
+ + setLdapForm((f) => ({ ...f, base_dn: e.target.value }))} + /> +
+
+ + setLdapForm((f) => ({ ...f, user_filter: e.target.value }))} + /> +
+
+ + +
+
+ + + +
+ + +
+ + + + {/* Group mappings */} +
+
+ + +
+ {ldapForm.group_mappings.length === 0 ? ( +

Keine Gruppen-Zuordnungen definiert.

+ ) : ( +
+ {ldapForm.group_mappings.map((gm, i) => ( +
+ { + const gms = [...ldapForm.group_mappings]; + gms[i] = { ...gms[i], group_dn: e.target.value }; + setLdapForm((f) => ({ ...f, group_mappings: gms })); + }} + /> + + +
+ ))} +
+ )} +
+
+
+ + {/* Test result */} + {ldapTestResult && ( + + +
+ + {ldapTestResult.ok ? "Verbunden" : "Fehler"} + + {ldapTestResult.message} + {ldapTestResult.latency_ms > 0 && ( + {ldapTestResult.latency_ms} ms + )} +
+ {ldapTestResult.server_info && ( +

{ldapTestResult.server_info}

+ )} + {ldapTestResult.users_found > 0 && ( +

{ldapTestResult.users_found} Benutzer gefunden

+ )} + {ldapTestResult.error_detail && ( +

{ldapTestResult.error_detail}

+ )} +
+
+ )} + + {/* Action bar */} +
+ + + {ldapConfig && ( + + )} +
+
+ )} + + {ldapConfig && ( +

+ Zuletzt geändert: {ldapConfig.updated_at ? new Date(ldapConfig.updated_at).toLocaleString("de-DE") : "–"} + {ldapConfig.updated_by ? ` von ${ldapConfig.updated_by}` : ""} +

+ )} +
+ + {/* ── Mandanten ── */} + +
+

Mandantenverwaltung

+ + + + + + + Neuen Mandanten anlegen + Name und URL-Slug für den neuen Mandanten eingeben. + +
+
+ + { + setNewTenantName(e.target.value); + setNewTenantSlug(e.target.value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); + }} + /> +
+
+ + setNewTenantSlug(e.target.value)} + /> +
+ {tenantCreateError && ( +

{tenantCreateError}

+ )} + + + +
+
+
+
+ + {tenantsError && ( + + {tenantsError} + + )} + + {tenantsLoading ? ( + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + ) : tenants.length === 0 ? ( + + + Keine Mandanten vorhanden. Klicke auf “+ Mandant anlegen” um den ersten Mandanten zu erstellen. + + + ) : ( + + + + + Name + Slug + Domains + Nutzer + Status + Aktionen + + + + {tenants.map((t) => ( + + {t.name} + {t.slug} + {t.domain_count ?? 0} + {t.user_count ?? 0} + + + {t.active ? "Aktiv" : "Inaktiv"} + + + +
+ + + +
+
+
+ ))} +
+
+
+ )} + + {/* Tenant delete confirmation */} + { if (!open) setTenantDeleteId(null); }}> + + + Mandant löschen + + Alle Daten dieses Mandanten (Domains, LDAP-Konfiguration) werden gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + + + + + + + + + + {/* Domain management dialog */} + { if (!open) setDomainDialogTenant(null); }}> + + + Domains: {domainDialogTenant?.name} + E-Mail-Domains diesem Mandanten zuweisen. + + + {domainError && ( + + {domainError} + + )} + + {domainsLoading ? ( + + ) : ( +
+ {tenantDomains.length === 0 ? ( +

Keine Domains zugewiesen.

+ ) : ( + tenantDomains.map((d) => ( +
+ {d.domain} + +
+ )) + )} +
+ )} + +
+ setNewDomain(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddDomain(); } }} + /> + +
+
+
+
+ diff --git a/src/lib/api.ts b/src/lib/api.ts index d64af67..0a6ccf5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -599,3 +599,132 @@ export async function fixSecurityIssue(action: string): Promise<{ message: strin body: JSON.stringify({ action }), }); } + +// ── LDAP ────────────────────────────────────────────────────────────────── + +export interface LDAPGroupMapping { + group_dn: string; + role: string; +} + +export interface LDAPConfig { + id?: number; + enabled: boolean; + url: string; + bind_dn: string; + bind_password: string; + base_dn: string; + user_filter: string; + tls: boolean; + tls_skip_verify: boolean; + default_role: string; + group_mappings: LDAPGroupMapping[]; + updated_at?: string; + updated_by?: string; +} + +export interface LDAPTestResult { + ok: boolean; + message: string; + latency_ms: number; + server_info: string; + users_found: number; + error_detail: string; +} + +export async function getLDAPConfig(): Promise { + try { + return await request("/api/admin/ldap"); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes("404")) return null; + if (e instanceof Error && e.message.includes("no ldap config")) return null; + throw e; + } +} + +export async function saveLDAPConfig(cfg: Partial): Promise { + await request("/api/admin/ldap", { + method: "PUT", + body: JSON.stringify(cfg), + }); +} + +export async function deleteLDAPConfig(): Promise { + await request("/api/admin/ldap", { method: "DELETE" }); +} + +export async function testLDAPConfig( + payload: { use_saved: boolean } & Partial +): Promise { + return request("/api/admin/ldap/test", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +// ── Tenants ──────────────────────────────────────────────────────────────── + +export interface Tenant { + id: number; + name: string; + slug: string; + active: boolean; + created_at: string; + domain_count?: number; + user_count?: number; +} + +export interface TenantDomain { + id: number; + tenant_id: number; + domain: string; + created_at: string; +} + +export async function getTenants(): Promise { + return request("/api/tenants"); +} + +export async function createTenant(name: string, slug: string): Promise { + return request("/api/tenants", { + method: "POST", + body: JSON.stringify({ name, slug }), + }); +} + +export async function updateTenant( + id: number, + data: { name?: string; active?: boolean } +): Promise { + return request(`/api/tenants/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +} + +export async function deleteTenant(id: number): Promise { + await request(`/api/tenants/${id}`, { method: "DELETE" }); +} + +export async function getTenantDomains(id: number): Promise { + return request(`/api/tenants/${id}/domains`); +} + +export async function addTenantDomain( + tenantId: number, + domain: string +): Promise { + return request(`/api/tenants/${tenantId}/domains`, { + method: "POST", + body: JSON.stringify({ domain }), + }); +} + +export async function removeTenantDomain( + tenantId: number, + domainId: number +): Promise { + await request(`/api/tenants/${tenantId}/domains/${domainId}`, { + method: "DELETE", + }); +}