inou/docs/study-access-in-entries.md

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 access
  • ops — 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:

  • CheckAccess in dbcore.go — walks hierarchy, checks Access fields. Uses dbLoad internally (no RBAC recursion — access checks are about RBAC, not subject to it).
  • Granting access = EntryRead the target entry, modify its Access field, EntryWrite it 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 = EntryRead the entry, parse its Access field.
  • Listing accessible dossiers = entryReadAccessible scans 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
  • dbLoad hits 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

  1. For each row in the current access table, read DossierID and EntryID
  2. Load that entry, parse its Access field (or initialize empty array)
  3. Append the grant, write back
  4. 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