inou/docs/rbac-design-2026-01.md

318 lines
7.4 KiB
Markdown

# 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