feat(#571): the grant API, its own policy switch, and /shared goes live #575

Merged
Cordy merged 9 commits from fix-571 into main 2026-09-17 03:19:04 +00:00
Owner

Backend half of #571. /shared stops being inert: grants can now be created, listed and revoked, and the overlay is wired to the share store.

The endpoints

POST   /api/v1/grants           {"path","addresseeKind","addressee","role"}
GET    /api/v1/grants?path=...  the grants on one path
DELETE /api/v1/grants/{id}      revoke (own grants only)

Registered alongside the link routes, because a grant is a share record with the other half filled in. Paths are stored resolved to the backend path, exactly like links, since that is what RoleForPath and the overlay look up.

Recipients cannot re-share — enforced, not intended

This is the one that would have been a real hole. The overlay resolves /shared/alice/q3.pdf to Alice's real backend path, which is correct for reading and is exactly what would let Bob POST a grant on Alice's file and become a second grantor of it. scope.Driver.Delegated reports whether a path was reached through someone else's grant, and resolveOwned refuses before anything is written. TestGrantCreateRefusesToReShare asserts both the 403 and that the store stayed empty.

Two switches, because two risks

ShareLinksDisabled says public share links are turned off on this server — a statement about the public internet. It does not govern grants, and TestPublicLinkPolicyDoesNotGovernGrants pins that: with links disabled, password required and uploads forbidden, sharing a file with a colleague still works. Internal sharing gets ShareInternalDisabled, stored negative like its neighbour so an instance upgrading into the field keeps sharing on.

A link is not a grant

GrantKey gives grants their own key space (g:<id>), so a link's token cannot be revoked through the grant route and a link never appears in a grant listing. Both fenced.

Wiring

share.GrantsFor fans in everything addressed to a principal — named directly, or through any of their groups — and deduplicates by id, because being both is the ordinary case for anyone on a team and the overlay would otherwise show the same file twice under the same owner. That logic lives in internal/share where it is tested; main.go keeps only the mapping into scope.Grant, via a new scope.GrantsFunc so the composition root declares no type of its own.

The audit callback uses literal verbs in a switch rather than passing the verb through. That keeps the #433 registry scanner able to see them, and it means an unregistered verb cannot be emitted from main.go at all. share-grant and share-grant-revoke are registered now that they have call sites.

One deliberate omission: the audit event carries no owner field, because vpath is /shared/<owner>/... — the record already names the owner.

Not in this PR

The panel itself, and the non-admin recipient picker. GET /api/v1/admin/groups/candidates is requireAdmin, so it cannot back a picker every user needs; that becomes a search-only /api/v1/recipients (min 2 characters, capped, no blank-query enumeration) in the next slice, together with the admin toggle for the new switch.

Red witnessed on the runner for every test here before any of it existed.

Backend half of #571. `/shared` stops being inert: grants can now be created, listed and revoked, and the overlay is wired to the share store. **The endpoints** ``` POST /api/v1/grants {"path","addresseeKind","addressee","role"} GET /api/v1/grants?path=... the grants on one path DELETE /api/v1/grants/{id} revoke (own grants only) ``` Registered alongside the link routes, because a grant *is* a share record with the other half filled in. Paths are stored resolved to the backend path, exactly like links, since that is what `RoleForPath` and the overlay look up. **Recipients cannot re-share — enforced, not intended** This is the one that would have been a real hole. The overlay resolves `/shared/alice/q3.pdf` to Alice's *real* backend path, which is correct for reading and is exactly what would let Bob `POST` a grant on Alice's file and become a second grantor of it. `scope.Driver.Delegated` reports whether a path was reached through someone else's grant, and `resolveOwned` refuses before anything is written. `TestGrantCreateRefusesToReShare` asserts both the 403 and that the store stayed empty. **Two switches, because two risks** `ShareLinksDisabled` says *public share links are turned off on this server* — a statement about the public internet. It does not govern grants, and `TestPublicLinkPolicyDoesNotGovernGrants` pins that: with links disabled, password required and uploads forbidden, sharing a file with a colleague still works. Internal sharing gets `ShareInternalDisabled`, stored negative like its neighbour so an instance upgrading into the field keeps sharing **on**. **A link is not a grant** `GrantKey` gives grants their own key space (`g:<id>`), so a link's token cannot be revoked through the grant route and a link never appears in a grant listing. Both fenced. **Wiring** `share.GrantsFor` fans in everything addressed to a principal — named directly, or through any of their groups — and deduplicates by id, because being both is the ordinary case for anyone on a team and the overlay would otherwise show the same file twice under the same owner. That logic lives in `internal/share` where it is tested; `main.go` keeps only the mapping into `scope.Grant`, via a new `scope.GrantsFunc` so the composition root declares no type of its own. The audit callback uses **literal** verbs in a switch rather than passing the verb through. That keeps the #433 registry scanner able to see them, and it means an unregistered verb cannot be emitted from `main.go` at all. `share-grant` and `share-grant-revoke` are registered now that they have call sites. One deliberate omission: the audit event carries no owner field, because `vpath` is `/shared/<owner>/...` — the record already names the owner. **Not in this PR** The panel itself, and the non-admin recipient picker. `GET /api/v1/admin/groups/candidates` is `requireAdmin`, so it cannot back a picker every user needs; that becomes a search-only `/api/v1/recipients` (min 2 characters, capped, no blank-query enumeration) in the next slice, together with the admin toggle for the new switch. Red witnessed on the runner for every test here before any of it existed.
Cordy merged commit 1b48417445 into main 2026-09-17 03:19:04 +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#575
No description provided.