refactor: große API-Handler-Dateien in fokussierte Module aufteilen
Reine Code-Verschiebung, keine Logikänderung. Betroffen: - ldap_tenants.go (994 Zeilen) -> ldap_tenants.go (Routing) + ldap_handlers.go + tenant_handlers.go + tenant_domain_handlers.go + tenant_logo_handlers.go + tenant_helpers.go - import_handlers.go (621 Zeilen) -> imap_handlers.go + pop3_handlers.go + import_helpers.go - admin_handlers.go (702 Zeilen) -> admin_users_handlers.go + admin_status_handlers.go + admin_services_handlers.go + admin_security_handlers.go Lokal kein Go-Build möglich — Verifikation manuell per Funktions- und Import-Abgleich Alt/Neu (alle Symbole exakt einmal vorhanden). Build muss vor Deploy auf 192.168.1.131/132 bestätigt werden.
This commit is contained in:
@@ -1,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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user