Files
KanzlAI-mGMT/backend/internal/router/router.go
m 0a0ec016d8 feat: role-based permissions (owner/partner/associate/paralegal/secretary)
Backend:
- auth/permissions.go: full permission matrix with RequirePermission/RequireRole
  middleware, CanEditCase, CanDeleteDocument helpers
- auth/context.go: add user role to request context
- auth/middleware.go: resolve role alongside tenant in auth flow
- auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID
- handlers/case_assignments.go: CRUD for case-level user assignments
- handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints
- handlers/documents.go: permission-based delete (own vs all)
- router/router.go: permission-wrapped routes for all endpoints
- services/case_assignment_service.go: assign/unassign with tenant validation
- services/tenant_service.go: UpdateMemberRole with owner protection
- models/case_assignment.go: CaseAssignment model

Database:
- user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary)
- case_assignments table: case_id, user_id, role (lead/team/viewer)
- Migrated existing admin->partner, member->associate

Frontend:
- usePermissions hook: fetches /api/me, provides can() helper
- TeamSettings: 5-role dropdown, role change, permission-gated invite
- CaseAssignments: new component for case-level team management
- Sidebar: conditionally hides AI/Settings based on permissions
- Cases page: hides "Neue Akte" button for non-authorized roles
- Case detail: new "Mitarbeiter" tab for assignment management
2026-03-30 11:04:57 +02:00

223 lines
8.9 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)
assignmentSvc := services.NewCaseAssignmentService(db)
// 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, db)
}
// 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, db)
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)
assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// 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)
api.HandleFunc("PUT /api/tenants/{id}/members/{uid}/role", tenantH.UpdateMemberRole)
// Permission-wrapping helper: wraps a HandlerFunc with a permission check
perm := func(p auth.Permission, fn http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
role := auth.UserRoleFromContext(r.Context())
if !auth.HasPermission(role, p) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte(`{"error":"insufficient permissions"}`))
return
}
fn(w, r)
}
}
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
// Current user info (role, permissions) — all authenticated users
scoped.HandleFunc("GET /api/me", tenantH.GetMe)
// Cases — all can view, create needs PermCreateCase, archive needs PermCreateCase
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create))
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler
scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete))
// Parties — same access as case editing
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 — manage needs PermManageDeadlines, view is open
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", perm(auth.PermManageDeadlines, deadlineH.Create))
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Update))
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", perm(auth.PermManageDeadlines, deadlineH.Complete))
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", perm(auth.PermManageDeadlines, deadlineH.Delete))
// Deadline rules (reference data) — all can read
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 — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments — all can manage (PermManageAppointments granted to all)
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", perm(auth.PermManageAppointments, apptH.Create))
scoped.HandleFunc("PUT /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Update))
scoped.HandleFunc("DELETE /api/appointments/{id}", perm(auth.PermManageAppointments, apptH.Delete))
// Case assignments — manage team required for assign/unassign
scoped.HandleFunc("GET /api/cases/{id}/assignments", assignmentH.List)
scoped.HandleFunc("POST /api/cases/{id}/assignments", perm(auth.PermManageTeam, assignmentH.Assign))
scoped.HandleFunc("DELETE /api/cases/{id}/assignments/{uid}", perm(auth.PermManageTeam, assignmentH.Unassign))
// Case events — all can view
scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
// Notes — all can manage
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 — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Documents — all can upload, delete checked in handler (own vs all)
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, 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) // permission check inside handler
// 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", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.ExtractDeadlines)))
scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase)))
}
// CalDAV sync endpoints — settings permission required
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, 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))
return requestLogger(mux)
}
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", "error": err.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(),
)
})
}