package api import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "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" ) const maxLogoSize = 2 * 1024 * 1024 // 2 MB // ── 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))) s.mux.HandleFunc("GET /api/tenants/{id}/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListTenantUsers))) // Logo routes: any auth can read; admin can write s.mux.HandleFunc("GET /api/tenants/{id}/logo", s.auth(s.handleGetTenantLogo)) s.mux.HandleFunc("POST /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadTenantLogo))) s.mux.HandleFunc("DELETE /api/tenants/{id}/logo", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteTenantLogo))) // Logo routes for domain_admin (own tenant) s.mux.HandleFunc("GET /api/tenant/logo", s.authAdmin(s.handleGetOwnTenantLogo)) s.mux.HandleFunc("POST /api/tenant/logo", s.authAdmin(s.handleUploadOwnTenantLogo)) s.mux.HandleFunc("DELETE /api/tenant/logo", s.authAdmin(s.handleDeleteOwnTenantLogo)) } // ── 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: s.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: s.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: s.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 } // Create default users for the new tenant. type defaultUserCreds struct { Username string `json:"username"` Password string `json:"password"` Role string `json:"role"` } type createTenantResponse struct { *tenantstore.Tenant DefaultUsers []defaultUserCreds `json:"default_users"` } resp := createTenantResponse{Tenant: tenant} for _, spec := range []struct { suffix string role string }{ {suffix: "admin", role: userstore.RoleDomainAdmin}, {suffix: "auditor", role: userstore.RoleAuditor}, } { pw, pwErr := tenantRandomPassword() if pwErr != nil { writeError(w, http.StatusInternalServerError, "failed to generate password") return } username := req.Slug + "-" + spec.suffix email := fmt.Sprintf("%s@%s.local", username, req.Slug) u, uErr := s.users.Create(userstore.CreateUserRequest{ Username: username, Email: email, Password: pw, Role: spec.role, TenantID: &tenant.ID, }) if uErr != nil { writeError(w, http.StatusInternalServerError, "failed to create default user") return } resp.DefaultUsers = append(resp.DefaultUsers, defaultUserCreds{ Username: u.Username, Password: pw, Role: spec.role, }) } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_created", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant erstellt: " + req.Name, }) writeJSON(w, http.StatusCreated, resp) } 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: s.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) } func (s *Server) handleListTenantUsers(w http.ResponseWriter, r *http.Request) { tenantID, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } users, err := s.users.ListByTenant(r.Context(), tenantID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list tenant users") return } if users == nil { users = []*userstore.User{} } writeJSON(w, http.StatusOK, users) } // ── PROJ-23: Per-Tenant LDAP handlers (Phase B) ───────────────────────────── // SetTenantLDAP wires the per-tenant LDAP config store into the API server and // registers the tenant LDAP routes. func (s *Server) SetTenantLDAP(store *ldapcfg.TenantStore) { s.tenantLdapStore = store // domain_admin routes — tenant_id comes from JWT session, NOT from URL s.mux.HandleFunc("GET /api/tenant/ldap", s.authAdmin(s.handleGetTenantLDAP)) s.mux.HandleFunc("PUT /api/tenant/ldap", s.authAdmin(s.handleSaveTenantLDAP)) s.mux.HandleFunc("DELETE /api/tenant/ldap", s.authAdmin(s.handleDeleteTenantLDAP)) s.mux.HandleFunc("POST /api/tenant/ldap/test", s.authAdmin(s.handleTestTenantLDAP)) // superadmin routes — tenant_id from URL parameter s.mux.HandleFunc("GET /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminGetTenantLDAP))) s.mux.HandleFunc("PUT /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminSaveTenantLDAP))) s.mux.HandleFunc("DELETE /api/admin/tenants/{id}/ldap", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminDeleteTenantLDAP))) s.mux.HandleFunc("POST /api/admin/tenants/{id}/ldap/test", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminTestTenantLDAP))) } // ── domain_admin handlers (own tenant) ────────────────────────────────────── func (s *Server) handleGetTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } cfg, err := s.tenantLdapStore.Get(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load tenant ldap config") return } if cfg == nil { writeError(w, http.StatusNotFound, "no tenant ldap config") return } writeJSON(w, http.StatusOK, cfg) } func (s *Server) handleSaveTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } var cfg ldapcfg.TenantLDAPConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } cfg.TenantID = *sess.TenantID // BUG-1 fix: domain_admin may only assign user/auditor roles — prevent privilege escalation // via LDAP default_role or group_mappings even when bypassing the frontend. allowedForTenantAdmin := map[string]bool{"user": true, "auditor": true} if cfg.DefaultRole != "" && !allowedForTenantAdmin[cfg.DefaultRole] { writeError(w, http.StatusForbidden, "role not allowed for tenant LDAP config") return } for _, gm := range cfg.GroupMappings { if !allowedForTenantAdmin[gm.Role] { writeError(w, http.StatusForbidden, "group mapping role not allowed") return } } if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil { writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config") return } s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_saved", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gespeichert", }) saved, err := s.tenantLdapStore.Get(r.Context(), *sess.TenantID) if err != nil || saved == nil { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return } writeJSON(w, http.StatusOK, saved) } func (s *Server) handleDeleteTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } if err := s.tenantLdapStore.Delete(r.Context(), *sess.TenantID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete tenant ldap config") return } s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_deleted", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gelöscht", }) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleTestTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } var body struct { UseSaved bool `json:"use_saved"` ldapcfg.TenantLDAPConfig } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } testCfg := s.buildTenantTestConfig(r, body.UseSaved, *sess.TenantID, body.TenantLDAPConfig) if testCfg == nil { writeError(w, http.StatusNotFound, "no tenant ldap config saved") return } result := ldapauth.TestConnection(*testCfg) s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_connection_test", Username: sess.Username, IPAddress: s.remoteIP(r), Success: result.OK, Detail: result.Message, }) writeJSON(w, http.StatusOK, result) } // ── superadmin handlers (arbitrary tenant) ────────────────────────────────── func (s *Server) handleAdminGetTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } cfg, err := s.tenantLdapStore.Get(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load tenant ldap config") return } if cfg == nil { writeError(w, http.StatusNotFound, "no tenant ldap config") return } writeJSON(w, http.StatusOK, cfg) } func (s *Server) handleAdminSaveTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } var cfg ldapcfg.TenantLDAPConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } cfg.TenantID = id // superadmin may assign up to domain_admin in group mappings — not superadmin itself. allowedForSuperAdmin := map[string]bool{"user": true, "auditor": true, "domain_admin": true} if cfg.DefaultRole != "" && !allowedForSuperAdmin[cfg.DefaultRole] { writeError(w, http.StatusForbidden, "role not allowed for tenant LDAP config") return } for _, gm := range cfg.GroupMappings { if !allowedForSuperAdmin[gm.Role] { writeError(w, http.StatusForbidden, "group mapping role not allowed") return } } sess := sessionFromCtx(r.Context()) if err := s.tenantLdapStore.Save(r.Context(), cfg, sess.Username); err != nil { writeError(w, http.StatusInternalServerError, "failed to save tenant ldap config") return } s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_saved", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gespeichert (tenant " + strconv.FormatInt(id, 10) + ")", }) saved, err := s.tenantLdapStore.Get(r.Context(), id) if err != nil || saved == nil { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) return } writeJSON(w, http.StatusOK, saved) } func (s *Server) handleAdminDeleteTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } if err := s.tenantLdapStore.Delete(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete tenant ldap config") return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_config_deleted", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-LDAP-Konfiguration gelöscht (tenant " + strconv.FormatInt(id, 10) + ")", }) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleAdminTestTenantLDAP(w http.ResponseWriter, r *http.Request) { if s.tenantLdapStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant ldap store not available") return } id, err := parseTenantID(r) if err != nil { writeError(w, http.StatusBadRequest, "invalid tenant id") return } var body struct { UseSaved bool `json:"use_saved"` ldapcfg.TenantLDAPConfig } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } testCfg := s.buildTenantTestConfig(r, body.UseSaved, id, body.TenantLDAPConfig) if testCfg == nil { writeError(w, http.StatusNotFound, "no tenant ldap config saved") return } result := ldapauth.TestConnection(*testCfg) sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_ldap_connection_test", Username: sess.Username, IPAddress: s.remoteIP(r), Success: result.OK, Detail: result.Message + " (tenant " + strconv.FormatInt(id, 10) + ")", }) writeJSON(w, http.StatusOK, result) } // buildTenantTestConfig constructs an ldapauth.Config for testing, either from // the saved tenant config or from the provided request body. func (s *Server) buildTenantTestConfig(r *http.Request, useSaved bool, tenantID int64, provided ldapcfg.TenantLDAPConfig) *ldapauth.Config { if useSaved { saved, err := s.tenantLdapStore.GetWithPassword(r.Context(), tenantID) if err != nil || saved == nil { return nil } return &ldapauth.Config{ URL: saved.URL, BindDN: saved.BindDN, BindPassword: saved.BindPassword, BaseDN: saved.BaseDN, UserFilter: saved.UserFilter, TLS: saved.TLS, TLSSkipVerify: saved.TLSSkipVerify, } } return &ldapauth.Config{ URL: provided.URL, BindDN: provided.BindDN, BindPassword: provided.BindPassword, BaseDN: provided.BaseDN, UserFilter: provided.UserFilter, TLS: provided.TLS, TLSSkipVerify: provided.TLSSkipVerify, } } // ── helpers ────────────────────────────────────────────────────────────────── func parseTenantID(r *http.Request) (int64, error) { return strconv.ParseInt(r.PathValue("id"), 10, 64) } // tenantRandomPassword generates a cryptographically random 16-byte hex password. func tenantRandomPassword() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // ── Logo handlers (admin: any tenant) ─────────────────────────────────────── func (s *Server) handleGetTenantLogo(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 } data, contentType, err := s.tenantStore.GetLogo(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load logo") return } if data == nil { writeError(w, http.StatusNotFound, "no logo set") return } if contentType == "" { contentType = "image/png" } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (s *Server) handleUploadTenantLogo(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 } s.saveTenantLogo(w, r, id) } func (s *Server) handleDeleteTenantLogo(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 } if err := s.tenantStore.DeleteLogo(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete logo") return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_logo_deleted", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-Logo gelöscht (tenant " + strconv.FormatInt(id, 10) + ")", }) w.WriteHeader(http.StatusNoContent) } // ── Logo handlers (domain_admin: own tenant) ───────────────────────────────── func (s *Server) handleGetOwnTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } data, contentType, err := s.tenantStore.GetLogo(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "failed to load logo") return } if data == nil { writeError(w, http.StatusNotFound, "no logo set") return } if contentType == "" { contentType = "image/png" } w.Header().Set("Content-Type", contentType) w.Header().Set("Cache-Control", "public, max-age=86400") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (s *Server) handleUploadOwnTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } s.saveTenantLogo(w, r, *sess.TenantID) } func (s *Server) handleDeleteOwnTenantLogo(w http.ResponseWriter, r *http.Request) { if s.tenantStore == nil { writeError(w, http.StatusServiceUnavailable, "tenant store not available") return } sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusBadRequest, "no tenant context") return } if err := s.tenantStore.DeleteLogo(r.Context(), *sess.TenantID); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete logo") return } s.audlog.Log(audit.Entry{ EventType: "tenant_logo_deleted", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "Mandant-Logo gelöscht", }) w.WriteHeader(http.StatusNoContent) } // saveTenantLogo is the shared multipart upload logic for logo handlers. func (s *Server) saveTenantLogo(w http.ResponseWriter, r *http.Request, tenantID int64) { if err := r.ParseMultipartForm(maxLogoSize); err != nil { writeError(w, http.StatusBadRequest, "failed to parse multipart form") return } file, header, err := r.FormFile("logo") if err != nil { writeError(w, http.StatusBadRequest, "logo file required") return } defer file.Close() contentType := header.Header.Get("Content-Type") if contentType == "" { contentType = "image/png" } allowed := map[string]bool{ "image/png": true, "image/jpeg": true, "image/jpg": true, "image/gif": true, "image/webp": true, "image/svg+xml": true, } if !allowed[contentType] { writeError(w, http.StatusBadRequest, "unsupported image type (allowed: png, jpeg, gif, webp, svg)") return } data, err := io.ReadAll(io.LimitReader(file, maxLogoSize+1)) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read logo") return } if int64(len(data)) > maxLogoSize { writeError(w, http.StatusBadRequest, "logo too large (max 2 MB)") return } if err := s.tenantStore.SetLogo(r.Context(), tenantID, data, contentType); err != nil { writeError(w, http.StatusInternalServerError, "failed to save logo") return } sess := sessionFromCtx(r.Context()) s.audlog.Log(audit.Entry{ EventType: "tenant_logo_uploaded", Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: fmt.Sprintf("Mandant-Logo hochgeladen (%d bytes, %s, tenant %d)", len(data), contentType, tenantID), }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }