8.1 KiB
Study: Access Rights as a Field on Entries
Design
Access permissions are stored as a packed BLOB field (Access) on the entry itself — like filesystem permissions on an inode. No separate access table.
Every entry already has DossierID (whose data) and ParentID (hierarchy). Adding an Access field means each entry can declare: "these grantees have these permissions here and on all children."
The Access Field
Access BLOB — packed JSON, same as other string fields
Format when unpacked:
[
{"grantee": "6e4e8192881a7494", "ops": 1, "relation": 3},
{"grantee": "a1b2c3d4e5f60718", "ops": 3}
]
grantee— int64 ID (hex16 representation) of who gets accessops— bitmask (1=read, 2=write, 4=delete, 8=manage)relation— relationship type (optional, for display: parent, doctor, etc.)
Most entries have an empty Access field. Only entries where access is explicitly granted have it populated.
How CheckAccess Works
Walk up the hierarchy from the requested entry, checking the Access field at each level:
CheckAccess(accessorID, dossierID, entryID, perm):
1. accessor == "" or system → true (system access)
2. accessor == dossierID → true (self-access)
3. Load entry (entryID)
- Check entry.Access for matching grantee + perm → granted
4. If entry.ParentID != "" and entry.ParentID != entry.EntryID:
- Load parent, check parent.Access → granted
- Continue walking up via ParentID
5. Load dossier root (dossierID, cat 0)
- Check root.Access → granted
6. No match at any level → denied
Grants cascade downward. A grant on the dossier root gives access to everything. A grant on an imaging study gives access to that study and all its series/slices. This is why ParentID must form a clean tree to the dossier root.
Hierarchy Requirement
For the walk to work, every root-level entry (study, lab batch, genome extraction, etc.) must have ParentID = dossierID. This connects the permission tree:
Dossier entry (cat 0, Access: [{grantee: "johan", ops: 1}])
├── Study (cat 1, ParentID = dossierID)
│ ├── Series (ParentID = studyID)
│ │ └── Slice (ParentID = seriesID)
│ └── Series
├── Lab batch (cat 3, ParentID = dossierID)
│ └── Lab result (ParentID = batchID)
└── Genome extraction (cat 4, ParentID = dossierID)
└── Tier (ParentID = extractionID)
└── Variant (ParentID = tierID)
A grant on the dossier entry → access to everything. A grant on the study → access to that study's series and slices only.
entryReadAccessible (Dashboard: "Show me all dossiers I can access")
This is the one query that currently needs the access table: "find all dossiers where I have a grant." Without a separate table, this becomes a cross-dossier query.
Options:
Option A: Scan all cat-0 entries for matching Access field
SELECT ... FROM entries WHERE Category = 0
Then in Go, unpack each entry's Access field and check for a matching grantee. This is O(all dossiers) — works at inou's current scale (hundreds, not millions), but doesn't scale.
Option B: Index table (materialized view)
Maintain a lightweight lookup: access_index(GranteeID, DossierID) — plain text, no packing. Updated whenever an Access field is written. CheckAccess still walks entries (source of truth). The index only serves the "list my accessible dossiers" query.
This is similar to the current access table but reduced to a two-column index. Not the source of truth — just a lookup accelerator.
Option C: SearchKey on dossier entries encodes grantees
Store grantee IDs in a queryable field. But SearchKey is already used for email on cat-0 entries.
Option D: Separate query with LIKE on packed Access field
Not feasible — Access is packed (compressed + encrypted), not queryable with SQL.
Recommendation: Option A for now, Option B if scale demands it. At current scale (< 1000 dossiers), scanning cat-0 entries and checking Access fields in Go is fast enough. If it becomes a problem, add a two-column index table — but the source of truth remains the Access field on entries.
What Happens to the Access Table
Eliminated. DROP TABLE access. The schema becomes:
entries — everything, including permissions (via Access field)
audit — immutable log (stays separate — different concern, append-only)
Two tables instead of three.
What Happens to Access Functions
All the current stubs (AccessGet, AccessList, AccessWrite, GrantAccess, RevokeAccess, RevokeAllAccess, ListGrants, ListGrantees, AccessListByAccessor, AccessListByTargetWithNames, CanManageDossier) — all deleted.
Replaced by:
CheckAccessindbcore.go— walks hierarchy, checks Access fields. UsesdbLoadinternally (no RBAC recursion — access checks are about RBAC, not subject to it).- Granting access =
EntryReadthe target entry, modify its Access field,EntryWriteit back. The caller must have PermManage. No special function needed — it's a field update. - Revoking access = same pattern, remove grantee from Access field.
- Listing grantees =
EntryReadthe entry, parse its Access field. - Listing accessible dossiers =
entryReadAccessiblescans cat-0 entries (as today, but checks Access field instead of access table).
The Circular Dependency
There is none. CheckAccess uses dbLoad to read entries and inspect their Access field. It never calls EntryRead. EntryRead calls CheckAccess, which reads entries directly. This is the same pattern as today (CheckAccess queries the access table directly via dbQuery). The only difference: instead of a separate table, it reads from the same table using a different code path.
Access checks are about RBAC, not subject to RBAC. You don't need permission to check if you have permission.
Performance
Current (access table): One dbQuery per CheckAccess call — SELECT ... FROM access WHERE GranteeID = ? AND DossierID = ?.
Proposed (Access field): One dbLoad per level in the hierarchy. Worst case: 4 loads (entry → parent → grandparent → dossier root). Best case: 1 load (grant is on the entry itself, or self-access shortcut).
The loads involve Unpack (decompress + decrypt) of the Access field. But:
- Most entries have empty Access fields — Unpack of empty is near-zero
- Grants are typically at the dossier root level — the walk usually reaches it in 1-3 hops
dbLoadhits SQLite by primary key — fast
For the common case (dossier-level grant), CheckAccess loads the dossier root entry, unpacks its Access field, finds the match. One primary key lookup + one Unpack. Comparable to the current single query on the access table.
Schema Change
ALTER TABLE entries ADD COLUMN Access BLOB;
On both staging and production. The Entry struct gains:
Access string `db:"Access"` // packed JSON: [{grantee, ops, relation}]
Migration
- For each row in the current
accesstable, readDossierIDandEntryID - Load that entry, parse its Access field (or initialize empty array)
- Append the grant, write back
- After migration, drop the access table
IDs as int64
Per the agreed design, IDs are stored as int64 internally. The Access field stores grantee IDs as int64 (represented as hex16 in JSON for readability, parsed via ParseID). This aligns with the planned migration from string IDs to int64.
Summary
| Aspect | Current (access table) | Proposed (Access field) |
|---|---|---|
| Tables | 3 (entries, access, audit) | 2 (entries, audit) |
| Source of truth | Two places | One place (entries) |
| Grant/revoke | Special functions | Field update via EntryWrite |
| CheckAccess | Query access table | Walk hierarchy, check Access field |
| "My dossiers" | Query access table | Scan cat-0 entries |
| Functions needed | 11 stubs | 0 new (CheckAccess + EntryRead/Write) |
| Hierarchy | Implicit (EntryID = scope) | Explicit (ParentID tree) |
| Scoped access | EntryID field on grant | Grant on any node in tree |