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:
sysops
2026-03-17 20:27:56 +01:00
parent 4f0670d94c
commit ac91dceac2
13 changed files with 2063 additions and 11 deletions
+393
View File
@@ -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)
}
+4
View File
@@ -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.
+109 -5
View File
@@ -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)
+195
View File
@@ -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)
}
+257
View File
@@ -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
}
+265
View File
@@ -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
}