Decide: any space member can grant space content outward #595

Closed
opened 2026-09-18 02:01:15 +00:00 by Cordy · 2 comments
Owner

Found while writing the #585 docs run, from code — not yet exercised live. Cut as its own issue so the docs don't silently decide a product question.

What the code says

resolveOwned (internal/api/grants.go) gates grant creation on exactly two things: the path must not be delegated (reached through someone else's grant — the re-share refusal), and it must stat in the caller's scope. A member's scope includes /spaces/<name>/..., so a space file passes both checks and POST /api/v1/grants will store a grant against the space's actual backend path — created by any member, not the space owner.

The dialog offers it too: the row menu's Share entry is gated (since #588) on delegation and the read-only flag, neither of which excludes a writable space path.

Why it might be fine

Spaces members already have the content; a grant is arguably no worse than downloading and re-sharing a copy, and it stays revocable and audited (share-grant names the grantor).

Why it might not be

  • The space owner manages membership as the single control over who sees space content; a member granting outward bypasses that control invisibly — the owner's Members dialog will never show the outsider.
  • Read-only members: resolveOwned checks stat, not role, so a read-only member can likely create a grant — possibly even with role write. Whether an entitled write through such a grant is then refused by the grantor's own read-only scope is unverified. If it is not, a read-only member can mint write access they do not have.
  • Encryption: space content is encrypted to the space's group identity; an entitled read resolves through the grantor's scope, so it decrypts. That means group-key material effectively serves non-members, which at minimum belongs in the encryption/sharing docs if kept.

What to do

  1. Verify live: as a read-only member and as a read-write member, share a /spaces/... file with an outside account; check what the recipient can read and write, and what the audit log records.
  2. Decide: allowed for write members only? owner only? not at all? (Nextcloud ties re-sharing inside group folders to explicit permission — the #571 re-share refusal suggests the restrictive instinct here.)
  3. Whatever the decision, resolveOwned is the single place to enforce it, and sharing.md + spaces.md get one sentence each. The #585 docs run deliberately says nothing about space-file grants until this is decided.
Found while writing the #585 docs run, from code — not yet exercised live. Cut as its own issue so the docs don't silently decide a product question. ## What the code says `resolveOwned` (internal/api/grants.go) gates grant creation on exactly two things: the path must not be **delegated** (reached through someone else's grant — the re-share refusal), and it must **stat** in the caller's scope. A member's scope includes `/spaces/<name>/...`, so a space file passes both checks and `POST /api/v1/grants` will store a grant against the space's actual backend path — created by *any member*, not the space owner. The dialog offers it too: the row menu's Share entry is gated (since #588) on delegation and the read-only flag, neither of which excludes a writable space path. ## Why it might be fine Spaces members already have the content; a grant is arguably no worse than downloading and re-sharing a copy, and it stays revocable and audited (`share-grant` names the grantor). ## Why it might not be - The space **owner** manages membership as the single control over who sees space content; a member granting outward bypasses that control invisibly — the owner's Members dialog will never show the outsider. - **Read-only members**: `resolveOwned` checks stat, not role, so a read-only member can likely create a grant — possibly even with role `write`. Whether an entitled write through such a grant is then refused by the grantor's own read-only scope is unverified. If it is not, a read-only member can mint write access they do not have. - Encryption: space content is encrypted to the space's group identity; an entitled read resolves through the grantor's scope, so it decrypts. That means group-key material effectively serves non-members, which at minimum belongs in the encryption/sharing docs if kept. ## What to do 1. Verify live: as a read-only member and as a read-write member, share a `/spaces/...` file with an outside account; check what the recipient can read and write, and what the audit log records. 2. Decide: allowed for write members only? owner only? not at all? (Nextcloud ties re-sharing inside group folders to explicit permission — the #571 re-share refusal suggests the restrictive instinct here.) 3. Whatever the decision, `resolveOwned` is the single place to enforce it, and sharing.md + spaces.md get one sentence each. The #585 docs run deliberately says nothing about space-file grants until this is decided.
Author
Owner

Code recon done — the sharp version of this is confirmed from source, not just suspected

Reading internal/storage/scope/shared.go settles the "unverified" bullets:

  • Mint: resolveOwned checks only not-delegated + stat-in-scope. A read-only space member's scope stats /spaces/<name>/file fine, and grantCreate validates role against read|write with no reference to the caller's own role on the path. So a read-only member can mint a write grant on space content.
  • Use: resolveShared sets writability purely from the grant — readOnly: g.Role != "write" — and then asOwner swaps the context to the minter's identity with no groups before calling inner drivers. The space-role check lives in the scope layer that has already been bypassed by the overlay's own resolution; nothing downstream re-checks it. asOwner's comment even notes the swap is "inert" for spaces — true for encryption (path-keyed), not for authorisation, where inert means absent.

Net: a read-only member of a space can give an outside account write access to space content that the member cannot write themselves. Privilege escalation by way of the grant table. The audit hook does name the real reader and the minter, so it is at least attributable — but attributable escalation is still escalation.

Options

A — Refuse grants on /spaces/** entirely, for now (recommended). One refusal branch in resolveOwned ("space content is shared by space membership — ask the space owner to add them"), one test, one sentence each in sharing.md and spaces.md. Space sharing stays what it already is: membership, managed by the owner, with read/write roles — a working, understood model. Personal files keep grants. This is the smallest rule that closes the escalation, loses nothing that was deliberately designed (space-file grants were never decided — they fell out of stat succeeding), and leaves room to relax later. Relaxing a refusal is painless; retracting a capability someone started using is not — the same reasoning as the re-share refusal.

B — Owner-only: the space owner (not members) may grant space content outward, role capped at the target's… this immediately runs into "which role does the owner's grant carry when membership already exists", plus the owner's Members dialog still not showing grant-recipients. More rules, still a second sharing system bolted onto spaces.

C — Cap at use-time by the minter's live role: keep minting free, compute readOnly = grant says read OR minter's current space role is read-only at resolve time. Dynamic (demote the member, the grant degrades — elegant), but it means a grant's meaning silently changes with someone else's membership edits, the minter keeps being an invisible conduit the owner can't see, and it needs a role lookup inside the hot resolve path. Closes the escalation, keeps the governance hole.

Consequence of A said out loud: someone who today shares a space file with an outsider loses that ability and is pointed at the space owner. On the dogfoods nobody has done this yet (the grant store is one entry old), so there is nothing to migrate — which is exactly why now is the moment to pick the rule.

Live confirmation on the dogfood is possible (mint a grant as a read-only member of Fall 1, watch the outsider write) but given the code path is unambiguous I'd rather spend that effort on the fix's red test, which will encode the same scenario server-side. Your call: A, B, C, or A-with-amendments.

## Code recon done — the sharp version of this is confirmed from source, not just suspected Reading `internal/storage/scope/shared.go` settles the "unverified" bullets: - **Mint:** `resolveOwned` checks only not-delegated + stat-in-scope. A read-only space member's scope stats `/spaces/<name>/file` fine, and `grantCreate` validates role against `read|write` with no reference to the caller's own role on the path. So a **read-only member can mint a `write` grant** on space content. - **Use:** `resolveShared` sets writability purely from the grant — `readOnly: g.Role != "write"` — and then `asOwner` swaps the context to the *minter's* identity **with no groups** before calling inner drivers. The space-role check lives in the scope layer that has already been bypassed by the overlay's own resolution; nothing downstream re-checks it. `asOwner`'s comment even notes the swap is "inert" for spaces — true for encryption (path-keyed), not for authorisation, where inert means *absent*. **Net: a read-only member of a space can give an outside account write access to space content that the member cannot write themselves.** Privilege escalation by way of the grant table. The audit hook does name the real reader and the minter, so it is at least attributable — but attributable escalation is still escalation. ## Options **A — Refuse grants on `/spaces/**` entirely, for now (recommended).** One refusal branch in `resolveOwned` ("space content is shared by space membership — ask the space owner to add them"), one test, one sentence each in sharing.md and spaces.md. Space sharing stays what it already is: membership, managed by the owner, with read/write roles — a working, understood model. Personal files keep grants. This is the smallest rule that closes the escalation, loses nothing that was deliberately designed (space-file grants were never decided — they fell out of `stat` succeeding), and leaves room to relax later. Relaxing a refusal is painless; retracting a capability someone started using is not — the same reasoning as the re-share refusal. **B — Owner-only:** the space *owner* (not members) may grant space content outward, role capped at the target's… this immediately runs into "which role does the owner's grant carry when membership already exists", plus the owner's Members dialog still not showing grant-recipients. More rules, still a second sharing system bolted onto spaces. **C — Cap at use-time by the minter's live role:** keep minting free, compute `readOnly = grant says read OR minter's current space role is read-only` at resolve time. Dynamic (demote the member, the grant degrades — elegant), but it means a grant's meaning silently changes with someone else's membership edits, the minter keeps being an invisible conduit the owner can't see, and it needs a role lookup inside the hot resolve path. Closes the escalation, keeps the governance hole. **Consequence of A said out loud:** someone who today shares a space file with an outsider loses that ability and is pointed at the space owner. On the dogfoods nobody has done this yet (the grant store is one entry old), so there is nothing to migrate — which is exactly why now is the moment to pick the rule. Live confirmation on the dogfood is possible (mint a grant as a read-only member of Fall 1, watch the outsider write) but given the code path is unambiguous I'd rather spend that effort on the fix's red test, which will encode the same scenario server-side. Your call: **A**, **B**, **C**, or A-with-amendments.
Author
Owner

Decided (Option A) and fixed — #600, shipped in v0.6.215, live on both dogfoods.

resolveOwned now refuses any grant whose resolved backend path lands under /spaces/: space content is shared by space membership: ask the space owner to add them. Same chokepoint as the re-share refusal, one branch.

The red test stands as the live confirmation this issue asked for: minting a write grant on a space path as an ordinary member returned 201 with the grant stored before the fix — the escalation was real, recorded in the run log — and returns 403 with an empty store after. With the mint refused, resolveShared's grant-only writability derivation is unreachable for space paths, closing the read-only-member escalation at its single entrance.

Docs shipped in the same PR: sharing.md ("Space content is not grantable") and spaces.md (membership is the grant), both noting public links on space content are unchanged.

Relaxing this later (owner-only grants, or use-time role capping — options B/C on this thread) remains open as a deliberate future decision; nothing to migrate since the grant store never accumulated a space grant outside the test.

**Decided (Option A) and fixed — #600, shipped in v0.6.215, live on both dogfoods.** `resolveOwned` now refuses any grant whose resolved backend path lands under `/spaces/`: *space content is shared by space membership: ask the space owner to add them*. Same chokepoint as the re-share refusal, one branch. The red test stands as the live confirmation this issue asked for: minting a **write** grant on a space path as an ordinary member returned **201 with the grant stored** before the fix — the escalation was real, recorded in the run log — and returns 403 with an empty store after. With the mint refused, `resolveShared`'s grant-only writability derivation is unreachable for space paths, closing the read-only-member escalation at its single entrance. Docs shipped in the same PR: sharing.md ("Space content is not grantable") and spaces.md (membership *is* the grant), both noting public links on space content are unchanged. Relaxing this later (owner-only grants, or use-time role capping — options B/C on this thread) remains open as a deliberate future decision; nothing to migrate since the grant store never accumulated a space grant outside the test.
Cordy closed this issue 2026-09-18 03:17:25 +00:00
Sign in to join this conversation.
No labels
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#595
No description provided.