refactor: große API-Handler-Dateien in fokussierte Module aufteilen
Reine Code-Verschiebung, keine Logikänderung. Betroffen: - ldap_tenants.go (994 Zeilen) -> ldap_tenants.go (Routing) + ldap_handlers.go + tenant_handlers.go + tenant_domain_handlers.go + tenant_logo_handlers.go + tenant_helpers.go - import_handlers.go (621 Zeilen) -> imap_handlers.go + pop3_handlers.go + import_helpers.go - admin_handlers.go (702 Zeilen) -> admin_users_handlers.go + admin_status_handlers.go + admin_services_handlers.go + admin_security_handlers.go Lokal kein Go-Build möglich — Verifikation manuell per Funktions- und Import-Abgleich Alt/Neu (alle Symbole exakt einmal vorhanden). Build muss vor Deploy auf 192.168.1.131/132 bestätigt werden.
This commit is contained in:
@@ -1,16 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"archivmail/internal/audit"
|
||||
"archivmail/internal/ldapauth"
|
||||
ldapcfg "archivmail/internal/ldapconfig"
|
||||
"archivmail/internal/tenantstore"
|
||||
"archivmail/internal/userstore"
|
||||
@@ -59,417 +49,6 @@ func (s *Server) SetTenants(store *tenantstore.Store) {
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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) {
|
||||
@@ -489,506 +68,3 @@ func (s *Server) SetTenantLDAP(store *ldapcfg.TenantStore) {
|
||||
s.mux.HandleFunc("POST /api/admin/tenants/{id}/ldap/test", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminTestTenantLDAP)))
|
||||
s.mux.HandleFunc("POST /api/admin/tenants/{id}/ldap/sync", s.authMiddleware(s.requireRole(userstore.RoleSuperAdmin, s.handleAdminSyncTenantLDAP)))
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
IPAddress: s.remoteIP(r),
|
||||
Success: result.OK,
|
||||
Detail: result.Message + " (tenant " + strconv.FormatInt(id, 10) + ")",
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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,
|
||||
TenantID: sess.TenantID,
|
||||
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})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user