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.
203 lines
7.1 KiB
Go
203 lines
7.1 KiB
Go
package router
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/middleware"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
|
)
|
|
|
|
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService) http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
// Services
|
|
tenantSvc := services.NewTenantService(db)
|
|
caseSvc := services.NewCaseService(db)
|
|
partySvc := services.NewPartyService(db)
|
|
appointmentSvc := services.NewAppointmentService(db)
|
|
holidaySvc := services.NewHolidayService(db)
|
|
deadlineSvc := services.NewDeadlineService(db)
|
|
deadlineRuleSvc := services.NewDeadlineRuleService(db)
|
|
calculator := services.NewDeadlineCalculator(holidaySvc)
|
|
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
|
|
documentSvc := services.NewDocumentService(db, storageCli)
|
|
|
|
// AI service (optional — only if API key is configured)
|
|
var aiH *handlers.AIHandler
|
|
if cfg.AnthropicAPIKey != "" {
|
|
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
|
|
aiH = handlers.NewAIHandler(aiSvc)
|
|
}
|
|
|
|
// Middleware
|
|
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
|
|
|
noteSvc := services.NewNoteService(db)
|
|
dashboardSvc := services.NewDashboardService(db)
|
|
|
|
// Handlers
|
|
tenantH := handlers.NewTenantHandler(tenantSvc)
|
|
caseH := handlers.NewCaseHandler(caseSvc)
|
|
partyH := handlers.NewPartyHandler(partySvc)
|
|
apptH := handlers.NewAppointmentHandler(appointmentSvc)
|
|
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc)
|
|
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
|
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
|
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
|
noteH := handlers.NewNoteHandler(noteSvc)
|
|
eventH := handlers.NewCaseEventHandler(db)
|
|
docH := handlers.NewDocumentHandler(documentSvc)
|
|
|
|
// Public routes
|
|
mux.HandleFunc("GET /health", handleHealth(db))
|
|
|
|
// Authenticated API routes
|
|
api := http.NewServeMux()
|
|
|
|
// Tenant management (no tenant resolver — these operate across tenants)
|
|
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
|
|
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
|
|
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
|
|
api.HandleFunc("PUT /api/tenants/{id}/settings", tenantH.UpdateSettings)
|
|
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
|
|
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
|
|
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
|
|
|
|
// Tenant-scoped routes (require tenant context)
|
|
scoped := http.NewServeMux()
|
|
|
|
// Cases
|
|
scoped.HandleFunc("GET /api/cases", caseH.List)
|
|
scoped.HandleFunc("POST /api/cases", caseH.Create)
|
|
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
|
|
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
|
|
scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
|
|
|
|
// Parties
|
|
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
|
|
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
|
|
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
|
|
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
|
|
|
|
// Deadlines
|
|
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
|
|
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
|
|
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
|
|
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
|
|
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
|
|
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
|
|
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
|
|
|
|
// Deadline rules (reference data)
|
|
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
|
|
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
|
|
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
|
|
|
|
// Deadline calculator
|
|
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
|
|
|
|
// Appointments
|
|
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
|
|
scoped.HandleFunc("GET /api/appointments", apptH.List)
|
|
scoped.HandleFunc("POST /api/appointments", apptH.Create)
|
|
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
|
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
|
|
|
// Case events
|
|
scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
|
|
|
|
// Notes
|
|
scoped.HandleFunc("GET /api/notes", noteH.List)
|
|
scoped.HandleFunc("POST /api/notes", noteH.Create)
|
|
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
|
|
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
|
|
|
|
// Dashboard
|
|
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
|
|
|
// Documents
|
|
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
|
|
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
|
|
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
|
|
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
|
|
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
|
|
|
|
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
|
|
if aiH != nil {
|
|
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
|
|
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
|
|
scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase))
|
|
}
|
|
|
|
// CalDAV sync endpoints
|
|
if calDAVSvc != nil {
|
|
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
|
|
scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync)
|
|
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
|
|
}
|
|
|
|
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
|
|
api.Handle("/api/", tenantResolver.Resolve(scoped))
|
|
|
|
mux.Handle("/api/", authMW.RequireAuth(api))
|
|
|
|
// Apply security middleware stack: CORS -> Security Headers -> Request Logger -> Routes
|
|
var handler http.Handler = mux
|
|
handler = requestLogger(handler)
|
|
handler = middleware.SecurityHeaders(handler)
|
|
handler = middleware.CORS(cfg.FrontendOrigin)(handler)
|
|
|
|
return handler
|
|
}
|
|
|
|
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if err := db.Ping(); err != nil {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "error"})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
type statusWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (w *statusWriter) WriteHeader(code int) {
|
|
w.status = code
|
|
w.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func requestLogger(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Skip health checks to reduce noise
|
|
if r.URL.Path == "/health" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
|
start := time.Now()
|
|
next.ServeHTTP(sw, r)
|
|
|
|
slog.Info("request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", sw.status,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
})
|
|
}
|