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:
parent
b684612797
commit
6980827fa2
215
portal/main.go
215
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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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}}">
|
||||
|
|
|
|||
Loading…
Reference in New Issue