diff --git a/internal/api/admin_handlers.go b/internal/api/admin_handlers.go deleted file mode 100644 index bd0817f..0000000 --- a/internal/api/admin_handlers.go +++ /dev/null @@ -1,702 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "net/http" - "os" - "os/exec" - "strconv" - "strings" - "time" - - "archivmail/internal/audit" - "archivmail/internal/auth" - "archivmail/internal/userstore" -) - -func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { - tenantID := tenantFromCtx(r.Context()) - - var ( - users []*userstore.User - err error - ) - if tenantID != nil { - users, err = s.users.ListByTenant(r.Context(), *tenantID) - } else { - users, err = s.users.List("") - } - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list users") - return - } - - type userResp struct { - ID int64 `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - Role string `json:"role"` - Active bool `json:"active"` - TenantID *int64 `json:"tenant_id,omitempty"` - } - - resp := make([]userResp, 0, len(users)) - for _, u := range users { - resp = append(resp, userResp{ - ID: u.ID, - Username: u.Username, - Email: u.Email, - Role: u.Role, - Active: u.Active, - TenantID: u.TenantID, - }) - } - writeJSON(w, http.StatusOK, resp) -} - -func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { - var req struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Role string `json:"role"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - // SEC-01: Privilege escalation check — caller must not assign a role - // at or above their own level. - sess := sessionFromCtx(r.Context()) - if roleLevel(req.Role) >= roleLevel(sess.Role) { - writeError(w, http.StatusForbidden, "insufficient privileges to assign this role") - return - } - - // SEC-02: Tenant isolation — non-superadmin users can only create users - // within their own tenant. - var tenantID *int64 - if sess.TenantID != nil { - tenantID = sess.TenantID - } - - // PROJ-29: Enforce max_users quota before creating a new user. - if tenantID != nil && s.tenantStore != nil { - quota, qErr := s.tenantStore.GetQuota(r.Context(), *tenantID) - if qErr == nil && quota.MaxUsers != nil { - usage, uErr := s.tenantStore.GetUsage(r.Context(), *tenantID) - if uErr == nil && int(usage.UserCount) >= *quota.MaxUsers { - writeError(w, http.StatusPaymentRequired, "user quota exceeded") - return - } - } - } - - user, err := s.users.Create(userstore.CreateUserRequest{ - Username: req.Username, - Email: req.Email, - Password: req.Password, - Role: req.Role, - TenantID: tenantID, - }) - if err != nil { - s.logger.Error("create user failed", "err", err) - writeError(w, http.StatusBadRequest, "user creation failed") - return - } - - s.audlog.Log(audit.Entry{ - EventType: audit.EventUserMgmt, - Username: sess.Username, - TenantID: sess.TenantID, - IPAddress: s.remoteIP(r), - Detail: "created user: " + user.Username, - Success: true, - }) - - writeJSON(w, http.StatusCreated, map[string]interface{}{ - "id": user.ID, - "username": user.Username, - "email": user.Email, - "role": user.Role, - "active": user.Active, - }) -} - -func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid user id") - return - } - - var req struct { - Email *string `json:"email"` - Role *string `json:"role"` - Active *bool `json:"active"` - Password *string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - sess := sessionFromCtx(r.Context()) - - // SEC-02: Tenant isolation — load target user and verify same tenant. - target, err := s.users.GetByID(id) - if err != nil { - writeError(w, http.StatusNotFound, "user not found") - return - } - if sess.TenantID != nil { - if target.TenantID == nil || *target.TenantID != *sess.TenantID { - writeError(w, http.StatusForbidden, "access denied") - return - } - } - - // SEC-01: Privilege escalation check — caller must not assign a role - // at or above their own level, and must not modify users at or above - // their own level. - if roleLevel(target.Role) >= roleLevel(sess.Role) { - writeError(w, http.StatusForbidden, "insufficient privileges to modify this user") - return - } - if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) { - writeError(w, http.StatusForbidden, "insufficient privileges to assign this role") - return - } - - updated, err := s.users.Update(id, userstore.UpdateUserRequest{ - Email: req.Email, - Role: req.Role, - Active: req.Active, - Password: req.Password, - }) - if err != nil { - s.logger.Error("update user failed", "err", err) - writeError(w, http.StatusBadRequest, "user update failed") - return - } - - s.audlog.Log(audit.Entry{ - EventType: audit.EventUserMgmt, - Username: sess.Username, - TenantID: sess.TenantID, - IPAddress: s.remoteIP(r), - Detail: fmt.Sprintf("updated user %d", id), - Success: true, - }) - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "id": updated.ID, - "username": updated.Username, - "email": updated.Email, - "role": updated.Role, - "active": updated.Active, - }) -} - -func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { - id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid user id") - return - } - - // Fetch user info before deletion for audit log and IMAP cleanup - target, err := s.users.GetByID(id) - if err != nil { - writeError(w, http.StatusNotFound, "user not found") - return - } - - // SEC-02: Tenant isolation — domain_admin can only delete users in their own tenant. - sess := sessionFromCtx(r.Context()) - if sess.TenantID != nil { - if target.TenantID == nil || *target.TenantID != *sess.TenantID { - writeError(w, http.StatusForbidden, "access denied") - return - } - } - - // SEC-01: Cannot delete users at or above own privilege level. - if roleLevel(target.Role) >= roleLevel(sess.Role) { - writeError(w, http.StatusForbidden, "insufficient privileges to delete this user") - return - } - - if err := s.users.DeleteSafe(id); err != nil { - if err.Error() == "userstore: cannot delete last admin" { - writeError(w, http.StatusConflict, "cannot delete the last active admin") - return - } - s.logger.Error("delete user failed", "err", err) - writeError(w, http.StatusInternalServerError, "user deletion failed") - return - } - - // Remove all IMAP accounts that belonged to this user - imapDeleted := 0 - if s.imapStore != nil { - if n, err := s.imapStore.DeleteByOwner(r.Context(), target.Username); err != nil { - s.logger.Warn("delete user: could not remove IMAP accounts", "user", target.Username, "err", err) - } else { - imapDeleted = n - } - } - - s.audlog.Log(audit.Entry{ - EventType: audit.EventUserMgmt, - Username: sess.Username, - TenantID: sess.TenantID, - IPAddress: s.remoteIP(r), - Detail: fmt.Sprintf( - "deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD", - id, target.Username, target.Role, imapDeleted, - ), - Success: true, - }) - - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} - -func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) { - sess := sessionFromCtx(r.Context()) - tenantID := tenantFromCtx(r.Context()) - - // domain_admin: return only their tenant's email statistics (no global daemon info) - if sess != nil && !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) { - stats, err := s.store.StatsByTenant(r.Context(), tenantID) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to read stats") - return - } - domains := []string{} - if tenantID != nil && s.tenantStore != nil { - if dd, derr := s.tenantStore.ListDomains(r.Context(), *tenantID); derr == nil { - for _, d := range dd { - domains = append(domains, d.Domain) - } - } - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "enabled": true, - "tenant_only": true, - "domains": domains, - "total_mails": stats["count"], - "total_bytes": stats["total_size"], - }) - return - } - - // superadmin: global daemon status - if s.smtpDaemon == nil { - writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false}) - return - } - writeJSON(w, http.StatusOK, s.smtpDaemon.Status()) -} - -func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) { - tenantID := tenantFromCtx(r.Context()) - stats, err := s.store.StatsByTenant(r.Context(), tenantID) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to read storage stats") - return - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "total_mails": stats["count"], - "total_bytes": stats["total_size"], - }) -} - -// --- Service management --- - -// allowedServices is the whitelist of systemd service names the admin may control. -var allowedServices = []string{ - "archivmail", - "archivmail-web", - "postgresql@17-main", - "postfix", - "nginx", -} - -type ServiceStatus struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - Active string `json:"active"` // active, inactive, failed, unknown - Sub string `json:"sub"` // running, dead, exited, ... - Enabled string `json:"enabled"` // enabled, disabled, static, unknown - Description string `json:"description"` - ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail -} - -func isAllowedService(name string) bool { - for _, s := range allowedServices { - if s == name { - return true - } - } - return false -} - -func systemctlShow(name string) ServiceStatus { - svc := ServiceStatus{Name: name, DisplayName: name} - out, err := exec.Command("systemctl", "show", name+".service", - "--property=ActiveState,SubState,UnitFileState,Description", - "--no-pager").Output() - if err != nil { - svc.Active = "unknown" - svc.Sub = "" - svc.Enabled = "unknown" - } else { - for _, line := range strings.Split(string(out), "\n") { - k, v, ok := strings.Cut(line, "=") - if !ok { - continue - } - switch k { - case "ActiveState": - svc.Active = v - case "SubState": - svc.Sub = v - case "UnitFileState": - svc.Enabled = v - case "Description": - svc.Description = v - } - } - } - if name == "archivmail" { - blocked := nftAPIBlocked() - svc.ExternalBlocked = &blocked - } - return svc -} - -// nftAPIBlocked reports whether external access to port 8080 is currently blocked. -func nftAPIBlocked() bool { - out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", "status").Output() - if err != nil { - return false - } - return strings.TrimSpace(string(out)) == "blocked" -} - -func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) { - result := make([]ServiceStatus, 0, len(allowedServices)) - for _, name := range allowedServices { - result = append(result, systemctlShow(name)) - } - writeJSON(w, http.StatusOK, result) -} - -func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { - // Only superadmin may start/stop/restart services - sess := sessionFromCtx(r.Context()) - if sess == nil || !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) { - writeError(w, http.StatusForbidden, "superadmin required") - return - } - - name := r.PathValue("name") - if !isAllowedService(name) { - writeError(w, http.StatusBadRequest, "unknown service") - return - } - - var body struct { - Action string `json:"action"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusBadRequest, "invalid request") - return - } - - allowedActions := map[string]bool{ - "start": true, "stop": true, "restart": true, - "enable": true, "disable": true, - } - nftActions := map[string]string{ - "block_external": "block", - "allow_external": "unblock", - } - - if nftArg, isNft := nftActions[body.Action]; isNft { - if name != "archivmail" { - writeError(w, http.StatusBadRequest, "external access control only available for archivmail") - return - } - out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", nftArg).CombinedOutput() - if err != nil { - writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out))) - return - } - sess := sessionFromCtx(r.Context()) - s.audlog.Log(audit.Entry{ - EventType: "service." + body.Action, - Username: sess.Username, - TenantID: sess.TenantID, - IPAddress: s.remoteIP(r), - Detail: name, - Success: true, - }) - writeJSON(w, http.StatusOK, systemctlShow(name)) - return - } - - if !allowedActions[body.Action] { - writeError(w, http.StatusBadRequest, "unknown action") - return - } - - out, err := exec.Command("sudo", "/usr/bin/systemctl", body.Action, name+".service").CombinedOutput() - if err != nil { - writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out))) - return - } - - s.audlog.Log(audit.Entry{ - EventType: "service." + body.Action, - Username: sess.Username, - TenantID: sess.TenantID, - IPAddress: s.remoteIP(r), - Detail: name, - Success: true, - }) - - writeJSON(w, http.StatusOK, systemctlShow(name)) -} - -// ── Security Audit ────────────────────────────────────────────────────────── - -type securityCheck struct { - Name string `json:"name"` - Status string `json:"status"` // "ok" | "warning" | "error" - Message string `json:"message"` -} - -func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) { - var checks []securityCheck - - // 1. Firewall (nftables) aktiv? - nftOut, err := exec.CommandContext(r.Context(), "nft", "list", "ruleset").Output() - nftStr := string(nftOut) - firewallActive := err == nil - - if !firewallActive { - checks = append(checks, securityCheck{ - Name: "Firewall (nftables)", - Status: "error", - Message: "nft konnte nicht ausgeführt werden — Firewall möglicherweise inaktiv", - }) - } else if strings.Contains(nftStr, "policy drop") { - checks = append(checks, securityCheck{ - Name: "Firewall (nftables)", - Status: "ok", - Message: "Aktiv — Input-Chain policy: drop (Whitelist-Modus)", - }) - } else { - checks = append(checks, securityCheck{ - Name: "Firewall (nftables)", - Status: "warning", - Message: "nftables aktiv, aber Input-Chain policy ist nicht 'drop'", - }) - } - - // 2. Port 3000 (Next.js) extern erreichbar? - if !firewallActive { - checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"}) - } else if strings.Contains(nftStr, "dport 3000") { - checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "warning", Message: "Port 3000 explizit in Firewall-Regeln — prüfen ob gewollt"}) - } else { - checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"}) - } - - // 3. Port 8080 (Go Backend) extern erreichbar? - if !firewallActive { - checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"}) - } else if strings.Contains(nftStr, "dport 8080") { - checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "warning", Message: "Port 8080 explizit in Firewall-Regeln — prüfen ob gewollt"}) - } else { - checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"}) - } - - // 4. HTTPS aktiv? - if firewallActive && strings.Contains(nftStr, "dport 443") { - checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "ok", Message: "Port 443 in Firewall freigegeben"}) - } else { - checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "warning", Message: "Kein HTTPS — Verbindungen unverschlüsselt (certbot empfohlen)"}) - } - - // 5. SSH PermitRootLogin + PasswordAuthentication - sshConf, err := os.ReadFile("/etc/ssh/sshd_config") - if err == nil { - lines := strings.Split(string(sshConf), "\n") - rootLogin := "" - passAuth := "" - for _, l := range lines { - tl := strings.ToLower(strings.TrimSpace(l)) - if strings.HasPrefix(tl, "permitrootlogin") && !strings.HasPrefix(tl, "#") { - rootLogin = tl - } - if strings.HasPrefix(tl, "passwordauthentication") && !strings.HasPrefix(tl, "#") { - passAuth = tl - } - } - if strings.Contains(rootLogin, "no") || strings.Contains(rootLogin, "prohibit-password") { - checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "ok", Message: rootLogin}) - } else if rootLogin == "" { - checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: "Nicht explizit gesetzt (Standard: prohibit-password)"}) - } else { - checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: rootLogin + " — Passwort-Login für root möglich"}) - } - if strings.Contains(passAuth, "no") { - checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "ok", Message: "Nur Key-basierte Authentifizierung"}) - } else if passAuth == "" { - checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: "Nicht explizit deaktiviert — SSH-Keys empfohlen"}) - } else { - checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: passAuth + " — Brute-Force-Risiko"}) - } - } else { - checks = append(checks, securityCheck{Name: "SSH Konfiguration", Status: "warning", Message: "/etc/ssh/sshd_config nicht lesbar"}) - } - - // 6. Fail2ban - f2bOut, err := exec.CommandContext(r.Context(), "systemctl", "is-active", "fail2ban").Output() - if err == nil && strings.TrimSpace(string(f2bOut)) == "active" { - checks = append(checks, securityCheck{Name: "Fail2ban", Status: "ok", Message: "Aktiv"}) - } else { - checks = append(checks, securityCheck{Name: "Fail2ban", Status: "warning", Message: "Nicht aktiv — kein Brute-Force-Schutz (apt install fail2ban)"}) - } - - writeJSON(w, http.StatusOK, map[string]interface{}{ - "checks": checks, - "run_at": time.Now().UTC().Format(time.RFC3339), - }) -} - -// ── Security Fix ──────────────────────────────────────────────────────────── - -// allowedFixActions is a strict whitelist — only these actions may be executed. -var allowedFixActions = map[string]bool{ - "install_fail2ban": true, - "enable_firewall": true, - "fix_ssh_password_auth": true, - "fix_ssh_root_login": true, -} - -func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) { - var body struct { - Action string `json:"action"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - if !allowedFixActions[body.Action] { - writeError(w, http.StatusBadRequest, "unknown action: "+body.Action) - return - } - - ctx := r.Context() - var msg string - var fixErr error - - switch body.Action { - - case "install_fail2ban": - // Install fail2ban if not present - if _, err := exec.LookPath("fail2ban-client"); err != nil { - out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput() - if err != nil { - writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out)) - return - } - } - // Write a minimal jail.local if not already present - jailPath := "/etc/fail2ban/jail.local" - if _, err := os.Stat(jailPath); os.IsNotExist(err) { - jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n" - if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil { - s.logger.Error("could not write jail.local", "err", err) - writeError(w, http.StatusInternalServerError, "security config update failed") - return - } - } - // Enable and start - exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run() - out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput() - if err != nil { - fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out)) - } else { - msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet." - } - - case "enable_firewall": - // Reload rules from /etc/nftables.conf and enable service - out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput() - if err != nil { - writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out)) - return - } - exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run() - msg = "nftables-Regeln neu geladen und Dienst aktiviert." - - case "fix_ssh_password_auth": - fixErr = sshConfigSet("PasswordAuthentication", "no") - if fixErr == nil { - out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput() - if err != nil { - fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out)) - } else { - msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet." - } - } - - case "fix_ssh_root_login": - fixErr = sshConfigSet("PermitRootLogin", "prohibit-password") - if fixErr == nil { - out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput() - if err != nil { - fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out)) - } else { - msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet." - } - } - } - - if fixErr != nil { - writeError(w, http.StatusInternalServerError, fixErr.Error()) - return - } - writeJSON(w, http.StatusOK, map[string]string{"message": msg}) -} - -// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config. -// Commented-out lines are left untouched; the active directive is updated or appended. -func sshConfigSet(key, value string) error { - const path = "/etc/ssh/sshd_config" - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read sshd_config: %w", err) - } - lines := strings.Split(string(data), "\n") - keyLower := strings.ToLower(key) - found := false - for i, l := range lines { - tl := strings.ToLower(strings.TrimSpace(l)) - if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") { - lines[i] = key + " " + value - found = true - } - } - if !found { - lines = append(lines, key+" "+value) - } - return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) -} diff --git a/internal/api/admin_security_handlers.go b/internal/api/admin_security_handlers.go new file mode 100644 index 0000000..49721aa --- /dev/null +++ b/internal/api/admin_security_handlers.go @@ -0,0 +1,240 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +// ── Security Audit ────────────────────────────────────────────────────────── + +type securityCheck struct { + Name string `json:"name"` + Status string `json:"status"` // "ok" | "warning" | "error" + Message string `json:"message"` +} + +func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) { + var checks []securityCheck + + // 1. Firewall (nftables) aktiv? + nftOut, err := exec.CommandContext(r.Context(), "nft", "list", "ruleset").Output() + nftStr := string(nftOut) + firewallActive := err == nil + + if !firewallActive { + checks = append(checks, securityCheck{ + Name: "Firewall (nftables)", + Status: "error", + Message: "nft konnte nicht ausgeführt werden — Firewall möglicherweise inaktiv", + }) + } else if strings.Contains(nftStr, "policy drop") { + checks = append(checks, securityCheck{ + Name: "Firewall (nftables)", + Status: "ok", + Message: "Aktiv — Input-Chain policy: drop (Whitelist-Modus)", + }) + } else { + checks = append(checks, securityCheck{ + Name: "Firewall (nftables)", + Status: "warning", + Message: "nftables aktiv, aber Input-Chain policy ist nicht 'drop'", + }) + } + + // 2. Port 3000 (Next.js) extern erreichbar? + if !firewallActive { + checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"}) + } else if strings.Contains(nftStr, "dport 3000") { + checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "warning", Message: "Port 3000 explizit in Firewall-Regeln — prüfen ob gewollt"}) + } else { + checks = append(checks, securityCheck{Name: "Port 3000 (Next.js)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"}) + } + + // 3. Port 8080 (Go Backend) extern erreichbar? + if !firewallActive { + checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "error", Message: "Keine Firewall aktiv — Port möglicherweise extern erreichbar"}) + } else if strings.Contains(nftStr, "dport 8080") { + checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "warning", Message: "Port 8080 explizit in Firewall-Regeln — prüfen ob gewollt"}) + } else { + checks = append(checks, securityCheck{Name: "Port 8080 (Go Backend)", Status: "ok", Message: "Blockiert (nicht in Whitelist)"}) + } + + // 4. HTTPS aktiv? + if firewallActive && strings.Contains(nftStr, "dport 443") { + checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "ok", Message: "Port 443 in Firewall freigegeben"}) + } else { + checks = append(checks, securityCheck{Name: "HTTPS (TLS)", Status: "warning", Message: "Kein HTTPS — Verbindungen unverschlüsselt (certbot empfohlen)"}) + } + + // 5. SSH PermitRootLogin + PasswordAuthentication + sshConf, err := os.ReadFile("/etc/ssh/sshd_config") + if err == nil { + lines := strings.Split(string(sshConf), "\n") + rootLogin := "" + passAuth := "" + for _, l := range lines { + tl := strings.ToLower(strings.TrimSpace(l)) + if strings.HasPrefix(tl, "permitrootlogin") && !strings.HasPrefix(tl, "#") { + rootLogin = tl + } + if strings.HasPrefix(tl, "passwordauthentication") && !strings.HasPrefix(tl, "#") { + passAuth = tl + } + } + if strings.Contains(rootLogin, "no") || strings.Contains(rootLogin, "prohibit-password") { + checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "ok", Message: rootLogin}) + } else if rootLogin == "" { + checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: "Nicht explizit gesetzt (Standard: prohibit-password)"}) + } else { + checks = append(checks, securityCheck{Name: "SSH Root-Login", Status: "warning", Message: rootLogin + " — Passwort-Login für root möglich"}) + } + if strings.Contains(passAuth, "no") { + checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "ok", Message: "Nur Key-basierte Authentifizierung"}) + } else if passAuth == "" { + checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: "Nicht explizit deaktiviert — SSH-Keys empfohlen"}) + } else { + checks = append(checks, securityCheck{Name: "SSH Passwort-Auth", Status: "warning", Message: passAuth + " — Brute-Force-Risiko"}) + } + } else { + checks = append(checks, securityCheck{Name: "SSH Konfiguration", Status: "warning", Message: "/etc/ssh/sshd_config nicht lesbar"}) + } + + // 6. Fail2ban + f2bOut, err := exec.CommandContext(r.Context(), "systemctl", "is-active", "fail2ban").Output() + if err == nil && strings.TrimSpace(string(f2bOut)) == "active" { + checks = append(checks, securityCheck{Name: "Fail2ban", Status: "ok", Message: "Aktiv"}) + } else { + checks = append(checks, securityCheck{Name: "Fail2ban", Status: "warning", Message: "Nicht aktiv — kein Brute-Force-Schutz (apt install fail2ban)"}) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "checks": checks, + "run_at": time.Now().UTC().Format(time.RFC3339), + }) +} + +// ── Security Fix ──────────────────────────────────────────────────────────── + +// allowedFixActions is a strict whitelist — only these actions may be executed. +var allowedFixActions = map[string]bool{ + "install_fail2ban": true, + "enable_firewall": true, + "fix_ssh_password_auth": true, + "fix_ssh_root_login": true, +} + +func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) { + var body struct { + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if !allowedFixActions[body.Action] { + writeError(w, http.StatusBadRequest, "unknown action: "+body.Action) + return + } + + ctx := r.Context() + var msg string + var fixErr error + + switch body.Action { + + case "install_fail2ban": + // Install fail2ban if not present + if _, err := exec.LookPath("fail2ban-client"); err != nil { + out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out)) + return + } + } + // Write a minimal jail.local if not already present + jailPath := "/etc/fail2ban/jail.local" + if _, err := os.Stat(jailPath); os.IsNotExist(err) { + jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n" + if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil { + s.logger.Error("could not write jail.local", "err", err) + writeError(w, http.StatusInternalServerError, "security config update failed") + return + } + } + // Enable and start + exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run() + out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput() + if err != nil { + fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out)) + } else { + msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet." + } + + case "enable_firewall": + // Reload rules from /etc/nftables.conf and enable service + out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out)) + return + } + exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run() + msg = "nftables-Regeln neu geladen und Dienst aktiviert." + + case "fix_ssh_password_auth": + fixErr = sshConfigSet("PasswordAuthentication", "no") + if fixErr == nil { + out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput() + if err != nil { + fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out)) + } else { + msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet." + } + } + + case "fix_ssh_root_login": + fixErr = sshConfigSet("PermitRootLogin", "prohibit-password") + if fixErr == nil { + out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput() + if err != nil { + fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out)) + } else { + msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet." + } + } + } + + if fixErr != nil { + writeError(w, http.StatusInternalServerError, fixErr.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) +} + +// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config. +// Commented-out lines are left untouched; the active directive is updated or appended. +func sshConfigSet(key, value string) error { + const path = "/etc/ssh/sshd_config" + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read sshd_config: %w", err) + } + lines := strings.Split(string(data), "\n") + keyLower := strings.ToLower(key) + found := false + for i, l := range lines { + tl := strings.ToLower(strings.TrimSpace(l)) + if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") { + lines[i] = key + " " + value + found = true + } + } + if !found { + lines = append(lines, key+" "+value) + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) +} diff --git a/internal/api/admin_services_handlers.go b/internal/api/admin_services_handlers.go new file mode 100644 index 0000000..898d963 --- /dev/null +++ b/internal/api/admin_services_handlers.go @@ -0,0 +1,170 @@ +package api + +import ( + "encoding/json" + "net/http" + "os/exec" + "strings" + + "archivmail/internal/audit" + "archivmail/internal/auth" + "archivmail/internal/userstore" +) + +// --- Service management --- + +// allowedServices is the whitelist of systemd service names the admin may control. +var allowedServices = []string{ + "archivmail", + "archivmail-web", + "postgresql@17-main", + "postfix", + "nginx", +} + +type ServiceStatus struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Active string `json:"active"` // active, inactive, failed, unknown + Sub string `json:"sub"` // running, dead, exited, ... + Enabled string `json:"enabled"` // enabled, disabled, static, unknown + Description string `json:"description"` + ExternalBlocked *bool `json:"external_blocked,omitempty"` // only set for archivmail +} + +func isAllowedService(name string) bool { + for _, s := range allowedServices { + if s == name { + return true + } + } + return false +} + +func systemctlShow(name string) ServiceStatus { + svc := ServiceStatus{Name: name, DisplayName: name} + out, err := exec.Command("systemctl", "show", name+".service", + "--property=ActiveState,SubState,UnitFileState,Description", + "--no-pager").Output() + if err != nil { + svc.Active = "unknown" + svc.Sub = "" + svc.Enabled = "unknown" + } else { + for _, line := range strings.Split(string(out), "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch k { + case "ActiveState": + svc.Active = v + case "SubState": + svc.Sub = v + case "UnitFileState": + svc.Enabled = v + case "Description": + svc.Description = v + } + } + } + if name == "archivmail" { + blocked := nftAPIBlocked() + svc.ExternalBlocked = &blocked + } + return svc +} + +// nftAPIBlocked reports whether external access to port 8080 is currently blocked. +func nftAPIBlocked() bool { + out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", "status").Output() + if err != nil { + return false + } + return strings.TrimSpace(string(out)) == "blocked" +} + +func (s *Server) handleListServices(w http.ResponseWriter, r *http.Request) { + result := make([]ServiceStatus, 0, len(allowedServices)) + for _, name := range allowedServices { + result = append(result, systemctlShow(name)) + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleServiceAction(w http.ResponseWriter, r *http.Request) { + // Only superadmin may start/stop/restart services + sess := sessionFromCtx(r.Context()) + if sess == nil || !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) { + writeError(w, http.StatusForbidden, "superadmin required") + return + } + + name := r.PathValue("name") + if !isAllowedService(name) { + writeError(w, http.StatusBadRequest, "unknown service") + return + } + + var body struct { + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request") + return + } + + allowedActions := map[string]bool{ + "start": true, "stop": true, "restart": true, + "enable": true, "disable": true, + } + nftActions := map[string]string{ + "block_external": "block", + "allow_external": "unblock", + } + + if nftArg, isNft := nftActions[body.Action]; isNft { + if name != "archivmail" { + writeError(w, http.StatusBadRequest, "external access control only available for archivmail") + return + } + out, err := exec.Command("sudo", "/usr/local/sbin/archivmail-nft", nftArg).CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out))) + return + } + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: "service." + body.Action, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Detail: name, + Success: true, + }) + writeJSON(w, http.StatusOK, systemctlShow(name)) + return + } + + if !allowedActions[body.Action] { + writeError(w, http.StatusBadRequest, "unknown action") + return + } + + out, err := exec.Command("sudo", "/usr/bin/systemctl", body.Action, name+".service").CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, strings.TrimSpace(string(out))) + return + } + + s.audlog.Log(audit.Entry{ + EventType: "service." + body.Action, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Detail: name, + Success: true, + }) + + writeJSON(w, http.StatusOK, systemctlShow(name)) +} diff --git a/internal/api/admin_status_handlers.go b/internal/api/admin_status_handlers.go new file mode 100644 index 0000000..705abea --- /dev/null +++ b/internal/api/admin_status_handlers.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/http" + + "archivmail/internal/auth" + "archivmail/internal/userstore" +) + +func (s *Server) handleSMTPStatus(w http.ResponseWriter, r *http.Request) { + sess := sessionFromCtx(r.Context()) + tenantID := tenantFromCtx(r.Context()) + + // domain_admin: return only their tenant's email statistics (no global daemon info) + if sess != nil && !auth.HasRole(sess.Role, userstore.RoleSuperAdmin) { + stats, err := s.store.StatsByTenant(r.Context(), tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read stats") + return + } + domains := []string{} + if tenantID != nil && s.tenantStore != nil { + if dd, derr := s.tenantStore.ListDomains(r.Context(), *tenantID); derr == nil { + for _, d := range dd { + domains = append(domains, d.Domain) + } + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": true, + "tenant_only": true, + "domains": domains, + "total_mails": stats["count"], + "total_bytes": stats["total_size"], + }) + return + } + + // superadmin: global daemon status + if s.smtpDaemon == nil { + writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false, "running": false}) + return + } + writeJSON(w, http.StatusOK, s.smtpDaemon.Status()) +} + +func (s *Server) handleStorageStats(w http.ResponseWriter, r *http.Request) { + tenantID := tenantFromCtx(r.Context()) + stats, err := s.store.StatsByTenant(r.Context(), tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to read storage stats") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "total_mails": stats["count"], + "total_bytes": stats["total_size"], + }) +} diff --git a/internal/api/admin_users_handlers.go b/internal/api/admin_users_handlers.go new file mode 100644 index 0000000..501253d --- /dev/null +++ b/internal/api/admin_users_handlers.go @@ -0,0 +1,260 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "archivmail/internal/audit" + "archivmail/internal/userstore" +) + +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { + tenantID := tenantFromCtx(r.Context()) + + var ( + users []*userstore.User + err error + ) + if tenantID != nil { + users, err = s.users.ListByTenant(r.Context(), *tenantID) + } else { + users, err = s.users.List("") + } + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list users") + return + } + + type userResp struct { + ID int64 `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Role string `json:"role"` + Active bool `json:"active"` + TenantID *int64 `json:"tenant_id,omitempty"` + } + + resp := make([]userResp, 0, len(users)) + for _, u := range users { + resp = append(resp, userResp{ + ID: u.ID, + Username: u.Username, + Email: u.Email, + Role: u.Role, + Active: u.Active, + TenantID: u.TenantID, + }) + } + writeJSON(w, http.StatusOK, resp) +} + +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + // SEC-01: Privilege escalation check — caller must not assign a role + // at or above their own level. + sess := sessionFromCtx(r.Context()) + if roleLevel(req.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to assign this role") + return + } + + // SEC-02: Tenant isolation — non-superadmin users can only create users + // within their own tenant. + var tenantID *int64 + if sess.TenantID != nil { + tenantID = sess.TenantID + } + + // PROJ-29: Enforce max_users quota before creating a new user. + if tenantID != nil && s.tenantStore != nil { + quota, qErr := s.tenantStore.GetQuota(r.Context(), *tenantID) + if qErr == nil && quota.MaxUsers != nil { + usage, uErr := s.tenantStore.GetUsage(r.Context(), *tenantID) + if uErr == nil && int(usage.UserCount) >= *quota.MaxUsers { + writeError(w, http.StatusPaymentRequired, "user quota exceeded") + return + } + } + } + + user, err := s.users.Create(userstore.CreateUserRequest{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + Role: req.Role, + TenantID: tenantID, + }) + if err != nil { + s.logger.Error("create user failed", "err", err) + writeError(w, http.StatusBadRequest, "user creation failed") + return + } + + s.audlog.Log(audit.Entry{ + EventType: audit.EventUserMgmt, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Detail: "created user: " + user.Username, + Success: true, + }) + + writeJSON(w, http.StatusCreated, map[string]interface{}{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + "active": user.Active, + }) +} + +func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + var req struct { + Email *string `json:"email"` + Role *string `json:"role"` + Active *bool `json:"active"` + Password *string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + sess := sessionFromCtx(r.Context()) + + // SEC-02: Tenant isolation — load target user and verify same tenant. + target, err := s.users.GetByID(id) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + if sess.TenantID != nil { + if target.TenantID == nil || *target.TenantID != *sess.TenantID { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + + // SEC-01: Privilege escalation check — caller must not assign a role + // at or above their own level, and must not modify users at or above + // their own level. + if roleLevel(target.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to modify this user") + return + } + if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to assign this role") + return + } + + updated, err := s.users.Update(id, userstore.UpdateUserRequest{ + Email: req.Email, + Role: req.Role, + Active: req.Active, + Password: req.Password, + }) + if err != nil { + s.logger.Error("update user failed", "err", err) + writeError(w, http.StatusBadRequest, "user update failed") + return + } + + s.audlog.Log(audit.Entry{ + EventType: audit.EventUserMgmt, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Detail: fmt.Sprintf("updated user %d", id), + Success: true, + }) + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "id": updated.ID, + "username": updated.Username, + "email": updated.Email, + "role": updated.Role, + "active": updated.Active, + }) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + // Fetch user info before deletion for audit log and IMAP cleanup + target, err := s.users.GetByID(id) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + // SEC-02: Tenant isolation — domain_admin can only delete users in their own tenant. + sess := sessionFromCtx(r.Context()) + if sess.TenantID != nil { + if target.TenantID == nil || *target.TenantID != *sess.TenantID { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + + // SEC-01: Cannot delete users at or above own privilege level. + if roleLevel(target.Role) >= roleLevel(sess.Role) { + writeError(w, http.StatusForbidden, "insufficient privileges to delete this user") + return + } + + if err := s.users.DeleteSafe(id); err != nil { + if err.Error() == "userstore: cannot delete last admin" { + writeError(w, http.StatusConflict, "cannot delete the last active admin") + return + } + s.logger.Error("delete user failed", "err", err) + writeError(w, http.StatusInternalServerError, "user deletion failed") + return + } + + // Remove all IMAP accounts that belonged to this user + imapDeleted := 0 + if s.imapStore != nil { + if n, err := s.imapStore.DeleteByOwner(r.Context(), target.Username); err != nil { + s.logger.Warn("delete user: could not remove IMAP accounts", "user", target.Username, "err", err) + } else { + imapDeleted = n + } + } + + s.audlog.Log(audit.Entry{ + EventType: audit.EventUserMgmt, + Username: sess.Username, + TenantID: sess.TenantID, + IPAddress: s.remoteIP(r), + Detail: fmt.Sprintf( + "deleted user %d (%s, role=%s); %d IMAP account(s) removed; emails retained per GoBD", + id, target.Username, target.Role, imapDeleted, + ), + Success: true, + }) + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/internal/api/import_handlers.go b/internal/api/imap_handlers.go similarity index 60% rename from internal/api/import_handlers.go rename to internal/api/imap_handlers.go index e9dfcf2..76dfcfc 100644 --- a/internal/api/import_handlers.go +++ b/internal/api/imap_handlers.go @@ -9,23 +9,11 @@ import ( "archivmail/internal/auth" imapstore "archivmail/internal/imap" - pop3store "archivmail/internal/pop3" "archivmail/internal/userstore" ) // ── IMAP handlers ───────────────────────────────────────────────────────── -// tenantAccessAllowed checks whether the given session may access an IMAP/POP3 -// account belonging to accTenantID. Superadmins (sess.TenantID == nil) may -// access any tenant. Other admins may only access accounts within their own -// tenant (accTenantID must be set and match). -func tenantAccessAllowed(sess *auth.Session, accTenantID *int64) bool { - if sess.TenantID == nil { - return true - } - return accTenantID != nil && *accTenantID == *sess.TenantID -} - func (s *Server) handleListImap(w http.ResponseWriter, r *http.Request) { if s.imapStore == nil { writeError(w, http.StatusServiceUnavailable, "IMAP not configured") @@ -389,233 +377,3 @@ func (s *Server) handleUpdateImapInterval(w http.ResponseWriter, r *http.Request acc.SyncIntervalMin = req.SyncIntervalMin writeJSON(w, http.StatusOK, acc) } - -// ── POP3 handlers ────────────────────────────────────────────────────────── - -func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) { - if s.pop3Store == nil { - writeError(w, http.StatusServiceUnavailable, "POP3 not configured") - return - } - sess := sessionFromCtx(r.Context()) - // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). - isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) - accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin, sess.TenantID) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts") - return - } - if accounts == nil { - accounts = []pop3store.Account{} - } - writeJSON(w, http.StatusOK, accounts) -} - -func (s *Server) handleCreatePop3(w http.ResponseWriter, r *http.Request) { - if s.pop3Store == nil { - writeError(w, http.StatusServiceUnavailable, "POP3 not configured") - return - } - var req struct { - Name string `json:"name"` - Host string `json:"host"` - Port int `json:"port"` - TLS string `json:"tls"` - TLSSkipVerify bool `json:"tls_skip_verify"` - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" { - writeError(w, http.StatusBadRequest, "name, host, username and password are required") - return - } - if req.Port <= 0 { - req.Port = 110 - } - if req.TLS == "" { - req.TLS = "none" - } - - sess := sessionFromCtx(r.Context()) - acc := pop3store.Account{ - Owner: sess.Username, - Name: req.Name, - Host: req.Host, - Port: req.Port, - TLS: req.TLS, - TLSSkipVerify: req.TLSSkipVerify, - Username: req.Username, - TenantID: sess.TenantID, - } - - created, err := s.pop3Store.Create(r.Context(), acc, req.Password) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to create POP3 account") - return - } - - writeJSON(w, http.StatusCreated, created) -} - -func (s *Server) handleDeletePop3(w http.ResponseWriter, r *http.Request) { - if s.pop3Store == nil { - writeError(w, http.StatusServiceUnavailable, "POP3 not configured") - return - } - idStr := r.PathValue("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid id") - return - } - - acc, err := s.pop3Store.Get(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "account not found") - return - } - - sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { - writeError(w, http.StatusForbidden, "access denied") - return - } - - if err := s.pop3Store.Delete(r.Context(), id); err != nil { - writeError(w, http.StatusInternalServerError, "failed to delete account") - return - } - - writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) -} - -func (s *Server) handleTestPop3(w http.ResponseWriter, r *http.Request) { - var req struct { - Host string `json:"host"` - Port int `json:"port"` - TLS string `json:"tls"` - TLSSkipVerify bool `json:"tls_skip_verify"` - Username string `json:"username"` - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - if req.Host == "" || req.Username == "" || req.Password == "" { - writeError(w, http.StatusBadRequest, "host, username and password are required") - return - } - if req.Port <= 0 { - req.Port = 110 - } - if req.TLS == "" { - req.TLS = "none" - } - - c, err := pop3store.Dial(req.Host, req.Port, req.TLS, req.TLSSkipVerify) - if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": false, - "message": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err), - }) - return - } - defer c.Close() - - if err := c.Login(req.Username, req.Password); err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": false, - "message": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err), - }) - return - } - - count, totalSize, err := c.Stat() - if err != nil { - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": false, - "message": fmt.Sprintf("STAT fehlgeschlagen: %v", err), - }) - return - } - - _ = c.Quit() - writeJSON(w, http.StatusOK, map[string]interface{}{ - "ok": true, - "message": fmt.Sprintf("Verbindung erfolgreich: %d E-Mails", count), - "message_count": count, - "total_size_bytes": totalSize, - }) -} - -func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) { - if s.pop3Store == nil || s.pop3Importer == nil { - writeError(w, http.StatusServiceUnavailable, "POP3 not configured") - return - } - idStr := r.PathValue("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid id") - return - } - - acc, err := s.pop3Store.Get(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "account not found") - return - } - - sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { - writeError(w, http.StatusForbidden, "access denied") - return - } - - if acc.Status == "running" { - writeError(w, http.StatusConflict, "import already running") - return - } - - go s.pop3Importer.Run(context.Background(), id) - - // Return current account state (status will switch to "running" shortly) - acc.Status = "running" - writeJSON(w, http.StatusOK, acc) -} - -func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) { - if s.pop3Store == nil { - writeError(w, http.StatusServiceUnavailable, "POP3 not configured") - return - } - idStr := r.PathValue("id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid id") - return - } - - acc, err := s.pop3Store.Get(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "account not found") - return - } - - sess := sessionFromCtx(r.Context()) - if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { - writeError(w, http.StatusForbidden, "access denied") - return - } - if !tenantAccessAllowed(sess, acc.TenantID) { - writeError(w, http.StatusForbidden, "access denied") - return - } - - writeJSON(w, http.StatusOK, acc) -} diff --git a/internal/api/import_helpers.go b/internal/api/import_helpers.go new file mode 100644 index 0000000..00fc693 --- /dev/null +++ b/internal/api/import_helpers.go @@ -0,0 +1,16 @@ +package api + +import ( + "archivmail/internal/auth" +) + +// tenantAccessAllowed checks whether the given session may access an IMAP/POP3 +// account belonging to accTenantID. Superadmins (sess.TenantID == nil) may +// access any tenant. Other admins may only access accounts within their own +// tenant (accTenantID must be set and match). +func tenantAccessAllowed(sess *auth.Session, accTenantID *int64) bool { + if sess.TenantID == nil { + return true + } + return accTenantID != nil && *accTenantID == *sess.TenantID +} diff --git a/internal/api/ldap_handlers.go b/internal/api/ldap_handlers.go new file mode 100644 index 0000000..4675719 --- /dev/null +++ b/internal/api/ldap_handlers.go @@ -0,0 +1,439 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "archivmail/internal/audit" + "archivmail/internal/ldapauth" + ldapcfg "archivmail/internal/ldapconfig" +) + +// ── 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) +} + +// ── PROJ-23: Per-Tenant LDAP — 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) +} + +// ── PROJ-23: Per-Tenant LDAP — 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) +} diff --git a/internal/api/ldap_tenants.go b/internal/api/ldap_tenants.go index 396fdd7..5e6affa 100644 --- a/internal/api/ldap_tenants.go +++ b/internal/api/ldap_tenants.go @@ -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}) -} - diff --git a/internal/api/pop3_handlers.go b/internal/api/pop3_handlers.go new file mode 100644 index 0000000..91139ba --- /dev/null +++ b/internal/api/pop3_handlers.go @@ -0,0 +1,243 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "archivmail/internal/auth" + pop3store "archivmail/internal/pop3" + "archivmail/internal/userstore" +) + +// ── POP3 handlers ────────────────────────────────────────────────────────── + +func (s *Server) handleListPop3(w http.ResponseWriter, r *http.Request) { + if s.pop3Store == nil { + writeError(w, http.StatusServiceUnavailable, "POP3 not configured") + return + } + sess := sessionFromCtx(r.Context()) + // SEC-03: Use HasRole to correctly check admin privileges (domain_admin, admin, superadmin). + isAdmin := auth.HasRole(sess.Role, userstore.RoleDomainAdmin) + accounts, err := s.pop3Store.List(r.Context(), sess.Username, isAdmin, sess.TenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list POP3 accounts") + return + } + if accounts == nil { + accounts = []pop3store.Account{} + } + writeJSON(w, http.StatusOK, accounts) +} + +func (s *Server) handleCreatePop3(w http.ResponseWriter, r *http.Request) { + if s.pop3Store == nil { + writeError(w, http.StatusServiceUnavailable, "POP3 not configured") + return + } + var req struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + TLS string `json:"tls"` + TLSSkipVerify bool `json:"tls_skip_verify"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Name == "" || req.Host == "" || req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "name, host, username and password are required") + return + } + if req.Port <= 0 { + req.Port = 110 + } + if req.TLS == "" { + req.TLS = "none" + } + + sess := sessionFromCtx(r.Context()) + acc := pop3store.Account{ + Owner: sess.Username, + Name: req.Name, + Host: req.Host, + Port: req.Port, + TLS: req.TLS, + TLSSkipVerify: req.TLSSkipVerify, + Username: req.Username, + TenantID: sess.TenantID, + } + + created, err := s.pop3Store.Create(r.Context(), acc, req.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create POP3 account") + return + } + + writeJSON(w, http.StatusCreated, created) +} + +func (s *Server) handleDeletePop3(w http.ResponseWriter, r *http.Request) { + if s.pop3Store == nil { + writeError(w, http.StatusServiceUnavailable, "POP3 not configured") + return + } + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + acc, err := s.pop3Store.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "account not found") + return + } + + sess := sessionFromCtx(r.Context()) + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { + writeError(w, http.StatusForbidden, "access denied") + return + } + + if err := s.pop3Store.Delete(r.Context(), id); err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete account") + return + } + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) handleTestPop3(w http.ResponseWriter, r *http.Request) { + var req struct { + Host string `json:"host"` + Port int `json:"port"` + TLS string `json:"tls"` + TLSSkipVerify bool `json:"tls_skip_verify"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Host == "" || req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "host, username and password are required") + return + } + if req.Port <= 0 { + req.Port = 110 + } + if req.TLS == "" { + req.TLS = "none" + } + + c, err := pop3store.Dial(req.Host, req.Port, req.TLS, req.TLSSkipVerify) + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": false, + "message": fmt.Sprintf("Verbindung fehlgeschlagen: %v", err), + }) + return + } + defer c.Close() + + if err := c.Login(req.Username, req.Password); err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": false, + "message": fmt.Sprintf("Anmeldung fehlgeschlagen: %v", err), + }) + return + } + + count, totalSize, err := c.Stat() + if err != nil { + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": false, + "message": fmt.Sprintf("STAT fehlgeschlagen: %v", err), + }) + return + } + + _ = c.Quit() + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, + "message": fmt.Sprintf("Verbindung erfolgreich: %d E-Mails", count), + "message_count": count, + "total_size_bytes": totalSize, + }) +} + +func (s *Server) handleStartPop3Import(w http.ResponseWriter, r *http.Request) { + if s.pop3Store == nil || s.pop3Importer == nil { + writeError(w, http.StatusServiceUnavailable, "POP3 not configured") + return + } + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + acc, err := s.pop3Store.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "account not found") + return + } + + sess := sessionFromCtx(r.Context()) + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { + writeError(w, http.StatusForbidden, "access denied") + return + } + + if acc.Status == "running" { + writeError(w, http.StatusConflict, "import already running") + return + } + + go s.pop3Importer.Run(context.Background(), id) + + // Return current account state (status will switch to "running" shortly) + acc.Status = "running" + writeJSON(w, http.StatusOK, acc) +} + +func (s *Server) handlePop3Progress(w http.ResponseWriter, r *http.Request) { + if s.pop3Store == nil { + writeError(w, http.StatusServiceUnavailable, "POP3 not configured") + return + } + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + + acc, err := s.pop3Store.Get(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "account not found") + return + } + + sess := sessionFromCtx(r.Context()) + if acc.Owner != sess.Username && !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { + writeError(w, http.StatusForbidden, "access denied") + return + } + if !tenantAccessAllowed(sess, acc.TenantID) { + writeError(w, http.StatusForbidden, "access denied") + return + } + + writeJSON(w, http.StatusOK, acc) +} diff --git a/internal/api/tenant_domain_handlers.go b/internal/api/tenant_domain_handlers.go new file mode 100644 index 0000000..58f5c9f --- /dev/null +++ b/internal/api/tenant_domain_handlers.go @@ -0,0 +1,78 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" +) + +// ── Tenant domain handlers ─────────────────────────────────────────────────── + +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) +} diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go new file mode 100644 index 0000000..ca76852 --- /dev/null +++ b/internal/api/tenant_handlers.go @@ -0,0 +1,216 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "archivmail/internal/audit" + "archivmail/internal/tenantstore" + "archivmail/internal/userstore" +) + +// ── 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) 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) +} diff --git a/internal/api/tenant_helpers.go b/internal/api/tenant_helpers.go new file mode 100644 index 0000000..e7e63a6 --- /dev/null +++ b/internal/api/tenant_helpers.go @@ -0,0 +1,23 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "strconv" +) + +// ── 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 +} diff --git a/internal/api/tenant_logo_handlers.go b/internal/api/tenant_logo_handlers.go new file mode 100644 index 0000000..7a8f8e5 --- /dev/null +++ b/internal/api/tenant_logo_handlers.go @@ -0,0 +1,205 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "strconv" + + "archivmail/internal/audit" +) + +// ── 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}) +}