diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go
index abab9cf..c2aeaef 100644
--- a/backend/internal/auth/context.go
+++ b/backend/internal/auth/context.go
@@ -9,10 +9,19 @@ import (
type contextKey string
const (
+<<<<<<< HEAD
userIDKey contextKey = "user_id"
tenantIDKey contextKey = "tenant_id"
ipKey contextKey = "ip_address"
userAgentKey contextKey = "user_agent"
+||||||| 82878df
+ userIDKey contextKey = "user_id"
+ tenantIDKey contextKey = "tenant_id"
+=======
+ userIDKey contextKey = "user_id"
+ tenantIDKey contextKey = "tenant_id"
+ userRoleKey contextKey = "user_role"
+>>>>>>> mai/pike/p0-role-based
)
func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context {
@@ -32,6 +41,7 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(tenantIDKey).(uuid.UUID)
return id, ok
}
+<<<<<<< HEAD
func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context {
ctx = context.WithValue(ctx, ipKey, ip)
@@ -52,3 +62,15 @@ func UserAgentFromContext(ctx context.Context) *string {
}
return nil
}
+||||||| 82878df
+=======
+
+func ContextWithUserRole(ctx context.Context, role string) context.Context {
+ return context.WithValue(ctx, userRoleKey, role)
+}
+
+func UserRoleFromContext(ctx context.Context) string {
+ role, _ := ctx.Value(userRoleKey).(string)
+ return role
+}
+>>>>>>> mai/pike/p0-role-based
diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go
index 8ec7228..02428c6 100644
--- a/backend/internal/auth/middleware.go
+++ b/backend/internal/auth/middleware.go
@@ -40,15 +40,19 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler {
// Tenant management routes handle their own access control.
||||||| 82878df
- // Resolve tenant from user_tenants
- var tenantID uuid.UUID
- err = m.db.GetContext(r.Context(), &tenantID,
- "SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
+ // Resolve tenant and role from user_tenants
+ var membership struct {
+ TenantID uuid.UUID `db:"tenant_id"`
+ Role string `db:"role"`
+ }
+ err = m.db.GetContext(r.Context(), &membership,
+ "SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID)
if err != nil {
http.Error(w, "no tenant found for user", http.StatusForbidden)
return
}
- ctx = ContextWithTenantID(ctx, tenantID)
+ ctx = ContextWithTenantID(ctx, membership.TenantID)
+ ctx = ContextWithUserRole(ctx, membership.Role)
=======
diff --git a/backend/internal/auth/permissions.go b/backend/internal/auth/permissions.go
new file mode 100644
index 0000000..4a51c3e
--- /dev/null
+++ b/backend/internal/auth/permissions.go
@@ -0,0 +1,213 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+)
+
+// Valid roles ordered by privilege level (highest first).
+var ValidRoles = []string{"owner", "partner", "associate", "paralegal", "secretary"}
+
+// IsValidRole checks if a role string is one of the defined roles.
+func IsValidRole(role string) bool {
+ for _, r := range ValidRoles {
+ if r == role {
+ return true
+ }
+ }
+ return false
+}
+
+// Permission represents an action that can be checked against roles.
+type Permission int
+
+const (
+ PermManageTeam Permission = iota
+ PermManageBilling
+ PermCreateCase
+ PermEditAllCases
+ PermEditAssignedCase
+ PermViewAllCases
+ PermManageDeadlines
+ PermManageAppointments
+ PermUploadDocuments
+ PermDeleteDocuments
+ PermDeleteOwnDocuments
+ PermViewAuditLog
+ PermManageSettings
+ PermAIExtraction
+)
+
+// rolePermissions maps each role to its set of permissions.
+var rolePermissions = map[string]map[Permission]bool{
+ "owner": {
+ PermManageTeam: true,
+ PermManageBilling: true,
+ PermCreateCase: true,
+ PermEditAllCases: true,
+ PermEditAssignedCase: true,
+ PermViewAllCases: true,
+ PermManageDeadlines: true,
+ PermManageAppointments: true,
+ PermUploadDocuments: true,
+ PermDeleteDocuments: true,
+ PermDeleteOwnDocuments: true,
+ PermViewAuditLog: true,
+ PermManageSettings: true,
+ PermAIExtraction: true,
+ },
+ "partner": {
+ PermManageTeam: true,
+ PermManageBilling: true,
+ PermCreateCase: true,
+ PermEditAllCases: true,
+ PermEditAssignedCase: true,
+ PermViewAllCases: true,
+ PermManageDeadlines: true,
+ PermManageAppointments: true,
+ PermUploadDocuments: true,
+ PermDeleteDocuments: true,
+ PermDeleteOwnDocuments: true,
+ PermViewAuditLog: true,
+ PermManageSettings: true,
+ PermAIExtraction: true,
+ },
+ "associate": {
+ PermCreateCase: true,
+ PermEditAssignedCase: true,
+ PermViewAllCases: true,
+ PermManageDeadlines: true,
+ PermManageAppointments: true,
+ PermUploadDocuments: true,
+ PermDeleteOwnDocuments: true,
+ PermAIExtraction: true,
+ },
+ "paralegal": {
+ PermEditAssignedCase: true,
+ PermViewAllCases: true,
+ PermManageDeadlines: true,
+ PermManageAppointments: true,
+ PermUploadDocuments: true,
+ },
+ "secretary": {
+ PermViewAllCases: true,
+ PermManageAppointments: true,
+ PermUploadDocuments: true,
+ },
+}
+
+// HasPermission checks if the given role has the specified permission.
+func HasPermission(role string, perm Permission) bool {
+ perms, ok := rolePermissions[role]
+ if !ok {
+ return false
+ }
+ return perms[perm]
+}
+
+// RequirePermission returns middleware that checks if the user's role has the given permission.
+func RequirePermission(perm Permission) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ role := UserRoleFromContext(r.Context())
+ if role == "" || !HasPermission(role, perm) {
+ writeJSONError(w, "insufficient permissions", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// RequireRole returns middleware that checks if the user has one of the specified roles.
+func RequireRole(roles ...string) func(http.Handler) http.Handler {
+ allowed := make(map[string]bool, len(roles))
+ for _, r := range roles {
+ allowed[r] = true
+ }
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ role := UserRoleFromContext(r.Context())
+ if !allowed[role] {
+ writeJSONError(w, "insufficient permissions", http.StatusForbidden)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// IsAssignedToCase checks if a user is assigned to a specific case.
+func IsAssignedToCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID) (bool, error) {
+ var exists bool
+ err := db.GetContext(ctx, &exists,
+ `SELECT EXISTS(SELECT 1 FROM case_assignments WHERE user_id = $1 AND case_id = $2)`,
+ userID, caseID)
+ return exists, err
+}
+
+// CanEditCase checks if a user can edit a specific case based on role and assignment.
+func CanEditCase(ctx context.Context, db *sqlx.DB, userID, caseID uuid.UUID, role string) (bool, error) {
+ // Owner and partner can edit all cases
+ if HasPermission(role, PermEditAllCases) {
+ return true, nil
+ }
+ // Others need to be assigned
+ if !HasPermission(role, PermEditAssignedCase) {
+ return false, nil
+ }
+ return IsAssignedToCase(ctx, db, userID, caseID)
+}
+
+// CanDeleteDocument checks if a user can delete a specific document.
+func CanDeleteDocument(role string, docUploaderID, userID uuid.UUID) bool {
+ if HasPermission(role, PermDeleteDocuments) {
+ return true
+ }
+ if HasPermission(role, PermDeleteOwnDocuments) {
+ return docUploaderID == userID
+ }
+ return false
+}
+
+// permissionNames maps Permission constants to their string names for frontend use.
+var permissionNames = map[Permission]string{
+ PermManageTeam: "manage_team",
+ PermManageBilling: "manage_billing",
+ PermCreateCase: "create_case",
+ PermEditAllCases: "edit_all_cases",
+ PermEditAssignedCase: "edit_assigned_case",
+ PermViewAllCases: "view_all_cases",
+ PermManageDeadlines: "manage_deadlines",
+ PermManageAppointments: "manage_appointments",
+ PermUploadDocuments: "upload_documents",
+ PermDeleteDocuments: "delete_documents",
+ PermDeleteOwnDocuments: "delete_own_documents",
+ PermViewAuditLog: "view_audit_log",
+ PermManageSettings: "manage_settings",
+ PermAIExtraction: "ai_extraction",
+}
+
+// GetRolePermissions returns a list of permission name strings for the given role.
+func GetRolePermissions(role string) []string {
+ perms, ok := rolePermissions[role]
+ if !ok {
+ return nil
+ }
+ var names []string
+ for p := range perms {
+ if name, ok := permissionNames[p]; ok {
+ names = append(names, name)
+ }
+ }
+ return names
+}
+
+func writeJSONError(w http.ResponseWriter, msg string, status int) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ w.Write([]byte(`{"error":"` + msg + `"}`))
+}
diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go
index 24688d0..0dab57f 100644
--- a/backend/internal/auth/tenant_resolver.go
+++ b/backend/internal/auth/tenant_resolver.go
@@ -12,7 +12,12 @@ import (
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
+<<<<<<< HEAD
VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error)
+||||||| 82878df
+=======
+ GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error)
+>>>>>>> mai/pike/p0-role-based
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
@@ -41,6 +46,7 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest)
return
}
+<<<<<<< HEAD
// Verify user has access to this tenant
hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed)
@@ -54,9 +60,24 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return
}
+||||||| 82878df
+=======
+ // Verify user has access and get their role
+ role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed)
+ if err != nil {
+ http.Error(w, "error checking tenant access", http.StatusInternalServerError)
+ return
+ }
+ if role == "" {
+ http.Error(w, "no access to this tenant", http.StatusForbidden)
+ return
+ }
+>>>>>>> mai/pike/p0-role-based
tenantID = parsed
+ // Override the role from middleware with the correct one for this tenant
+ r = r.WithContext(ContextWithUserRole(r.Context(), role))
} else {
- // Default to user's first tenant
+ // Default to user's first tenant (role already set by middleware)
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
slog.Error("failed to resolve default tenant", "error", err, "user_id", userID)
diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go
index a542bdf..d0300c2 100644
--- a/backend/internal/auth/tenant_resolver_test.go
+++ b/backend/internal/auth/tenant_resolver_test.go
@@ -10,23 +10,49 @@ import (
)
type mockTenantLookup struct {
+<<<<<<< HEAD
tenantID *uuid.UUID
err error
hasAccess bool
accessErr error
+||||||| 82878df
+ tenantID *uuid.UUID
+ err error
+=======
+ tenantID *uuid.UUID
+ role string
+ err error
+>>>>>>> mai/pike/p0-role-based
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
+<<<<<<< HEAD
func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) {
return m.hasAccess, m.accessErr
}
+||||||| 82878df
+=======
+func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
+ if m.role != "" {
+ return m.role, m.err
+ }
+ return "associate", m.err
+}
+
+>>>>>>> mai/pike/p0-role-based
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
+<<<<<<< HEAD
tr := NewTenantResolver(&mockTenantLookup{hasAccess: true})
+||||||| 82878df
+ tr := NewTenantResolver(&mockTenantLookup{})
+=======
+ tr := NewTenantResolver(&mockTenantLookup{role: "partner"})
+>>>>>>> mai/pike/p0-role-based
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/handlers/case_assignments.go b/backend/internal/handlers/case_assignments.go
new file mode 100644
index 0000000..a58fcee
--- /dev/null
+++ b/backend/internal/handlers/case_assignments.go
@@ -0,0 +1,119 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/google/uuid"
+
+ "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
+ "mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
+)
+
+type CaseAssignmentHandler struct {
+ svc *services.CaseAssignmentService
+}
+
+func NewCaseAssignmentHandler(svc *services.CaseAssignmentService) *CaseAssignmentHandler {
+ return &CaseAssignmentHandler{svc: svc}
+}
+
+// List handles GET /api/cases/{id}/assignments
+func (h *CaseAssignmentHandler) List(w http.ResponseWriter, r *http.Request) {
+ tenantID, ok := auth.TenantFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusForbidden, "missing tenant")
+ return
+ }
+
+ caseID, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid case ID")
+ return
+ }
+
+ assignments, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]any{
+ "assignments": assignments,
+ "total": len(assignments),
+ })
+}
+
+// Assign handles POST /api/cases/{id}/assignments
+func (h *CaseAssignmentHandler) Assign(w http.ResponseWriter, r *http.Request) {
+ tenantID, ok := auth.TenantFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusForbidden, "missing tenant")
+ return
+ }
+
+ caseID, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid case ID")
+ return
+ }
+
+ var req struct {
+ UserID string `json:"user_id"`
+ Role string `json:"role"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ userID, err := uuid.Parse(req.UserID)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid user_id")
+ return
+ }
+
+ if req.Role == "" {
+ req.Role = "team"
+ }
+ if req.Role != "lead" && req.Role != "team" && req.Role != "viewer" {
+ writeError(w, http.StatusBadRequest, "role must be lead, team, or viewer")
+ return
+ }
+
+ assignment, err := h.svc.Assign(r.Context(), tenantID, caseID, userID, req.Role)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, assignment)
+}
+
+// Unassign handles DELETE /api/cases/{id}/assignments/{uid}
+func (h *CaseAssignmentHandler) Unassign(w http.ResponseWriter, r *http.Request) {
+ tenantID, ok := auth.TenantFromContext(r.Context())
+ if !ok {
+ writeError(w, http.StatusForbidden, "missing tenant")
+ return
+ }
+
+ caseID, err := uuid.Parse(r.PathValue("id"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid case ID")
+ return
+ }
+
+ userID, err := uuid.Parse(r.PathValue("uid"))
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "invalid user ID")
+ return
+ }
+
+ if err := h.svc.Unassign(r.Context(), tenantID, caseID, userID); err != nil {
+ writeError(w, http.StatusNotFound, err.Error())
+ return
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
+}
diff --git a/backend/internal/handlers/documents.go b/backend/internal/handlers/documents.go
index 3a9098c..7dec882 100644
--- a/backend/internal/handlers/documents.go
+++ b/backend/internal/handlers/documents.go
@@ -167,6 +167,7 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
userID, _ := auth.UserFromContext(r.Context())
+ role := auth.UserRoleFromContext(r.Context())
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
@@ -174,6 +175,26 @@ func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
return
}
+ // Check permission: owner/partner can delete any, associate can delete own
+ doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ if doc == nil {
+ writeError(w, http.StatusNotFound, "document not found")
+ return
+ }
+
+ uploaderID := uuid.Nil
+ if doc.UploadedBy != nil {
+ uploaderID = *doc.UploadedBy
+ }
+ if !auth.CanDeleteDocument(role, uploaderID, userID) {
+ writeError(w, http.StatusForbidden, "insufficient permissions to delete this document")
+ return
+ }
+
if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
diff --git a/backend/internal/handlers/tenant_handler.go b/backend/internal/handlers/tenant_handler.go
index 52bb29f..6341b1d 100644
--- a/backend/internal/handlers/tenant_handler.go
+++ b/backend/internal/handlers/tenant_handler.go
@@ -130,15 +130,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return
}
- // Only owners and admins can invite
+ // Only owners and partners 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)
+ if role != "owner" && role != "partner" {
+ jsonError(w, "only owners and partners can invite users", http.StatusForbidden)
return
}
@@ -155,10 +155,15 @@ func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
return
}
if req.Role == "" {
- req.Role = "member"
+ req.Role = "associate"
}
- if req.Role != "member" && req.Role != "admin" {
- jsonError(w, "role must be member or admin", http.StatusBadRequest)
+ if !auth.IsValidRole(req.Role) {
+ jsonError(w, "invalid role", http.StatusBadRequest)
+ return
+ }
+ // Non-owners cannot invite as owner
+ if role != "owner" && req.Role == "owner" {
+ jsonError(w, "only owners can invite as owner", http.StatusForbidden)
return
}
@@ -192,14 +197,14 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
return
}
- // Only owners and admins can remove members (or user removing themselves)
+ // Only owners and partners 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 {
+ if role != "owner" && role != "partner" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden)
return
}
@@ -227,15 +232,15 @@ func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
return
}
- // Only owners and admins can update settings
+ // Only owners and partners 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)
+ if role != "owner" && role != "partner" {
+ jsonError(w, "only owners and partners can update settings", http.StatusForbidden)
return
}
@@ -294,6 +299,85 @@ func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
jsonResponse(w, members, http.StatusOK)
}
+// UpdateMemberRole handles PUT /api/tenants/{id}/members/{uid}/role
+func (h *TenantHandler) UpdateMemberRole(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 partners can change roles
+ role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
+ if err != nil {
+ jsonError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if role != "owner" && role != "partner" {
+ jsonError(w, "only owners and partners can change roles", http.StatusForbidden)
+ return
+ }
+
+ var req struct {
+ Role string `json:"role"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ jsonError(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+ if !auth.IsValidRole(req.Role) {
+ jsonError(w, "invalid role", http.StatusBadRequest)
+ return
+ }
+
+ // Non-owners cannot promote to owner
+ if role != "owner" && req.Role == "owner" {
+ jsonError(w, "only owners can promote to owner", http.StatusForbidden)
+ return
+ }
+
+ if err := h.svc.UpdateMemberRole(r.Context(), tenantID, memberID, req.Role); err != nil {
+ jsonError(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ jsonResponse(w, map[string]string{"status": "updated"}, http.StatusOK)
+}
+
+// GetMe handles GET /api/me — returns the current user's ID and role in the active tenant.
+func (h *TenantHandler) GetMe(w http.ResponseWriter, r *http.Request) {
+ userID, ok := auth.UserFromContext(r.Context())
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ role := auth.UserRoleFromContext(r.Context())
+ tenantID, _ := auth.TenantFromContext(r.Context())
+
+ // Get user's permissions for frontend UI
+ perms := auth.GetRolePermissions(role)
+
+ jsonResponse(w, map[string]any{
+ "user_id": userID,
+ "tenant_id": tenantID,
+ "role": role,
+ "permissions": perms,
+ }, http.StatusOK)
+}
+
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
diff --git a/backend/internal/models/case_assignment.go b/backend/internal/models/case_assignment.go
new file mode 100644
index 0000000..3d7a9ce
--- /dev/null
+++ b/backend/internal/models/case_assignment.go
@@ -0,0 +1,15 @@
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type CaseAssignment struct {
+ ID uuid.UUID `db:"id" json:"id"`
+ CaseID uuid.UUID `db:"case_id" json:"case_id"`
+ UserID uuid.UUID `db:"user_id" json:"user_id"`
+ Role string `db:"role" json:"role"`
+ AssignedAt time.Time `db:"assigned_at" json:"assigned_at"`
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 156d1dc..84b7f8e 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -29,7 +29,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
+<<<<<<< HEAD
documentSvc := services.NewDocumentService(db, storageCli, auditSvc)
+||||||| 82878df
+ documentSvc := services.NewDocumentService(db, storageCli)
+=======
+ documentSvc := services.NewDocumentService(db, storageCli)
+ assignmentSvc := services.NewCaseAssignmentService(db)
+>>>>>>> mai/pike/p0-role-based
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
@@ -63,6 +70,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
noteH := handlers.NewNoteHandler(noteSvc)
eventH := handlers.NewCaseEventHandler(db)
docH := handlers.NewDocumentHandler(documentSvc)
+ assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -78,76 +86,106 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
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()
- // 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)
+ // Current user info (role, permissions) — all authenticated users
+ scoped.HandleFunc("GET /api/me", tenantH.GetMe)
- // Parties
+ // 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
+ // 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", 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)
+ 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)
+ // 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
+ // Deadline calculator — all can use
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
- // Appointments
+ // 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", apptH.Create)
- scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
- scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
+ 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 events
+ // 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
+ // 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
+ // Dashboard — all can view
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
+<<<<<<< HEAD
// Audit log
scoped.HandleFunc("GET /api/audit-log", auditH.List)
// Documents
+||||||| 82878df
+ // Documents
+=======
+ // Documents — all can upload, delete checked in handler (own vs all)
+>>>>>>> mai/pike/p0-role-based
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
- scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
+ 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)
+ 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", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
- scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase))
+ 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)))
}
+<<<<<<< HEAD
// Notifications
if notifH != nil {
scoped.HandleFunc("GET /api/notifications", notifH.List)
@@ -159,9 +197,14 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
}
// CalDAV sync endpoints
+||||||| 82878df
+ // CalDAV sync endpoints
+=======
+ // CalDAV sync endpoints — settings permission required
+>>>>>>> mai/pike/p0-role-based
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
- scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync)
+ scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync))
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
}
diff --git a/backend/internal/services/case_assignment_service.go b/backend/internal/services/case_assignment_service.go
new file mode 100644
index 0000000..1fb8545
--- /dev/null
+++ b/backend/internal/services/case_assignment_service.go
@@ -0,0 +1,92 @@
+package services
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+
+ "mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
+)
+
+type CaseAssignmentService struct {
+ db *sqlx.DB
+}
+
+func NewCaseAssignmentService(db *sqlx.DB) *CaseAssignmentService {
+ return &CaseAssignmentService{db: db}
+}
+
+// ListByCase returns all assignments for a case.
+func (s *CaseAssignmentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.CaseAssignment, error) {
+ var assignments []models.CaseAssignment
+ err := s.db.SelectContext(ctx, &assignments,
+ `SELECT ca.id, ca.case_id, ca.user_id, ca.role, ca.assigned_at
+ FROM case_assignments ca
+ JOIN cases c ON c.id = ca.case_id
+ WHERE ca.case_id = $1 AND c.tenant_id = $2
+ ORDER BY ca.assigned_at`,
+ caseID, tenantID)
+ if err != nil {
+ return nil, fmt.Errorf("list case assignments: %w", err)
+ }
+ return assignments, nil
+}
+
+// Assign adds a user to a case with the given role.
+func (s *CaseAssignmentService) Assign(ctx context.Context, tenantID, caseID, userID uuid.UUID, role string) (*models.CaseAssignment, error) {
+ // Verify user is a member of this tenant
+ var memberExists bool
+ err := s.db.GetContext(ctx, &memberExists,
+ `SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
+ userID, tenantID)
+ if err != nil {
+ return nil, fmt.Errorf("check membership: %w", err)
+ }
+ if !memberExists {
+ return nil, fmt.Errorf("user is not a member of this tenant")
+ }
+
+ // Verify case belongs to tenant
+ var caseExists bool
+ err = s.db.GetContext(ctx, &caseExists,
+ `SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)`,
+ caseID, tenantID)
+ if err != nil {
+ return nil, fmt.Errorf("check case: %w", err)
+ }
+ if !caseExists {
+ return nil, fmt.Errorf("case not found")
+ }
+
+ var assignment models.CaseAssignment
+ err = s.db.QueryRowxContext(ctx,
+ `INSERT INTO case_assignments (case_id, user_id, role)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (case_id, user_id) DO UPDATE SET role = EXCLUDED.role
+ RETURNING id, case_id, user_id, role, assigned_at`,
+ caseID, userID, role,
+ ).StructScan(&assignment)
+ if err != nil {
+ return nil, fmt.Errorf("assign user to case: %w", err)
+ }
+ return &assignment, nil
+}
+
+// Unassign removes a user from a case.
+func (s *CaseAssignmentService) Unassign(ctx context.Context, tenantID, caseID, userID uuid.UUID) error {
+ result, err := s.db.ExecContext(ctx,
+ `DELETE FROM case_assignments ca
+ USING cases c
+ WHERE ca.case_id = c.id AND ca.case_id = $1 AND ca.user_id = $2 AND c.tenant_id = $3`,
+ caseID, userID, tenantID)
+ if err != nil {
+ return fmt.Errorf("unassign: %w", err)
+ }
+ rows, _ := result.RowsAffected()
+ if rows == 0 {
+ return fmt.Errorf("assignment not found")
+ }
+ return nil
+}
diff --git a/backend/internal/services/tenant_service.go b/backend/internal/services/tenant_service.go
index 5e47a80..e0f5cea 100644
--- a/backend/internal/services/tenant_service.go
+++ b/backend/internal/services/tenant_service.go
@@ -206,6 +206,40 @@ func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID,
return &tenant, nil
}
+// UpdateMemberRole changes a member's role in a tenant.
+func (s *TenantService) UpdateMemberRole(ctx context.Context, tenantID, userID uuid.UUID, newRole string) error {
+ // Get current role
+ currentRole, err := s.GetUserRole(ctx, userID, tenantID)
+ if err != nil {
+ return fmt.Errorf("get current role: %w", err)
+ }
+ if currentRole == "" {
+ return fmt.Errorf("user is not a member of this tenant")
+ }
+
+ // If demoting the last owner, block it
+ if currentRole == "owner" && newRole != "owner" {
+ var ownerCount int
+ err := s.db.GetContext(ctx, &ownerCount,
+ `SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
+ tenantID)
+ if err != nil {
+ return fmt.Errorf("count owners: %w", err)
+ }
+ if ownerCount <= 1 {
+ return fmt.Errorf("cannot demote the last owner")
+ }
+ }
+
+ _, err = s.db.ExecContext(ctx,
+ `UPDATE user_tenants SET role = $1 WHERE user_id = $2 AND tenant_id = $3`,
+ newRole, userID, tenantID)
+ if err != nil {
+ return fmt.Errorf("update role: %w", err)
+ }
+ return nil
+}
+
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner
diff --git a/frontend/src/app/(app)/cases/[id]/layout.tsx b/frontend/src/app/(app)/cases/[id]/layout.tsx
index a8ea73f..857f570 100644
--- a/frontend/src/app/(app)/cases/[id]/layout.tsx
+++ b/frontend/src/app/(app)/cases/[id]/layout.tsx
@@ -13,6 +13,7 @@ import {
Clock,
FileText,
Users,
+ UserCheck,
StickyNote,
AlertTriangle,
ScrollText,
@@ -44,6 +45,7 @@ const TABS = [
{ segment: "fristen", label: "Fristen", icon: Clock },
{ segment: "dokumente", label: "Dokumente", icon: FileText },
{ segment: "parteien", label: "Parteien", icon: Users },
+ { segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
{ segment: "notizen", label: "Notizen", icon: StickyNote },
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
] as const;
@@ -53,6 +55,7 @@ const TAB_LABELS: Record
+ {a.user_id.slice(0, 8)}... +
++ {CASE_ASSIGNMENT_ROLE_LABELS[a.role as CaseAssignmentRole] ?? + a.role} +
+