# RBAC & Family Tree Redesign **Date:** January 2026 **Status:** Design phase --- ## Overview Redesign of the permission and family relationship model to: 1. Properly model genetic relationships via family tree 2. Implement granular, entry-level RBAC 3. Support custom roles with canned presets 4. Enforce all access control at the store layer --- ## 1. Family Tree (Genetics) ### Schema Change Add to `dossiers` table: ```sql ALTER TABLE dossiers ADD COLUMN mother_id TEXT REFERENCES dossiers(dossier_id); ALTER TABLE dossiers ADD COLUMN father_id TEXT REFERENCES dossiers(dossier_id); ``` ### Derived Relationships All relationships computed via graph traversal: | Relationship | Derivation | |--------------|------------| | Children | `WHERE mother_id = X OR father_id = X` | | Siblings | Share at least one parent | | Full sibling | Share both parents | | Half-sibling (maternal) | Share mother only | | Half-sibling (paternal) | Share father only | | Grandparents | Parent's mother_id / father_id | | Cousins, aunts, etc. | Graph traversal | ### UI Each dossier shows: ``` Mother: [Name] [view] Father: [_____] [+ add] [link: ____] ``` - **+ add**: Create new dossier, link as parent - **link**: Enter existing dossier ID to link --- ## 2. RBAC Model ### Four Operations | Op | Code | Description | |----|------|-------------| | Read | r | View entry and children | | Write | w | Add/edit entries | | Delete | d | Remove entries | | Manage | m | Share access, grant permissions | ### Access Levels | Level | R | W | D | M | |-------|---|---|---|---| | Owner (self) | always | always | always | always | | Can-manage flag | always | always | always | always | | Entry grant | per grant | per grant | no | no | --- ## 3. Schema: `access` Table Single flexible table for all access control: ```sql CREATE TABLE access ( id TEXT PRIMARY KEY, dossier_id TEXT NOT NULL REFERENCES dossiers(dossier_id), grantee_id TEXT REFERENCES dossiers(dossier_id), -- null = role template entry_id TEXT REFERENCES entries(entry_id), -- null = root level role TEXT NOT NULL, -- "Trainer", "Family", custom ops TEXT NOT NULL, -- "r", "rw", "rwdm" created_at INTEGER NOT NULL ); CREATE INDEX idx_access_grantee ON access(grantee_id); CREATE INDEX idx_access_dossier ON access(dossier_id); CREATE INDEX idx_access_entry ON access(entry_id); ``` ### How It Works | grantee_id | entry_id | role | ops | Meaning | |------------|----------|------|-----|---------| | null | null | "Trainer" | r | Template: Trainer default | | null | {exercise_root} | "Trainer" | rw | Template: Trainer exercise access | | {john} | null | "Trainer" | r | Grant: John as Trainer | | {john} | {exercise_root} | "Trainer" | rw | Grant: John exercise write | | {mary} | null | "Family" | rwdm | Grant: Mary full access | ### Role Templates - `grantee_id = null` → template definition - Templates are per-dossier (user creates their own) - System templates: `dossier_id = null` (Doctor, Coach, etc.) ### Granting Access 1. User selects a role (template) or creates custom 2. System copies template rows with grantee_id set 3. User can customize entry-level overrides --- ## 4. Permission Resolution ### Algorithm ``` canAccess(accessor, dossier, entry, op): 1. if ctx.IsSystem → allow 2. if accessor == dossier owner → allow 3. lookup grants for (accessor, dossier) 4. if grant exists for entry with op → allow 5. walk up entry.parent_id, repeat step 4 6. if hit root (entry=null) and still no grant → deny ``` ### Inheritance Access to a parent entry implies access to all children. Example: Grant read on "Exercise" category → can read all exercise entries. --- ## 5. Caching Strategy (Option C) ### Approach Load grants lazily per accessor-dossier pair, cache in memory. ### Implementation ```go type permCache struct { // [dossier_id][entry_id] → "rwdm" grants map[string]map[string]string loadedAt time.Time } // Per accessor var accessorCache = map[string]*permCache{} var cacheTTL = 5 * time.Minute ``` ### Load ```go func loadPerms(accessorID string) *permCache { // SELECT dossier_id, entry_id, ops // FROM access // WHERE grantee_id = ? // Build map, return } ``` ### Check ```go func canAccess(ctx, dossierID, entryID, op string) bool { if ctx.IsSystem { return true } if ctx.AccessorID == dossierOwner { return true } perms := getPerms(ctx.AccessorID, dossierID) return walkTreeCheckPerms(perms, entryID, op) } ``` ### Invalidation ```go func invalidateCache(accessorID string) { delete(accessorCache, accessorID) } // Called on any access table write ``` --- ## 6. Store Layer Enforcement ### Principle All RBAC checks happen in `store.go`. Impossible to bypass. ### Context ```go type AccessContext struct { AccessorID string // who's asking IsSystem bool // bypass RBAC } ``` ### Wrapped Functions | Current | New | |---------|-----| | `EntryGet(id)` | `EntryGet(ctx, id)` | | `EntryList(filter)` | `EntryList(ctx, filter)` | | `EntrySave(e)` | `EntrySave(ctx, e)` | | `EntryDelete(id)` | `EntryDelete(ctx, id)` | | `DossierGet(id)` | `DossierGet(ctx, id)` | ### Prerequisite Consolidate all SQL into `store.go`. Currently scattered across: - `portal/main.go` - `api/api_*.go` (14 files) - `lib/data.go`, `lib/v2.go`, `lib/prompt.go` - `portal/prompts.go`, `portal/genome.go` --- ## 7. Canned Role Presets ### System Roles (dossier_id = null) | Role | Default Ops | Entry Scope | |------|-------------|-------------| | Family | rwdm | root | | Doctor | rw | root | | Caregiver | rw | root | | Trainer | r | root | | Trainer | rw | exercise category | | Trainer | rw | nutrition category | | Friend | r | root | ### Custom Roles Users can create their own role templates with custom entry-level permissions. --- ## 8. Migration from Current Model ### What Changes | Current | New | |---------|-----| | `dossier_access.relation` (family types) | `dossiers.mother_id/father_id` | | `dossier_access.relation` (roles) | `access.role` | | `dossier_access.can_edit` | `access.ops` includes 'w' | | `dossier_access.is_care_receiver` | `access.ops` includes 'm' | ### What Gets Removed - Family relation types in `dossier_access` (Parent, Child, Sibling, etc.) - `lineage` field (never fully implemented) ### Migration Steps 1. Add `mother_id`, `father_id` to dossiers 2. Create `access` table 3. Migrate existing `dossier_access` → `access`: - `can_edit=true` → ops includes 'w' - `is_care_receiver=true` → ops='rwdm' - role from relation label 4. Consolidate SQL into store.go 5. Add ctx parameter throughout 6. Deprecate `dossier_access` table --- ## 9. API Impact ### MCP Bridge Needs to pass accessor context from authentication. ### REST API Needs to pass accessor context from token. ### Portal Needs to create context from session. --- ## 10. Open Questions 1. How to handle "link existing dossier" for family tree? Merge conflicts? 2. Performance of tree traversal for permission checks - need benchmarks 3. UI for custom role creation - how complex? 4. Backward compatibility period - dual-write? --- ## 11. Implementation Order 1. [ ] Design review & sign-off 2. [ ] Consolidate SQL into store.go 3. [ ] Add `access` table 4. [ ] Implement permission cache 5. [ ] Add `mother_id/father_id` to dossiers 6. [ ] Migrate existing permissions 7. [ ] Update portal UI 8. [ ] Update API 9. [ ] Update MCP bridge 10. [ ] Deprecate old model