1. Tenant isolation bypass (CRITICAL): TenantResolver now verifies user has access to X-Tenant-ID via user_tenants lookup before setting context. Added VerifyAccess method to TenantLookup interface and TenantService. 2. Consolidated tenant resolution: Removed duplicate resolveTenant() from helpers.go and tenant resolution from auth middleware. TenantResolver is now the single source of truth. Deadlines and AI handlers use auth.TenantFromContext() instead of direct DB queries. 3. CalDAV credential masking: tenant settings responses now mask CalDAV passwords with "********" via maskSettingsPassword helper. Applied to GetTenant, ListTenants, and UpdateSettings responses. 4. CORS + security headers: New middleware/security.go with CORS (restricted to FRONTEND_ORIGIN) and security headers (X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, X-XSS-Protection). 5. Internal error leaking: All writeError(w, 500, err.Error()) replaced with internalError() that logs via slog and returns generic "internal error" to client. Same for jsonError in tenant handler. 6. Input validation: Max length on title (500), description (10000), case_number (100), search (200). Pagination clamped to max 100. Content-Disposition filename sanitized against header injection. Regression test added for tenant access denial (403 on unauthorized X-Tenant-ID). All existing tests pass, go vet clean.
308 lines
8.3 KiB
Go
308 lines
8.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
|
)
|
|
|
|
type TenantHandler struct {
|
|
svc *services.TenantService
|
|
}
|
|
|
|
func NewTenantHandler(svc *services.TenantService) *TenantHandler {
|
|
return &TenantHandler{svc: svc}
|
|
}
|
|
|
|
// CreateTenant handles POST /api/tenants
|
|
func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == "" || req.Slug == "" {
|
|
jsonError(w, "name and slug are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
|
|
if err != nil {
|
|
slog.Error("failed to create tenant", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, tenant, http.StatusCreated)
|
|
}
|
|
|
|
// ListTenants handles GET /api/tenants
|
|
func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenants, err := h.svc.ListForUser(r.Context(), userID)
|
|
if err != nil {
|
|
slog.Error("failed to list tenants", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Mask CalDAV passwords in tenant settings
|
|
for i := range tenants {
|
|
tenants[i].Settings = maskSettingsPassword(tenants[i].Settings)
|
|
}
|
|
|
|
jsonResponse(w, tenants, http.StatusOK)
|
|
}
|
|
|
|
// GetTenant handles GET /api/tenants/{id}
|
|
func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenantID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify user has access to this tenant
|
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get user role", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if role == "" {
|
|
jsonError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
tenant, err := h.svc.GetByID(r.Context(), tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get tenant", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if tenant == nil {
|
|
jsonError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Mask CalDAV password before returning
|
|
tenant.Settings = maskSettingsPassword(tenant.Settings)
|
|
|
|
jsonResponse(w, tenant, http.StatusOK)
|
|
}
|
|
|
|
// InviteUser handles POST /api/tenants/{id}/invite
|
|
func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenantID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Only owners and admins can invite
|
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get user role", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if role != "owner" && role != "admin" {
|
|
jsonError(w, "only owners and admins can invite users", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Email == "" {
|
|
jsonError(w, "email is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Role == "" {
|
|
req.Role = "member"
|
|
}
|
|
if req.Role != "member" && req.Role != "admin" {
|
|
jsonError(w, "role must be member or admin", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
|
|
if err != nil {
|
|
// These are user-facing validation errors (user not found, already member)
|
|
jsonError(w, "failed to invite user", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, ut, http.StatusCreated)
|
|
}
|
|
|
|
// RemoveMember handles DELETE /api/tenants/{id}/members/{uid}
|
|
func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenantID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
memberID, err := uuid.Parse(r.PathValue("uid"))
|
|
if err != nil {
|
|
jsonError(w, "invalid member ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Only owners and admins can remove members (or user removing themselves)
|
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get user role", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if role != "owner" && role != "admin" && userID != memberID {
|
|
jsonError(w, "insufficient permissions", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
|
|
// These are user-facing validation errors (not a member, last owner, etc.)
|
|
jsonError(w, "failed to remove member", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK)
|
|
}
|
|
|
|
// UpdateSettings handles PUT /api/tenants/{id}/settings
|
|
func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenantID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Only owners and admins can update settings
|
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get user role", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if role != "owner" && role != "admin" {
|
|
jsonError(w, "only owners and admins can update settings", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
var settings json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
|
|
if err != nil {
|
|
slog.Error("failed to update settings", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Mask CalDAV password before returning
|
|
tenant.Settings = maskSettingsPassword(tenant.Settings)
|
|
|
|
jsonResponse(w, tenant, http.StatusOK)
|
|
}
|
|
|
|
// ListMembers handles GET /api/tenants/{id}/members
|
|
func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := auth.UserFromContext(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
tenantID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify user has access
|
|
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to get user role", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if role == "" {
|
|
jsonError(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
members, err := h.svc.ListMembers(r.Context(), tenantID)
|
|
if err != nil {
|
|
slog.Error("failed to list members", "error", err)
|
|
jsonError(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, members, http.StatusOK)
|
|
}
|
|
|
|
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func jsonError(w http.ResponseWriter, msg string, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|