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 <noreply@anthropic.com>
This commit is contained in:
James 2026-02-07 17:35:42 -05:00
parent b684612797
commit 6980827fa2
4 changed files with 385 additions and 1 deletions

View File

@ -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)

View File

@ -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}}
<script>

View File

@ -0,0 +1,168 @@
{{define "edit_rbac"}}
<div class="sg-container" style="justify-content: center;">
<div style="flex: 1; display: flex; align-items: flex-start; padding-top: 5vh; justify-content: center;">
<div class="data-card" style="padding: 48px; max-width: 800px; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 32px;">
<div>
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 8px;">Edit permissions</h1>
<p style="color: var(--text-muted); font-weight: 300;">{{.GranteeName}}'s access to {{.TargetDossier.Name}}</p>
</div>
<a href="/dossier/{{.TargetDossier.DossierID}}" class="btn btn-secondary btn-small">Back</a>
</div>
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .Success}}<div class="success">{{.Success}}</div>{{end}}
<form action="/dossier/{{.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" method="POST">
<input type="hidden" name="action" value="update">
<!-- Role Selector -->
<div style="margin-bottom: 32px;">
<div class="form-group">
<label style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Role Template</label>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 12px;">Quick presets for common access patterns</p>
<select id="roleSelect" class="sg-select" style="width: 100%;">
<option value="">Custom (manual selection)</option>
{{range .Roles}}
<option value="{{.Name}}" data-grants='{{.GrantsJSON}}'>{{.Name}} — {{.Description}}</option>
{{end}}
</select>
</div>
</div>
<!-- Base Operations -->
<div style="margin-bottom: 32px; border-top: 1px solid var(--border-light); padding-top: 32px;">
<h3 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Base Permissions</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Operations that apply across all data</p>
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px;">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_r" name="op_r" value="1" {{if .HasRead}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Read</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">View all data</span>
</div>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_w" name="op_w" value="1" {{if .HasWrite}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Write</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Add & update data</span>
</div>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_d" name="op_d" value="1" {{if .HasDelete}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Delete</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Remove data</span>
</div>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px; background: var(--bg-muted); border-radius: 8px;">
<input type="checkbox" id="op_m" name="op_m" value="1" {{if .HasManage}}checked{{end}}>
<div style="flex: 1;">
<span style="font-weight: 500;">Manage</span>
<span style="display: block; color: var(--text-muted); font-size: 0.85rem;">Grant access to others</span>
</div>
</label>
</div>
</div>
<!-- Category-Specific Access -->
<div style="margin-bottom: 32px; border-top: 1px solid var(--border-light); padding-top: 32px;">
<h3 style="font-size: 1.1rem; font-weight: 600; margin-bottom: 8px;">Category Permissions</h3>
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 16px;">Fine-grained control per data type</p>
<div style="display: flex; flex-direction: column; gap: 16px;">
{{range .Categories}}
<div style="background: var(--bg-muted); border-radius: 8px; padding: 16px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div>
<span style="font-weight: 600; font-size: 0.95rem;">{{.Name}}</span>
<span style="display: block; color: var(--text-muted); font-size: 0.8rem; margin-top: 2px;">{{.Description}}</span>
</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_r" value="1" class="cat-{{.ID}}-perm" {{if .CanRead}}checked{{end}}>
<span>Read</span>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_w" value="1" class="cat-{{.ID}}-perm" {{if .CanWrite}}checked{{end}}>
<span>Write</span>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_d" value="1" class="cat-{{.ID}}-perm" {{if .CanDelete}}checked{{end}}>
<span>Delete</span>
</label>
<label class="checkbox-label" style="display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.9rem;">
<input type="checkbox" name="cat_{{.ID}}_m" value="1" class="cat-{{.ID}}-perm" {{if .CanManage}}checked{{end}}>
<span>Manage</span>
</label>
</div>
</div>
{{end}}
</div>
</div>
<div style="display: flex; gap: 12px; margin-top: 24px;">
<a href="/dossier/{{.TargetDossier.DossierID}}" class="btn btn-secondary" style="flex: 1; text-align: center;">Cancel</a>
<button type="submit" class="btn btn-primary" style="flex: 1;">Save changes</button>
</div>
</form>
<!-- Revoke all access -->
<div style="border-top: 1px solid var(--border-light); padding-top: 32px; margin-top: 32px;">
<form action="/dossier/{{.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" method="POST" onsubmit="return confirm('Revoke all access for {{.GranteeName}}?');">
<input type="hidden" name="action" value="revoke">
<button type="submit" class="btn btn-danger" style="width: 100%;">Revoke all access</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.getElementById('roleSelect').addEventListener('change', function() {
if (!this.value) return;
const grantsJSON = this.options[this.selectedOptions[0]].dataset.grants;
if (!grantsJSON) return;
const grants = JSON.parse(grantsJSON);
// Clear all checkboxes first
document.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
// Apply grants
grants.forEach(grant => {
const ops = grant.Ops || '';
if (grant.Category === 0) {
// Root level - apply to base ops
if (ops.includes('r')) document.getElementById('op_r').checked = true;
if (ops.includes('w')) document.getElementById('op_w').checked = true;
if (ops.includes('d')) document.getElementById('op_d').checked = true;
if (ops.includes('m')) document.getElementById('op_m').checked = true;
} else {
// Category specific
const catID = grant.Category;
if (ops.includes('r')) {
const cb = document.querySelector(`input[name="cat_${catID}_r"]`);
if (cb) cb.checked = true;
}
if (ops.includes('w')) {
const cb = document.querySelector(`input[name="cat_${catID}_w"]`);
if (cb) cb.checked = true;
}
if (ops.includes('d')) {
const cb = document.querySelector(`input[name="cat_${catID}_d"]`);
if (cb) cb.checked = true;
}
if (ops.includes('m')) {
const cb = document.querySelector(`input[name="cat_${catID}_m"]`);
if (cb) cb.checked = true;
}
}
});
});
</script>
{{end}}

View File

@ -33,7 +33,7 @@
<span style="color: var(--text-muted); font-size: 0.85rem; margin-left: 8px;">{{.Ops}}</span>
</div>
<div style="display: flex; gap: 8px;">
<a href="/dossier/{{$.TargetDossier.DossierID}}/access/{{.GranteeID}}" class="btn btn-secondary btn-small">Edit</a>
<a href="/dossier/{{$.TargetDossier.DossierID}}/rbac/{{.GranteeID}}" class="btn btn-secondary btn-small">Edit</a>
<form action="/dossier/{{$.TargetDossier.DossierID}}/permissions" method="POST" style="display: inline;">
<input type="hidden" name="action" value="revoke">
<input type="hidden" name="grantee_id" value="{{.GranteeID}}">