Feature-Spec, INDEX.md und features.ts für PROJ-72 (Superadmin-Peer-Patch-Fix) ergänzt inkl. QA-Ergebnisse. Zusätzlich: abgelehnte Privilege-Escalation-/ Tenant-Isolation-Versuche (403) werden jetzt als Success:false im Audit-Log protokolliert (vorher nur erfolgreiche Updates) - Härtungspunkt aus QA-Runde.
296 lines
8.2 KiB
Go
296 lines
8.2 KiB
Go
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) {
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: fmt.Sprintf("denied: insufficient privileges to assign role %q on create", req.Role),
|
|
Success: false,
|
|
})
|
|
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 {
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: fmt.Sprintf("denied: cross-tenant access to user %d", id),
|
|
Success: false,
|
|
})
|
|
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. Superadmin is the top level, so peer (same-level)
|
|
// checks would otherwise block superadmin-vs-superadmin management.
|
|
if sess.Role != userstore.RoleSuperAdmin {
|
|
if roleLevel(target.Role) >= roleLevel(sess.Role) {
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: fmt.Sprintf("denied: insufficient privileges to modify user %d", id),
|
|
Success: false,
|
|
})
|
|
writeError(w, http.StatusForbidden, "insufficient privileges to modify this user")
|
|
return
|
|
}
|
|
if req.Role != nil && roleLevel(*req.Role) >= roleLevel(sess.Role) {
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventUserMgmt,
|
|
Username: sess.Username,
|
|
TenantID: sess.TenantID,
|
|
IPAddress: s.remoteIP(r),
|
|
Detail: fmt.Sprintf("denied: insufficient privileges to assign role %q to user %d", *req.Role, id),
|
|
Success: false,
|
|
})
|
|
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})
|
|
}
|