From 6980827fa2468d000595be8604a6b546d75ca4cc Mon Sep 17 00:00:00 2001 From: James Date: Sat, 7 Feb 2026 17:35:42 -0500 Subject: [PATCH] feature: add RBAC editor with role templates and per-category permissions Added comprehensive RBAC editor accessible from dossier permissions page. Supports quick role presets and granular per-category permission control. Features: - Role template dropdown (Family, Doctor, Caregiver, Trainer, Friend, Researcher) - Automatic checkbox population from role selection - Base permissions: Read, Write, Delete, Manage - Per-category permissions for 9 data types - Manual checkbox override after role selection - Save/Cancel buttons with confirmation - Revoke all access option - Matches existing design system Components: - templates/edit_rbac.tmpl: New RBAC editor page - portal/main.go: handleEditRBAC() handler - portal/main.go: CategoryRBACView type for per-category ops - portal/main.go: Updated RoleView with GrantsJSON for JavaScript - templates/base.tmpl: Added edit_rbac case - templates/permissions.tmpl: Edit button now links to RBAC editor UI Design: - Follows styleguide patterns (data-card, form-group, sg-select) - Checkbox grid layout for base ops - Category blocks with 4 operation checkboxes each - JavaScript for role template application - Success/error message display Routing: /dossier/{id}/rbac/{grantee_id} Co-Authored-By: Claude Sonnet 4.5 --- portal/main.go | 215 ++++++++++++++++++++++++++++++ portal/templates/base.tmpl | 1 + portal/templates/edit_rbac.tmpl | 168 +++++++++++++++++++++++ portal/templates/permissions.tmpl | 2 +- 4 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 portal/templates/edit_rbac.tmpl diff --git a/portal/main.go b/portal/main.go index 5878e67..f91fac5 100644 --- a/portal/main.go +++ b/portal/main.go @@ -129,6 +129,8 @@ type PageData struct { HasRead, HasWrite, HasDelete, HasManage bool Categories []CategoryAccess EntryGrants []EntryGrant + // RBAC edit page + CategoriesRBAC []CategoryRBACView // Dossier: unified sections Sections []DossierSection LabRefJSON template.JS // JSON: abbreviation → {direction, refLow, refHigh} @@ -1428,6 +1430,18 @@ type RoleView struct { Name string Description string Ops string + GrantsJSON string // JSON-encoded grants for JavaScript +} + +// CategoryRBACView represents a category with per-operation permissions +type CategoryRBACView struct { + ID int + Name string + Description string + CanRead bool + CanWrite bool + CanDelete bool + CanManage bool } func handlePermissions(w http.ResponseWriter, r *http.Request) { @@ -1593,6 +1607,206 @@ var accessCategories = []struct { {lib.CategoryNote, "Notes"}, } +func handleEditRBAC(w http.ResponseWriter, r *http.Request) { + p := getLoggedInDossier(r) + if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } + lang := getLang(r) + + // Parse path: /dossier/{id}/rbac/{grantee_id} + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 5 { http.NotFound(w, r); return } + targetID := parts[2] + granteeID := parts[4] + + target, err := lib.DossierGet(nil, targetID) + if err != nil { http.NotFound(w, r); return } + + grantee, err := lib.DossierGet(nil, granteeID) + if err != nil { http.NotFound(w, r); return } + + // Check manage permission + if !lib.CanManageDossier(p.DossierID, targetID) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + // Handle POST + if r.Method == "POST" { + action := r.FormValue("action") + + if action == "revoke" { + lib.AccessRevokeAll(targetID, granteeID) + lib.InvalidateCacheForAccessor(granteeID) + lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_revoke", "", 0) + http.Redirect(w, r, "/dossier/"+targetID, http.StatusSeeOther) + return + } + + if action == "update" { + // Build base ops from checkboxes + baseOps := "" + if r.FormValue("op_r") == "1" { baseOps += "r" } + if r.FormValue("op_w") == "1" { baseOps += "w" } + if r.FormValue("op_d") == "1" { baseOps += "d" } + if r.FormValue("op_m") == "1" { baseOps += "m" } + + // Clear existing grants + lib.AccessRevokeAll(targetID, granteeID) + + // Create root grant if base ops specified + if baseOps != "" { + lib.AccessGrantWrite(&lib.Access{ + DossierID: targetID, + GranteeID: granteeID, + EntryID: "", + Role: "Custom", + Ops: baseOps, + }) + } + + // Create category-specific grants + allCats := []int{ + lib.CategoryImaging, lib.CategoryDocument, lib.CategoryLab, + lib.CategoryGenome, lib.CategoryVital, lib.CategoryMedication, + lib.CategorySupplement, lib.CategoryExercise, lib.CategorySymptom, + } + + for _, catID := range allCats { + catOps := "" + if r.FormValue(fmt.Sprintf("cat_%d_r", catID)) == "1" { catOps += "r" } + if r.FormValue(fmt.Sprintf("cat_%d_w", catID)) == "1" { catOps += "w" } + if r.FormValue(fmt.Sprintf("cat_%d_d", catID)) == "1" { catOps += "d" } + if r.FormValue(fmt.Sprintf("cat_%d_m", catID)) == "1" { catOps += "m" } + + if catOps != "" { + // Ensure category entry exists + entryID, err := lib.EnsureCategoryEntry(targetID, catID) + if err == nil { + lib.AccessGrantWrite(&lib.Access{ + DossierID: targetID, + GranteeID: granteeID, + EntryID: entryID, + Role: "Custom", + Ops: catOps, + }) + } + } + } + + lib.InvalidateCacheForAccessor(granteeID) + lib.AuditLogFull(p.DossierID, granteeID, targetID, "rbac_update", baseOps, 0) + http.Redirect(w, r, "/dossier/"+targetID+"/rbac/"+granteeID+"?success=1", http.StatusSeeOther) + return + } + } + + // GET: Load current grants and build view + grants, _ := lib.AccessGrantList(&lib.PermissionFilter{DossierID: targetID, GranteeID: granteeID}) + + // Parse grants to determine permissions + hasRead, hasWrite, hasDelete, hasManage := false, false, false, false + catPerms := make(map[int]map[rune]bool) // catID -> op -> bool + + for _, g := range grants { + if g.EntryID == "" { + // Root grant - applies to base permissions + for _, op := range g.Ops { + switch op { + case 'r': hasRead = true + case 'w': hasWrite = true + case 'd': hasDelete = true + case 'm': hasManage = true + } + } + } else { + // Entry-specific grant - find which category + entry, err := lib.EntryGet(nil, g.EntryID) + if err == nil && entry != nil && entry.Type == "category" { + if catPerms[entry.Category] == nil { + catPerms[entry.Category] = make(map[rune]bool) + } + for _, op := range g.Ops { + catPerms[entry.Category][op] = true + } + } + } + } + + // Build category RBAC views + categoryDefs := []struct { + ID int + Name string + Desc string + }{ + {lib.CategoryImaging, "Imaging", "MRI, CT, X-rays, DICOM studies"}, + {lib.CategoryDocument, "Documents", "PDFs, reports, letters"}, + {lib.CategoryLab, "Labs", "Blood tests, lab results"}, + {lib.CategoryGenome, "Genome", "Genetic data, variants"}, + {lib.CategoryVital, "Vitals", "Weight, temperature, blood pressure"}, + {lib.CategoryMedication, "Medications", "Prescriptions and dosages"}, + {lib.CategorySupplement, "Supplements", "Vitamins and supplements"}, + {lib.CategoryExercise, "Exercise", "Workouts and physical activity"}, + {lib.CategorySymptom, "Symptoms", "Health observations and notes"}, + } + + var categoriesRBAC []CategoryRBACView + for _, def := range categoryDefs { + perms := catPerms[def.ID] + categoriesRBAC = append(categoriesRBAC, CategoryRBACView{ + ID: def.ID, + Name: def.Name, + Description: def.Desc, + CanRead: perms['r'], + CanWrite: perms['w'], + CanDelete: perms['d'], + CanManage: perms['m'], + }) + } + + // Build role templates with JSON + systemRoles := lib.GetSystemRoles() + var roles []RoleView + for _, r := range systemRoles { + grantsJSON, _ := json.Marshal(r.Grants) + roles = append(roles, RoleView{ + Name: r.Name, + Description: r.Description, + GrantsJSON: string(grantsJSON), + }) + } + + successMsg := "" + if r.URL.Query().Get("success") == "1" { + successMsg = "Permissions updated" + } + + data := PageData{ + Page: "edit_rbac", + Lang: lang, + Dossier: p, + TargetDossier: target, + GranteeID: granteeID, + GranteeName: grantee.Name, + HasRead: hasRead, + HasWrite: hasWrite, + HasDelete: hasDelete, + HasManage: hasManage, + CategoriesRBAC: categoriesRBAC, + Roles: roles, + Success: successMsg, + } + + if devMode { loadTemplates() } + data.T = translations[lang] + if data.T == nil { data.T = translations["en"] } + data.RequestPath = r.URL.Path + + if err := templates.ExecuteTemplate(w, "base.tmpl", data); err != nil { + fmt.Fprintf(os.Stderr, "Template error: %v\n", err) + http.Error(w, "Template error", 500) + } +} + func handleEditAccess(w http.ResponseWriter, r *http.Request) { p := getLoggedInDossier(r) if p == nil { http.Redirect(w, r, "/", http.StatusSeeOther); return } @@ -1823,6 +2037,7 @@ func setupMux() http.Handler { } else if strings.HasSuffix(path, "/audit") { handleAuditLog(w, r) } else if strings.HasSuffix(path, "/export") { handleExportData(w, r) } else if strings.HasSuffix(path, "/permissions") { handlePermissions(w, r) + } else if strings.Contains(path, "/rbac/") { handleEditRBAC(w, r) } else if strings.Contains(path, "/access/") { handleEditAccess(w, r) } else if strings.HasSuffix(path, "/prompts") { handlePrompts(w, r) } else if strings.HasSuffix(path, "/prompts/respond") { handlePromptRespond(w, r) diff --git a/portal/templates/base.tmpl b/portal/templates/base.tmpl index ccba68f..4010d06 100644 --- a/portal/templates/base.tmpl +++ b/portal/templates/base.tmpl @@ -110,6 +110,7 @@ {{else if eq .Page "prompts"}}{{template "prompts" .}} {{else if eq .Page "permissions"}}{{template "permissions" .}} {{else if eq .Page "edit_access"}}{{template "edit_access" .}} + {{else if eq .Page "edit_rbac"}}{{template "edit_rbac" .}} {{end}} +{{end}} diff --git a/portal/templates/permissions.tmpl b/portal/templates/permissions.tmpl index c67ad06..1594849 100644 --- a/portal/templates/permissions.tmpl +++ b/portal/templates/permissions.tmpl @@ -33,7 +33,7 @@ {{.Ops}}
- Edit + Edit