refactor: große API-Handler-Dateien in fokussierte Module aufteilen

Reine Code-Verschiebung, keine Logikänderung. Betroffen:
- ldap_tenants.go (994 Zeilen) -> ldap_tenants.go (Routing) + ldap_handlers.go
  + tenant_handlers.go + tenant_domain_handlers.go + tenant_logo_handlers.go
  + tenant_helpers.go
- import_handlers.go (621 Zeilen) -> imap_handlers.go + pop3_handlers.go
  + import_helpers.go
- admin_handlers.go (702 Zeilen) -> admin_users_handlers.go +
  admin_status_handlers.go + admin_services_handlers.go +
  admin_security_handlers.go

Lokal kein Go-Build möglich — Verifikation manuell per Funktions- und
Import-Abgleich Alt/Neu (alle Symbole exakt einmal vorhanden). Build
muss vor Deploy auf 192.168.1.131/132 bestätigt werden.
This commit is contained in:
sysops
2026-06-21 23:38:57 +02:00
parent 6d3ca7fd50
commit 1d27dc2d8b
14 changed files with 1948 additions and 1868 deletions
-702
View File
@@ -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)
}
+240
View File
@@ -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)
}
+170
View File
@@ -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))
}
+58
View File
@@ -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"],
})
}
+260
View File
@@ -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})
}
@@ -9,23 +9,11 @@ import (
"archivmail/internal/auth" "archivmail/internal/auth"
imapstore "archivmail/internal/imap" imapstore "archivmail/internal/imap"
pop3store "archivmail/internal/pop3"
"archivmail/internal/userstore" "archivmail/internal/userstore"
) )
// ── IMAP handlers ───────────────────────────────────────────────────────── // ── 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) { func (s *Server) handleListImap(w http.ResponseWriter, r *http.Request) {
if s.imapStore == nil { if s.imapStore == nil {
writeError(w, http.StatusServiceUnavailable, "IMAP not configured") 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 acc.SyncIntervalMin = req.SyncIntervalMin
writeJSON(w, http.StatusOK, acc) 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)
}
+16
View File
@@ -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
}
+439
View File
@@ -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)
}
-924
View File
@@ -1,16 +1,6 @@
package api package api
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"archivmail/internal/audit"
"archivmail/internal/ldapauth"
ldapcfg "archivmail/internal/ldapconfig" ldapcfg "archivmail/internal/ldapconfig"
"archivmail/internal/tenantstore" "archivmail/internal/tenantstore"
"archivmail/internal/userstore" "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)) 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 // SetTenantLDAP wires the per-tenant LDAP config store into the API server and
// registers the tenant LDAP routes. // registers the tenant LDAP routes.
func (s *Server) SetTenantLDAP(store *ldapcfg.TenantStore) { 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/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))) 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})
}
+243
View File
@@ -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)
}
+78
View File
@@ -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)
}
+216
View File
@@ -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)
}
+23
View File
@@ -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
}
+205
View File
@@ -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})
}