feat(#570): the /shared overlay — reaching a granted path as its owner #574

Merged
Cordy merged 6 commits from fix-570b into main 2026-09-17 02:46:31 +00:00
Owner

Slice 2 of #570. Slice 1 made the answer computable; this makes it reachable.

The overlay

/shared becomes a third synthetic root beside /home and /spaces, laid out /shared/<owner>/<name>. The owner folder is not decoration — two people can each share a report.pdf, and a flat tree would have to mangle one of the names.

It is shown only when the user actually has a grant. A folder that sits empty forever for everyone who was never shared anything reads as broken, not as a feature. TestSyntheticRootList still asserts exactly two entries and still passes.

Reaching the file

encrypt/group.go derives the key scope from the backend path, so a personal file is encrypted to its owner. An entitled read therefore descends with the owner's identity (auth.ContextWithUser, the pattern peering/deliver.go and wopi.go already use) or it decrypts to nothing. For a /spaces path the space's own identity already covers every member, so the swap is inert there.

Write-role grants are writable on the same footing: the write re-encrypts into the owner's scope, which is the only scope the file can live in.

The attribution problem, and why the audit hook is where it is

Everything below the scope decorator sees the owner. Emitting the audit record down there would produce "alice read her own file" for every entitled access, making a share indistinguishable from ordinary activity — the opposite of what the record is for. So the hook fires in the scope driver, above the swap, with the real reader and the owner passed separately. TestSharedAuditNamesTheReaderNotTheOwner is the fence, and it also asserts the inner driver saw the owner, so neither half can drift.

share-grant-read and share-grant-write are registered in internal/audit/verbs.go now that they have an emit site — the deferral recorded on #570 when slice 1 landed.

Boundaries held

  • Exact segments, never string prefixes. A grant on reports does not reach reports-private; TestSharedSiblingPrefixIsNotReachable fences it. Descending into a granted directory matches segment by segment.
  • ensureReal skips granted paths. For a file grant the "prefix" is the file itself, and the lazy Mkdir would have put a directory over it.
  • isRoot() replaces the four open-coded root guards. A granted path is never a root — its prefix is exactly the thing the grant exists to permit — and the old guard would otherwise have refused every write to a granted file.
  • Rename and copy across the grant boundary are refused, not guessed: they would need one identity for the read and another for the write.

Still not wired

WithGrants is not called in cmd/cairnd/main.go, so /shared is inert in a running instance. Nothing can create a grant yet — that is #571's panel and its API. Wiring it before then would ship a root that can only ever be empty.

Red witnessed on the runner: fourteen tests, all failing on the missing overlay, before any of it existed.

Slice 2 of #570. Slice 1 made the answer computable; this makes it reachable. **The overlay** `/shared` becomes a third synthetic root beside `/home` and `/spaces`, laid out `/shared/<owner>/<name>`. The owner folder is not decoration — two people can each share a `report.pdf`, and a flat tree would have to mangle one of the names. It is shown **only when the user actually has a grant**. A folder that sits empty forever for everyone who was never shared anything reads as broken, not as a feature. `TestSyntheticRootList` still asserts exactly two entries and still passes. **Reaching the file** `encrypt/group.go` derives the key scope from the backend path, so a personal file is encrypted to its owner. An entitled read therefore descends with the **owner's** identity (`auth.ContextWithUser`, the pattern `peering/deliver.go` and `wopi.go` already use) or it decrypts to nothing. For a `/spaces` path the space's own identity already covers every member, so the swap is inert there. Write-role grants are writable on the same footing: the write re-encrypts into the owner's scope, which is the only scope the file can live in. **The attribution problem, and why the audit hook is where it is** Everything below the scope decorator sees the owner. Emitting the audit record down there would produce "alice read her own file" for every entitled access, making a share indistinguishable from ordinary activity — the opposite of what the record is for. So the hook fires **in the scope driver, above the swap**, with the real reader and the owner passed separately. `TestSharedAuditNamesTheReaderNotTheOwner` is the fence, and it also asserts the inner driver saw the owner, so neither half can drift. `share-grant-read` and `share-grant-write` are registered in `internal/audit/verbs.go` now that they have an emit site — the deferral recorded on #570 when slice 1 landed. **Boundaries held** - **Exact segments, never string prefixes.** A grant on `reports` does not reach `reports-private`; `TestSharedSiblingPrefixIsNotReachable` fences it. Descending into a granted directory matches segment by segment. - **`ensureReal` skips granted paths.** For a file grant the "prefix" is the file itself, and the lazy Mkdir would have put a directory over it. - **`isRoot()` replaces the four open-coded root guards.** A granted path is never a root — its prefix is exactly the thing the grant exists to permit — and the old guard would otherwise have refused every write to a granted file. - **Rename and copy across the grant boundary are refused**, not guessed: they would need one identity for the read and another for the write. **Still not wired** `WithGrants` is not called in `cmd/cairnd/main.go`, so `/shared` is inert in a running instance. Nothing can create a grant yet — that is #571's panel and its API. Wiring it before then would ship a root that can only ever be empty. Red witnessed on the runner: fourteen tests, all failing on the missing overlay, before any of it existed.
Cordy merged commit a23ad6b144 into main 2026-09-17 02:46:31 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#574
No description provided.