feat(#570): shares gain an addressee — grant model, store and resolution #573

Merged
Cordy merged 19 commits from fix-570 into main 2026-09-17 02:06:53 +00:00
Owner

Slice 1 of #570, backend only: a share can now be addressed to a principal instead of to a URL.

What lands

  • Share gains AddresseeKind (user/group), Addressee, Role (read/write) and ID. A record is either a link (token-addressed) or a grant (principal-addressed) — IsGrant() is the discriminator.
  • Entitles(username, groups) answers "does THIS record grant a role", case-insensitively, with no cross-kind matching and fail-closed on an unknown role.
  • FileStore persists grants alongside links. Grants have no token, so every write-side map access is re-keyed through Key() (g:<id> for a grant, the token for a link) — including the load path and the Create rollback. Missing one would have collapsed every grant onto "" on reload.
  • ListForPath / ListForAddressee query grants only. Links never surface through them, or #550's Used-by count silently becomes a link count.
  • RoleForPath resolves the strongest role a principal holds on a path, skipping the owner.

Two design notes worth reading

Store is not widened. The first attempt added the two queries to Store and broke recStore in public_recovery_test.go — a link-only double that will never answer a grant query. The queries live on a narrow GrantQuerier instead; the public-link handler keeps needing Store and nothing else.

Exact path match, not prefix. A grant on /home/alice/reports must not reach /home/alice/reports-private. Folder sharing is a separate feature with its own semantics; inheriting by string prefix is a data leak that reads like a typo. Test: TestRoleForPathIsExact.

What this deliberately does not do

  • No addressing UI and no /shared tree — a recipient still cannot reach the path. The scope driver's synthetic root is /home + /spaces, and /home resolves to the requester's own tree. This slice makes the answer computable; the next makes it reachable.
  • No audit verbs yet. share-grant / share-grant-revoke have no emit site until the grant create/revoke API and the entitled read exist. The registry in internal/audit/verbs.go is append-only, so a name shipped ahead of its feature is permanent — they land with the code that emits them.
  • A grant carries authorisation only. It cannot make a recipient able to decrypt: encrypt/group.go derives the key scope from the backend path, so an entitled read goes through the owner's scope (next slice).

Red witnessed on the runner for grant_test.go and grantstore_test.go before either implementation existed.

Slice 1 of #570, backend only: a share can now be addressed to a principal instead of to a URL. **What lands** - `Share` gains `AddresseeKind` (`user`/`group`), `Addressee`, `Role` (`read`/`write`) and `ID`. A record is either a **link** (token-addressed) or a **grant** (principal-addressed) — `IsGrant()` is the discriminator. - `Entitles(username, groups)` answers "does THIS record grant a role", case-insensitively, with no cross-kind matching and fail-closed on an unknown role. - `FileStore` persists grants alongside links. Grants have no token, so every write-side map access is re-keyed through `Key()` (`g:<id>` for a grant, the token for a link) — including the load path and the `Create` rollback. Missing one would have collapsed every grant onto `""` on reload. - `ListForPath` / `ListForAddressee` query grants only. Links never surface through them, or #550's Used-by count silently becomes a link count. - `RoleForPath` resolves the strongest role a principal holds on a path, skipping the owner. **Two design notes worth reading** *`Store` is not widened.* The first attempt added the two queries to `Store` and broke `recStore` in `public_recovery_test.go` — a link-only double that will never answer a grant query. The queries live on a narrow `GrantQuerier` instead; the public-link handler keeps needing `Store` and nothing else. *Exact path match, not prefix.* A grant on `/home/alice/reports` must not reach `/home/alice/reports-private`. Folder sharing is a separate feature with its own semantics; inheriting by string prefix is a data leak that reads like a typo. Test: `TestRoleForPathIsExact`. **What this deliberately does not do** - No addressing UI and no `/shared` tree — a recipient still cannot reach the path. The scope driver's synthetic root is `/home` + `/spaces`, and `/home` resolves to the requester's own tree. This slice makes the answer computable; the next makes it reachable. - **No audit verbs yet.** `share-grant` / `share-grant-revoke` have no emit site until the grant create/revoke API and the entitled read exist. The registry in `internal/audit/verbs.go` is append-only, so a name shipped ahead of its feature is permanent — they land with the code that emits them. - A grant carries authorisation only. It cannot make a recipient able to decrypt: `encrypt/group.go` derives the key scope from the backend path, so an entitled read goes through the owner's scope (next slice). Red witnessed on the runner for `grant_test.go` and `grantstore_test.go` before either implementation existed.
Cordy merged commit 985ec45b3b into main 2026-09-17 02:06:53 +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#573
No description provided.