From 75507617af674e22691a153e21fbfb98bdd7ae50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:14:51 +0000 Subject: [PATCH 1/6] fix(core): resharing needs read, like every other mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A creator removed from the roster that conveyed their read could still editAccess() their Record and grant themselves back in: the reshare gate asked owner-or-creator but never whether the requester reaches the Record. canReshare() now asks read too, so "mutation is never blind" covers the authority aspect as it already covered the data aspect. Spec: access-control.md states the read requirement on resharing; identity.md § Group gains "Governing a group stack" — owner authority, unrecorded owner reads, and what removal takes away. Closes #419 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY --- docs/spec/access-control.md | 16 ++-- docs/spec/identity.md | 10 +++ packages/core/src/scoped-stack/authority.ts | 9 ++- .../core/src/scoped-stack/scoped-stack.ts | 9 ++- packages/core/tests/scoped-stack.test.ts | 77 ++++++++++++++++++- 5 files changed, 106 insertions(+), 15 deletions(-) diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 2070c690..2ab4105d 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -92,7 +92,7 @@ Record-level `write` stays a single coarse bit — no per-verb fencing (`update` Operations that sit outside the `write` bit entirely, regardless of grants: - **Purge** is owner-only (see [Deletion](./versioning.md#deletion)) — it destroys the Record and its version history, so there's nothing left to undo. An irreversible verb has no place in a bit whose entire safety argument is recoverability. -- **Resharing — `editAccess()`, `setAccess()` and `setUnlisted()`** — is owner-or-creator-only. A write-holder who is neither cannot change who else can access the Record — otherwise a `write` element would let its holder escalate to granting others access, defeating the point of scoping access in the first place. Same pattern as purge: a privilege-bearing operation stays outside the coarse bit. (`restoreVersion()` reinforces this from the other direction — it restores `content` and `typeId` and nothing else, so a content rollback can't silently change who has access, or a Record's roster, tags or listing state, either.) +- **Resharing — `editAccess()`, `setAccess()` and `setUnlisted()`** — is owner-or-creator-only, and like every mutation it [needs read](#write-implies-read). A write-holder who is neither cannot change who else can access the Record — otherwise a `write` element would let its holder escalate to granting others access, defeating the point of scoping access in the first place. Same pattern as purge: a privilege-bearing operation stays outside the coarse bit. (`restoreVersion()` reinforces this from the other direction — it restores `content` and `typeId` and nothing else, so a content rollback can't silently change who has access, or a Record's roster, tags or listing state, either.) - **`_group` Records** opt out of the write bit (and type-level grants) entirely, for every mutating verb including `editAccess()` and `setAccess()`. A Group's own `permissions`/grants govern who can _read_ it, not who can _manage_ it. See [Group](./identity.md#group) for the admin-or-owner rule that replaces it. This is about the **served topology**: for `adapter-local`, direct adapter access is full trust and the permission model doesn't apply — `Stack` is unscoped by design. Record-level permissions exist for the requester on the far side of a server, who has no direct database access. For them, the `write` bit is the only fence, so what it permits has to hold up as a real policy surface — which is exactly why everything it reaches has to be undoable by the owner. @@ -127,6 +127,8 @@ The invariant rests on history being gated on the mutate surface: it is [`getVer What this buys is an invariant the mutate gate already assumed: **anything that passes it can be read by the same requester.** `getVersions()`/`getVersion()` gate on the mutate surface precisely so that history isn't as public as the present, and this closes the reverse — the past never reaches a requester denied the present. The one deliberate exception is `_group`, which opts out of the write bit entirely: Group management is owner-or-admin (see [Group](./identity.md#group)), and an admin may mutate a Group — and read its history — without a read grant on `_group`. Roster standing, not a withheld read bit, is what carries it. +**Resharing reads the same way.** It mutates the Record's authority aspect, so the owner-or-creator rule is asked only of a requester who can read the Record. Authorship confers no read: a creator reaches their own Record through its permissions or a grant like anyone else, and once that lapses — a roster that conveyed it drops them, a grant is revoked — so does their standing to reshare. Otherwise a creator shut out of a Record could grant their way back in. The owner always reads, and `_group` management is roster standing, as above. + **Blind write is offered, and it is `create`.** Contribute-without-reading — a tip box, a contact form, comments held for moderation, an app filing telemetry — is a `create` grant with no read action, and that combination is deliberately left alone: writing a Record you cannot read back discloses nothing. The contributor can neither re-read their own submission nor enumerate the type; `query()` returns nothing to them and the owner sees everything in the box. `putAttachment()` follows the same gate, so a drop box can take files. Three edges are worth knowing before building one: - **Anonymous requesters are always refused**, default grant or not, so a contributor needs a DID — cheap to mint client-side, and it becomes the Record's author. An open box (a default `create` grant) is an unauthenticated-adjacent surface in every way that matters for abuse; rate limiting is the server's business, not the permission model's. @@ -405,12 +407,12 @@ A missing target and an existing-but-inaccessible one **always produce the same Each [write verb](./data-model.md#mutations) moves one aspect, and passes exactly one gate. Over a Record in no system family: -| Verb | What the requester must hold | -| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | -| `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | -| `setParent` | the same, plus read access to the destination | -| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator, on both sides of a delegation — and never a delegated principal, as at [create time](#delegation-principal-and-subject) | +| Verb | What the requester must hold | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | +| `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | +| `setParent` | the same, plus read access to the destination | +| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator who can [read](#write-implies-read) it, on both sides of a delegation — and never a delegated principal, as at [create time](#delegation-principal-and-subject) | The family fences apply to every verb alike, whatever the table says: a `_group` Record is writable only by an admin or the owner — its roster is its associations, so on a group the association verbs carry the admin gate — a `_grant` or `_install` Record only by the owner acting alone, and an `_app` card's `did`/`appId`, like an `_entity` card naming the owner's own DID, only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). A content fence is asked of the content a write would leave, by whichever verb writes it — `create()`, `patchContent()`, `restoreVersion()` and `commitMigration()` alike — and compared value-wise, so re-sending a value the Record already holds claims nothing. diff --git a/docs/spec/identity.md b/docs/spec/identity.md index ebf088eb..b4dafd6a 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -195,6 +195,16 @@ What this does **not** promise: that an `admin` is _reachable_. An admin who los **Open question, deliberately not resolved here:** the roles above describe who may manage the Group _Record_. A Group that holds its own key will eventually need to answer "who may act _as_ the Group" — accept grants on its behalf, speak for it to other stacks. That's a distinct question from record management and isn't foreclosed by this definition. +### Governing a group stack + +A collaborative group's stack is a stack like any other, so it has an owner, and the roster sits beneath it. Three defaults follow from rules stated elsewhere; they are gathered here because anyone deciding whether to join a group stack needs them. + +**The owner is whoever holds the group's key, and the owner's access is unconditional.** A group stack's `_config.entityId` is the group's DID, so the key's holders are its owner. No Record in the stack is withheld from them, and they manage any Group whatever its roster says — see [Access control § Enforcement](./access-control.md#enforcement-stackasentity). The roster's `admin` manages the roster; it does not bound the owner. + +**The owner's reads are not recorded anywhere.** The [journal](./journal.md#reading-it) records writes only. A member trusts the key's holders with everything the stack holds. + +**Removal cuts a member's reach to what they wrote.** Authorship confers no read: a member reaches Records, their own included, through record-level permissions or type grants, and a grant naming the group stops reaching them once they are off the roster. `createdBy` still names them, but a creator's standing to reshare lapses with their read — [resharing needs read](./access-control.md#write-implies-read) like every other mutation — so they cannot grant their way back in. A member who wants their contributions copies them out while still a member; a group that wants a gentler exit grants the person individual read before removing them. + ## Authentication: challenge–response Token issuance is not an out-of-band secret handoff, and the handshake that replaces one is a **normative wire contract** — [Wire format § Authentication](./wire-format.md#authentication) pins the endpoints, the signed payload, and the error vocabulary. It has to be normative to be worth anything: a client that must be handed a token by some server-specific means is back to an out-of-band handoff, for every server. In outline: diff --git a/packages/core/src/scoped-stack/authority.ts b/packages/core/src/scoped-stack/authority.ts index fb1719e0..c07cc655 100644 --- a/packages/core/src/scoped-stack/authority.ts +++ b/packages/core/src/scoped-stack/authority.ts @@ -472,14 +472,15 @@ export class ScopeAuthority { /** * Whether this request may decide who else reaches `record`: management * for a `_group`, so a demoted creator keeps no side door; otherwise the - * owner-or-author rule, asked of both sides of a delegation. - * See docs/spec/access-control.md § Record-level permissions. + * owner-or-author rule, asked of both sides of a delegation, of a record + * the request can read. See docs/spec/access-control.md § Write implies read. */ - canReshare(record: StackRecord): boolean { + async canReshare(record: StackRecord, snapshot: AuthoritySnapshot): Promise { if (familyPolicy(record.typeId).writers === 'group-managers') { return this.isGroupManager(record); } if (!this.mayReshare(this.principalId, record)) return false; - return !this.delegated || this.mayReshare(this.subjectId, record); + if (this.delegated && !this.mayReshare(this.subjectId, record)) return false; + return this.canRead(record, snapshot); } } diff --git a/packages/core/src/scoped-stack/scoped-stack.ts b/packages/core/src/scoped-stack/scoped-stack.ts index 64980f25..f890836f 100644 --- a/packages/core/src/scoped-stack/scoped-stack.ts +++ b/packages/core/src/scoped-stack/scoped-stack.ts @@ -684,7 +684,7 @@ export class ScopedStack implements StackClient { /** requireReshareable()'s gate, asked of a record already found in its family. */ private async gateReshare(record: StackRecord, snapshot: AuthoritySnapshot): Promise { await this.requireOwnerAloneWriter(record, snapshot); - if (!this.#authority.canReshare(record)) { + if (!(await this.#authority.canReshare(record, snapshot))) { throw await this.#authority.denialFor( record, `Cannot change permissions on "${record.id}": only its author or the stack owner can reshare it`, @@ -805,9 +805,12 @@ export class ScopedStack implements StackClient { * See docs/spec/journal.md § Reading it. */ async getJournal(id: RecordId, query: JournalQuery = {}): Promise { - const record = await this.requireUpdatable(id, { mutating: false }, this.#authority.snapshot()); + const snapshot = this.#authority.snapshot(); + const record = await this.requireUpdatable(id, { mutating: false }, snapshot); const entries = await this.stack.getJournal(id, query); - return this.#authority.canReshare(record) ? entries : entries.map(withoutAuthorityChanges); + return (await this.#authority.canReshare(record, snapshot)) + ? entries + : entries.map(withoutAuthorityChanges); } /** See getVersions() — the same mutate-surface gate. */ diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 0055e6ce..c5d40ce9 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -695,7 +695,14 @@ describe('ScopedStack.setUnlisted', () => { }); test('the record creator (not stack owner) may toggle it', async () => { - const record = await adapter.createRecord(makeRecord({ createdBy: { subjectId: MEMBER } })); + const record = await adapter.createRecord( + makeRecord({ + createdBy: { subjectId: MEMBER }, + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }), + ); await stack.asEntity(MEMBER).setUnlisted(record.id, true); expect((await adapter.getRecord(record.id))?.unlistedAt).toBeInstanceOf(Date); }); @@ -3902,6 +3909,74 @@ describe('_group — a deleted Group carries no roster', () => { }); }); +describe('_group — removal leaves a creator nothing their roster conveyed', () => { + const memberEntry: DataAssociation = { + kind: 'relationship', + label: 'member', + target: { kind: 'entity', entityId: MEMBER }, + }; + const readFor = (entityId: string): AuthorityAssociation => ({ + kind: 'permission', + label: 'read', + grantee: { kind: 'entity', entityId }, + }); + + const rosteredGroup = async () => + adapter.createRecord( + makeRecord({ + typeId: '_group@1', + associations: [ + { kind: 'relationship', label: 'admin', target: { kind: 'entity', entityId: OWNER } }, + memberEntry, + ], + }), + ); + + // The removed creator is refused as one who cannot read the record, on + // every verb that decides who reaches it. + const expectNoReshare = async (id: string) => { + const removed = stack.asEntity(MEMBER); + expect(await removed.get(id)).toBeNull(); + await expect( + removed.editAccess(id, [{ op: 'add', association: readFor(MEMBER) }]), + ).rejects.toThrow(StackNotFoundError); + await expect(removed.setAccess(id, [readFor(MEMBER)])).rejects.toThrow(StackNotFoundError); + await expect(removed.setUnlisted(id, true)).rejects.toThrow(StackNotFoundError); + expect((await stack.get(id))?.createdBy?.subjectId).toBe(MEMBER); + }; + + test('through a group-targeted grant', async () => { + const group = await rosteredGroup(); + await stack.grantType(fam(NOTE), { + actions: ['create', 'read-own'], + grantee: { kind: 'group', groupId: group.id, role: 'member' }, + }); + const record = await stack.asEntity(MEMBER).create(NOTE, { text: 'mine' }); + + await stack.editAssociations(group.id, [{ op: 'remove', association: memberEntry }]); + await expectNoReshare(record.id); + }); + + test('through a record-level group grantee', async () => { + const group = await rosteredGroup(); + const record = await adapter.createRecord( + makeRecord({ + createdBy: { subjectId: MEMBER }, + permissions: [ + { + kind: 'permission', + label: 'read', + grantee: { kind: 'group', groupId: group.id, role: 'member' }, + }, + ], + }), + ); + + await stack.editAssociations(group.id, [{ op: 'remove', association: memberEntry }]); + await expectNoReshare(record.id); + }); +}); + // ------------------------------------------------------- // A grant confers what it confers, listed or not // ------------------------------------------------------- From 7f3d108100dbb7a358fdc8d1bb6e3edb2455ec45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:14:59 +0000 Subject: [PATCH 2/6] chore: add changeset for reshare-needs-read Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY --- .changeset/reshare-needs-read.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/reshare-needs-read.md diff --git a/.changeset/reshare-needs-read.md b/.changeset/reshare-needs-read.md new file mode 100644 index 00000000..9bcf3bc9 --- /dev/null +++ b/.changeset/reshare-needs-read.md @@ -0,0 +1,5 @@ +--- +'@haverstack/core': patch +--- + +Resharing (`editAccess()`, `setAccess()`, `setUnlisted()`) needs read on the Record, like every other mutation, so a creator removed from the roster that conveyed their read can no longer reshare their Record to grant themselves back in. From ab90fcf56db3815697d9c9b99ecd5c559756e9f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:33:39 +0000 Subject: [PATCH 3/6] fix(core): create-time permissions and unlisted need read too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resharing needs read on the Record; setting `permissions` or `unlisted` at create() is the same capability exercised before the Record exists, so it is asked the same question. A non-owner creator may pass either only if their type grants alone would let them read the Record — the permissions being written cannot supply that read. A drop-box contributor can no longer name themselves a reader at create time. Also: the changeset is minor (a reshare that succeeded now refuses), and identity.md § Governing a group stack is trimmed to links and states what removal does and does not take away. Refs #419 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY --- .changeset/reshare-needs-read.md | 4 +- docs/spec/access-control.md | 18 ++++---- docs/spec/identity.md | 8 ++-- packages/core/src/scoped-stack/authority.ts | 15 ++++++- .../core/src/scoped-stack/scoped-stack.ts | 8 ++++ packages/core/tests/scoped-stack.test.ts | 42 +++++++++++++++++++ 6 files changed, 79 insertions(+), 16 deletions(-) diff --git a/.changeset/reshare-needs-read.md b/.changeset/reshare-needs-read.md index 9bcf3bc9..ad12493e 100644 --- a/.changeset/reshare-needs-read.md +++ b/.changeset/reshare-needs-read.md @@ -1,5 +1,5 @@ --- -'@haverstack/core': patch +'@haverstack/core': minor --- -Resharing (`editAccess()`, `setAccess()`, `setUnlisted()`) needs read on the Record, like every other mutation, so a creator removed from the roster that conveyed their read can no longer reshare their Record to grant themselves back in. +Resharing needs read on the Record. `editAccess()`, `setAccess()` and `setUnlisted()` refuse a creator who cannot read their Record — one removed from the roster that conveyed it, or a drop-box contributor. `permissions` and `unlisted` at `create()` are refused to a non-owner creator whose type grants would not let them read the Record. diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 2ab4105d..8af0273d 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -92,7 +92,7 @@ Record-level `write` stays a single coarse bit — no per-verb fencing (`update` Operations that sit outside the `write` bit entirely, regardless of grants: - **Purge** is owner-only (see [Deletion](./versioning.md#deletion)) — it destroys the Record and its version history, so there's nothing left to undo. An irreversible verb has no place in a bit whose entire safety argument is recoverability. -- **Resharing — `editAccess()`, `setAccess()` and `setUnlisted()`** — is owner-or-creator-only, and like every mutation it [needs read](#write-implies-read). A write-holder who is neither cannot change who else can access the Record — otherwise a `write` element would let its holder escalate to granting others access, defeating the point of scoping access in the first place. Same pattern as purge: a privilege-bearing operation stays outside the coarse bit. (`restoreVersion()` reinforces this from the other direction — it restores `content` and `typeId` and nothing else, so a content rollback can't silently change who has access, or a Record's roster, tags or listing state, either.) +- **Resharing — `editAccess()`, `setAccess()` and `setUnlisted()`** — is owner-or-creator-only, and like every mutation of an existing Record it [needs read](#write-implies-read) — asked at create time too, of the Record being made. A write-holder who is neither cannot change who else can access the Record — otherwise a `write` element would let its holder escalate to granting others access, defeating the point of scoping access in the first place. Same pattern as purge: a privilege-bearing operation stays outside the coarse bit. (`restoreVersion()` reinforces this from the other direction — it restores `content` and `typeId` and nothing else, so a content rollback can't silently change who has access, or a Record's roster, tags or listing state, either.) - **`_group` Records** opt out of the write bit (and type-level grants) entirely, for every mutating verb including `editAccess()` and `setAccess()`. A Group's own `permissions`/grants govern who can _read_ it, not who can _manage_ it. See [Group](./identity.md#group) for the admin-or-owner rule that replaces it. This is about the **served topology**: for `adapter-local`, direct adapter access is full trust and the permission model doesn't apply — `Stack` is unscoped by design. Record-level permissions exist for the requester on the far side of a server, who has no direct database access. For them, the `write` bit is the only fence, so what it permits has to hold up as a real policy surface — which is exactly why everything it reaches has to be undoable by the owner. @@ -129,6 +129,8 @@ What this buys is an invariant the mutate gate already assumed: **anything that **Resharing reads the same way.** It mutates the Record's authority aspect, so the owner-or-creator rule is asked only of a requester who can read the Record. Authorship confers no read: a creator reaches their own Record through its permissions or a grant like anyone else, and once that lapses — a roster that conveyed it drops them, a grant is revoked — so does their standing to reshare. Otherwise a creator shut out of a Record could grant their way back in. The owner always reads, and `_group` management is roster standing, as above. +**`permissions` and `unlisted` at create time are the same rule, asked before the Record exists.** A non-owner creator may pass either only if the Record would reach them through the type alone — a `read-own` or `read-any` grant. The `permissions` being written cannot count toward it, or the read a reshare needs would be the read it grants. A `_group`'s creator is stamped its first admin, so management stands in for read there as after. Refused with `StackPermissionError`. + **Blind write is offered, and it is `create`.** Contribute-without-reading — a tip box, a contact form, comments held for moderation, an app filing telemetry — is a `create` grant with no read action, and that combination is deliberately left alone: writing a Record you cannot read back discloses nothing. The contributor can neither re-read their own submission nor enumerate the type; `query()` returns nothing to them and the owner sees everything in the box. `putAttachment()` follows the same gate, so a drop box can take files. Three edges are worth knowing before building one: - **Anonymous requesters are always refused**, default grant or not, so a contributor needs a DID — cheap to mint client-side, and it becomes the Record's author. An open box (a default `create` grant) is an unauthenticated-adjacent surface in every way that matters for abuse; rate limiting is the server's business, not the permission model's. @@ -401,18 +403,18 @@ A missing target and an existing-but-inaccessible one **always produce the same **`setParent()` applies the same gate as `create()`**, so a move cannot reach a container an authoring call could not have named. It is the only route: [a restore settles no containment](./versioning.md#restore-semantics), so it can neither reach a container nor need gating on one. It is otherwise an ordinary write on the record — not reshare-gated like the access verbs and `setUnlisted()`, because containment decides which listings enumerate a record rather than who may read it, so a move discloses it to nobody who could not already read it and withdraws it from nobody who could. The origin container is ungated: naming it requires reading the record, which the caller has already had to do. See [Data model § Reparenting](./data-model.md#reparenting). -`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (see [App](./identity.md#app) for what can and cannot be checked after the fact), never a permission input. `permissions` at create time is consistent with the access verbs' owner-or-creator policy after it — a contributor authoring a Record in your Stack can already widen its access up to and including world-readable; create-time is the same capability exercised earlier, not a new one. That holds for every requester who genuinely holds the reshare capability, which is every undelegated one, and [not for a delegated principal](#delegation-principal-and-subject), where create-time `permissions` is refused outright rather than ungated. +`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (see [App](./identity.md#app) for what can and cannot be checked after the fact), never a permission input. `permissions` at create time is the reshare capability exercised earlier, not a new one, and is gated as one: a creator who will be able to read the Record may set it, [as they could after](#write-implies-read), and [a delegated principal](#delegation-principal-and-subject) is refused it outright. ### Per-verb gates Each [write verb](./data-model.md#mutations) moves one aspect, and passes exactly one gate. Over a Record in no system family: -| Verb | What the requester must hold | -| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | -| `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | -| `setParent` | the same, plus read access to the destination | -| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator who can [read](#write-implies-read) it, on both sides of a delegation — and never a delegated principal, as at [create time](#delegation-principal-and-subject) | +| Verb | What the requester must hold | +| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | +| `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | +| `setParent` | the same, plus read access to the destination | +| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator who can [read](#write-implies-read) it — by type grants alone for `permissions`/`unlisted` at `create()` — on both sides of a delegation — and never a delegated principal, as at [create time](#delegation-principal-and-subject) | The family fences apply to every verb alike, whatever the table says: a `_group` Record is writable only by an admin or the owner — its roster is its associations, so on a group the association verbs carry the admin gate — a `_grant` or `_install` Record only by the owner acting alone, and an `_app` card's `did`/`appId`, like an `_entity` card naming the owner's own DID, only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). A content fence is asked of the content a write would leave, by whichever verb writes it — `create()`, `patchContent()`, `restoreVersion()` and `commitMigration()` alike — and compared value-wise, so re-sending a value the Record already holds claims nothing. diff --git a/docs/spec/identity.md b/docs/spec/identity.md index b4dafd6a..7ae7aa91 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -197,13 +197,11 @@ What this does **not** promise: that an `admin` is _reachable_. An admin who los ### Governing a group stack -A collaborative group's stack is a stack like any other, so it has an owner, and the roster sits beneath it. Three defaults follow from rules stated elsewhere; they are gathered here because anyone deciding whether to join a group stack needs them. +**The owner is whoever holds the group's key.** A group stack's `_config.entityId` is the group's DID, and the owner's access is [unconditional](./access-control.md#enforcement-stackasentity): the roster's `admin` manages the roster, it does not bound the owner. -**The owner is whoever holds the group's key, and the owner's access is unconditional.** A group stack's `_config.entityId` is the group's DID, so the key's holders are its owner. No Record in the stack is withheld from them, and they manage any Group whatever its roster says — see [Access control § Enforcement](./access-control.md#enforcement-stackasentity). The roster's `admin` manages the roster; it does not bound the owner. +**The owner's reads are not recorded.** The [journal](./journal.md#reading-it) records writes only. -**The owner's reads are not recorded anywhere.** The [journal](./journal.md#reading-it) records writes only. A member trusts the key's holders with everything the stack holds. - -**Removal cuts a member's reach to what they wrote.** Authorship confers no read: a member reaches Records, their own included, through record-level permissions or type grants, and a grant naming the group stops reaching them once they are off the roster. `createdBy` still names them, but a creator's standing to reshare lapses with their read — [resharing needs read](./access-control.md#write-implies-read) like every other mutation — so they cannot grant their way back in. A member who wants their contributions copies them out while still a member; a group that wants a gentler exit grants the person individual read before removing them. +**Removal ends what the roster conveyed, and nothing else.** Authorship confers no read, so a removed member keeps only what reaches them as an individual — a `read` element naming them, or a grant to them directly — and with their read goes their standing to [reshare](./access-control.md#write-implies-read). A member who could read their own Records could also name themselves on them, and that survives removal; one who never could, cannot. A group that wants a gentler exit grants the person individual read before removing them. ## Authentication: challenge–response diff --git a/packages/core/src/scoped-stack/authority.ts b/packages/core/src/scoped-stack/authority.ts index c07cc655..2954366f 100644 --- a/packages/core/src/scoped-stack/authority.ts +++ b/packages/core/src/scoped-stack/authority.ts @@ -275,7 +275,7 @@ export class ScopeAuthority { return inReach(record, within ? { within } : {}); } - canRead(record: StackRecord, snapshot: AuthoritySnapshot): Promise { + canRead(record: ReachedRecord, snapshot: AuthoritySnapshot): Promise { return this.may('read', record, snapshot); } @@ -483,4 +483,17 @@ export class ScopeAuthority { if (this.delegated && !this.mayReshare(this.subjectId, record)) return false; return this.canRead(record, snapshot); } + + /** + * canReshare() asked of the Record a create would make, before it exists: + * its type alone must reach the requester, so the `permissions` being + * written cannot supply the read that writing them needs. A `_group`'s + * creator is stamped its first admin. See docs/spec/access-control.md + * § Write implies read. + */ + async canReshareAtCreate(typeId: TypeId, snapshot: AuthoritySnapshot): Promise { + if (!this.mayGrantAccess()) return false; + if (familyPolicy(typeId).writers === 'group-managers') return true; + return this.canRead({ typeId, createdBy: this.requester }, snapshot); + } } diff --git a/packages/core/src/scoped-stack/scoped-stack.ts b/packages/core/src/scoped-stack/scoped-stack.ts index f890836f..d3293e1c 100644 --- a/packages/core/src/scoped-stack/scoped-stack.ts +++ b/packages/core/src/scoped-stack/scoped-stack.ts @@ -421,6 +421,14 @@ export class ScopedStack implements StackClient { 'A delegated principal cannot create an unlisted record, at create time or after', ); } + if ( + (opts.permissions?.length || opts.unlisted) && + !(await this.#authority.canReshareAtCreate(typeId, snapshot)) + ) { + throw new StackPermissionError( + `Cannot set permissions or listing on a new ${typeId} record: only a creator who can read it may decide who else reaches it`, + ); + } this.requireOwnerFields(typeId, undefined, content); await this.requireAppIdMatchesPrincipal(opts.appId); if (opts.id !== undefined) { diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index c5d40ce9..b63d316f 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -2532,6 +2532,48 @@ describe('ScopedStack — blind write', () => { .create(NOTE, { text: 'hello' }, { parentId: box.id }); expect(posted.parentId).toBe(box.id); }); + + test('a contributor cannot reshare a submission, after the create or within it', async () => { + await stack.grantType(fam(NOTE), { + actions: ['create'], + grantee: { kind: 'entity', entityId: MEMBER }, + }); + const view = stack.asEntity(MEMBER); + const self: AuthorityAssociation = { + kind: 'permission', + label: 'read', + grantee: { kind: 'entity', entityId: MEMBER }, + }; + const posted = await view.create(NOTE, { text: 'for the owner' }); + + await expect(view.editAccess(posted.id, [{ op: 'add', association: self }])).rejects.toThrow( + StackNotFoundError, + ); + await expect(view.setUnlisted(posted.id, true)).rejects.toThrow(StackNotFoundError); + await expect(view.create(NOTE, { text: 'x' }, { permissions: [self] })).rejects.toThrow( + StackPermissionError, + ); + await expect(view.create(NOTE, { text: 'x' }, { unlisted: true })).rejects.toThrow( + StackPermissionError, + ); + expect((await stack.query({ filter: { typeId: NOTE } })).records).toHaveLength(1); + }); + + test('a contributor who reads their own may set permissions at create', async () => { + await stack.grantType(fam(NOTE), { + actions: ['create', 'read-own'], + grantee: { kind: 'entity', entityId: MEMBER }, + }); + const shared = await stack + .asEntity(MEMBER) + .create( + NOTE, + { text: 'for everyone' }, + { permissions: [{ kind: 'anyone', label: 'read' }], unlisted: true }, + ); + expect(shared.permissions).toEqual([{ kind: 'anyone', label: 'read' }]); + expect(shared.unlistedAt).toBeInstanceOf(Date); + }); }); // ------------------------------------------------------- From e112fd842a34efc4d9b7e3055b2e2aa49c228a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 10:57:37 +0000 Subject: [PATCH 4/6] refactor(core): one reshare gate, asked of the Record a create would make MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create-time check restated canReshare() by hand, with its own _group branch and its own delegation check. It now builds the Record the create would produce — stamped as Stack.create() stamps it, without the permissions being written — and asks canReshare() of it. The stamped admin answers the _group case, and owner-or-creator answers delegation, so mayGrantAccess() and the two delegated refusals in create() go. Spec: the create-time rule and the delegation paragraph state it as the reshare gate; the per-verb row is simplified; "authorship alone confers no read". identity.md drops § Governing a group stack: the owner points are already owned by access-control.md and journal.md, and the removal rule by access-control.md, which § Group now links in one sentence. Refs #419 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY --- docs/spec/access-control.md | 20 +++---- docs/spec/identity.md | 10 +--- packages/core/src/scoped-stack/authority.ts | 40 +++++--------- .../core/src/scoped-stack/scoped-stack.ts | 53 ++++++++++++------- packages/core/tests/scoped-stack.test.ts | 3 ++ 5 files changed, 60 insertions(+), 66 deletions(-) diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 8af0273d..49d86ea7 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -127,9 +127,9 @@ The invariant rests on history being gated on the mutate surface: it is [`getVer What this buys is an invariant the mutate gate already assumed: **anything that passes it can be read by the same requester.** `getVersions()`/`getVersion()` gate on the mutate surface precisely so that history isn't as public as the present, and this closes the reverse — the past never reaches a requester denied the present. The one deliberate exception is `_group`, which opts out of the write bit entirely: Group management is owner-or-admin (see [Group](./identity.md#group)), and an admin may mutate a Group — and read its history — without a read grant on `_group`. Roster standing, not a withheld read bit, is what carries it. -**Resharing reads the same way.** It mutates the Record's authority aspect, so the owner-or-creator rule is asked only of a requester who can read the Record. Authorship confers no read: a creator reaches their own Record through its permissions or a grant like anyone else, and once that lapses — a roster that conveyed it drops them, a grant is revoked — so does their standing to reshare. Otherwise a creator shut out of a Record could grant their way back in. The owner always reads, and `_group` management is roster standing, as above. +**Resharing reads the same way.** It mutates the Record's authority aspect, so the owner-or-creator rule is asked only of a requester who can read the Record. Authorship alone confers no read: a creator reaches their own Record through its permissions or a grant, `read-own` included, and once that lapses — a roster that conveyed it drops them, a grant is revoked — so does their standing to reshare. Otherwise a creator shut out of a Record could grant their way back in. An element naming them individually, written while they could read, is theirs and outlasts the roster. The owner always reads, and `_group` management is roster standing, as above. -**`permissions` and `unlisted` at create time are the same rule, asked before the Record exists.** A non-owner creator may pass either only if the Record would reach them through the type alone — a `read-own` or `read-any` grant. The `permissions` being written cannot count toward it, or the read a reshare needs would be the read it grants. A `_group`'s creator is stamped its first admin, so management stands in for read there as after. Refused with `StackPermissionError`. +**At create time the same rule is asked of the Record the create would make**, stamped as it will be — a `_group`'s creator its first admin — and without the `permissions` being written, so they cannot supply the read that writing them needs. It gates `permissions` and `unlisted` alike, and refuses with `StackPermissionError`. **Blind write is offered, and it is `create`.** Contribute-without-reading — a tip box, a contact form, comments held for moderation, an app filing telemetry — is a `create` grant with no read action, and that combination is deliberately left alone: writing a Record you cannot read back discloses nothing. The contributor can neither re-read their own submission nor enumerate the type; `query()` returns nothing to them and the owner sees everything in the box. `putAttachment()` follows the same gate, so a drop box can take files. Three edges are worth knowing before building one: @@ -328,7 +328,7 @@ The right-hand column is why delegation is worth having: `-own` keeps meaning "t Standing, not a grant, is what the two-sided rule asks for, and that has a consequence worth naming: an app the owner rosters as a Group `admin` may mutate that Group when acting for another admin, holding no grant on `_group` at all — while still needing a `read-any`/`read-own` grant to _read_ the Group Record, since `_group` is grantable. Mutating a Group widens access to every Record shared with it, so rostering an app's DID as `admin` is the same trust decision as granting it a type, and should be made as deliberately. -Refusing a delegated reshare only contains an app if the same reach isn't available a step earlier, so **`permissions` passed to `create()` is refused under delegation** with `StackPermissionError`, unless the principal is the owner. The general argument that create-time `permissions` is "the same capability exercised earlier, not a new one" holds for a human contributor, who genuinely does hold it after the fact. It does not transfer to a principal denied that capability by design. +Refusing a delegated reshare only contains an app if the same reach isn't available a step earlier, and it isn't: **`permissions` and `unlisted` passed to `create()` are [the same gate](#write-implies-read)**, asked of the Record being made, so an app principal — neither owner nor creator — is refused them with `StackPermissionError` unless the principal is the owner. The create is refused outright rather than written with the `permissions` quietly stripped: silently narrowing a write leaves the app believing it shared something it didn't, and a containment boundary an app can't see isn't one it can respect. An empty `permissions` array carries no request and is not refused. @@ -403,18 +403,18 @@ A missing target and an existing-but-inaccessible one **always produce the same **`setParent()` applies the same gate as `create()`**, so a move cannot reach a container an authoring call could not have named. It is the only route: [a restore settles no containment](./versioning.md#restore-semantics), so it can neither reach a container nor need gating on one. It is otherwise an ordinary write on the record — not reshare-gated like the access verbs and `setUnlisted()`, because containment decides which listings enumerate a record rather than who may read it, so a move discloses it to nobody who could not already read it and withdraws it from nobody who could. The origin container is ungated: naming it requires reading the record, which the caller has already had to do. See [Data model § Reparenting](./data-model.md#reparenting). -`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (see [App](./identity.md#app) for what can and cannot be checked after the fact), never a permission input. `permissions` at create time is the reshare capability exercised earlier, not a new one, and is gated as one: a creator who will be able to read the Record may set it, [as they could after](#write-implies-read), and [a delegated principal](#delegation-principal-and-subject) is refused it outright. +`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (see [App](./identity.md#app) for what can and cannot be checked after the fact), never a permission input. `permissions` at create time is the reshare capability exercised earlier, not a new one, so it passes [the reshare gate](#write-implies-read) instead. ### Per-verb gates Each [write verb](./data-model.md#mutations) moves one aspect, and passes exactly one gate. Over a Record in no system family: -| Verb | What the requester must hold | -| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | -| `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | -| `setParent` | the same, plus read access to the destination | -| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator who can [read](#write-implies-read) it — by type grants alone for `permissions`/`unlisted` at `create()` — on both sides of a delegation — and never a delegated principal, as at [create time](#delegation-principal-and-subject) | +| Verb | What the requester must hold | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | +| `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | +| `setParent` | the same, plus read access to the destination | +| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator, holding [read](#write-implies-read); never a delegated principal | The family fences apply to every verb alike, whatever the table says: a `_group` Record is writable only by an admin or the owner — its roster is its associations, so on a group the association verbs carry the admin gate — a `_grant` or `_install` Record only by the owner acting alone, and an `_app` card's `did`/`appId`, like an `_entity` card naming the owner's own DID, only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). A content fence is asked of the content a write would leave, by whichever verb writes it — `create()`, `patchContent()`, `restoreVersion()` and `commitMigration()` alike — and compared value-wise, so re-sending a value the Record already holds claims nothing. diff --git a/docs/spec/identity.md b/docs/spec/identity.md index 7ae7aa91..e998b175 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -163,6 +163,8 @@ This gives roles for free via association labels, and membership is queryable li - **`member`** — counted by group ACLs (a permission element whose grantee is `{ kind: 'group', groupId, role: 'member' }`). - **`admin`** — everything `member` gets, plus may manage the Group Record itself: update its content, add or remove roster (`member`/`admin`) associations, and delete it. An `admin` association implies `member` for every purpose, which is why `role: 'member'` on a grantee is the wider set and `role: 'admin'` the narrower. +**Removal from a roster withdraws what the roster conveyed**, a creator's standing to reshare what they wrote included, since that [needs read](./access-control.md#write-implies-read). + **A roster entry and a permission element are the same shape of edge.** Both are elements on the Record they govern, journalled and delta'd identically — see [Access control § Record-level permissions](./access-control.md#record-level-permissions). What separates them is the aspect: a roster is the Group's associations, moving through `editAssociations()`/`setAssociations()` under the Group's admin rule, and an ACL is its permissions, moving through `editAccess()`/`setAccess()` under the reshare rule. **Role-gated group management.** Mutating a `_group` Record — `patchContent`, `setParent`, `setUnlisted`, `editAssociations`/`setAssociations` of roster associations, `editAccess`/`setAccess`, `delete`, `undelete` — requires the requester to be an `admin` of that Group, or the stack owner. This replaces the ordinary write-bit/grant check for `_group` Records specifically: membership rosters live on the very Record that write access would otherwise let a write-holder rewrite, so "can write this record" is deliberately not sufficient to add or remove members. `ScopedStack` enforces this — _who_ may write a `_group`; the invariant below is a separate question about _what_ a write may leave behind, and `Stack` enforces that for every requester including the owner. Under [delegation](./access-control.md#delegation-principal-and-subject) the rule is asked of both identities: the principal and the subject must each be an `admin` or the owner, so neither a contained app nor the owner's own software seizes a Group on the other's behalf. @@ -195,14 +197,6 @@ What this does **not** promise: that an `admin` is _reachable_. An admin who los **Open question, deliberately not resolved here:** the roles above describe who may manage the Group _Record_. A Group that holds its own key will eventually need to answer "who may act _as_ the Group" — accept grants on its behalf, speak for it to other stacks. That's a distinct question from record management and isn't foreclosed by this definition. -### Governing a group stack - -**The owner is whoever holds the group's key.** A group stack's `_config.entityId` is the group's DID, and the owner's access is [unconditional](./access-control.md#enforcement-stackasentity): the roster's `admin` manages the roster, it does not bound the owner. - -**The owner's reads are not recorded.** The [journal](./journal.md#reading-it) records writes only. - -**Removal ends what the roster conveyed, and nothing else.** Authorship confers no read, so a removed member keeps only what reaches them as an individual — a `read` element naming them, or a grant to them directly — and with their read goes their standing to [reshare](./access-control.md#write-implies-read). A member who could read their own Records could also name themselves on them, and that survives removal; one who never could, cannot. A group that wants a gentler exit grants the person individual read before removing them. - ## Authentication: challenge–response Token issuance is not an out-of-band secret handoff, and the handshake that replaces one is a **normative wire contract** — [Wire format § Authentication](./wire-format.md#authentication) pins the endpoints, the signed payload, and the error vocabulary. It has to be normative to be worth anything: a client that must be handed a token by some server-specific means is back to an out-of-band handoff, for every server. In outline: diff --git a/packages/core/src/scoped-stack/authority.ts b/packages/core/src/scoped-stack/authority.ts index 2954366f..4d95d796 100644 --- a/packages/core/src/scoped-stack/authority.ts +++ b/packages/core/src/scoped-stack/authority.ts @@ -57,6 +57,9 @@ import type { } from '../types/index.js'; import type { Stack } from '../stack/stack.js'; +/** What a reshare decision reads: reach, plus the roster for a `_group`. */ +export type ReshareTarget = ReachedRecord & Pick; + /** * How each side of a request reads a grant. The subject is the entity a * grant is written about, so every tier counts; on the principal's side @@ -305,13 +308,16 @@ export class ScopeAuthority { * grants don't apply. Asked of both identities under delegation, like * a reshare. See docs/spec/identity.md § Group. */ - isGroupManager(record: StackRecord): boolean { + isGroupManager(record: Pick): boolean { if (!this.managesGroup(this.principalId, record)) return false; return !this.delegated || this.managesGroup(this.subjectId, record); } /** Whether one identity, on its own, manages `record` — see isGroupManager(). */ - private managesGroup(entityId: EntityId | null, record: StackRecord): boolean { + private managesGroup( + entityId: EntityId | null, + record: Pick, + ): boolean { if (!entityId) return false; if (entityId === this.stack.ownerEntityId) return true; return groupRoleFromAssociations(record.associations, entityId) === 'admin'; @@ -448,23 +454,13 @@ export class ScopeAuthority { return type ? fileRefFields(type.schema) : []; } - /** - * Whether this request may widen who reaches a record, at create time or - * after. A delegated app may not, unless the owner is its principal: - * widening access is what containment most needs to hold. - * See docs/spec/access-control.md § Delegation: principal and subject. - */ - mayGrantAccess(): boolean { - return this.principalUnbounded; - } - /** * Whether one identity, on its own, may decide who else reaches `record` * — the owner-or-creator rule a reshare enforces, asked of one * side at a time. See * docs/spec/access-control.md § Delegation: principal and subject. */ - private mayReshare(entityId: EntityId | null, record: StackRecord): boolean { + private mayReshare(entityId: EntityId | null, record: ReachedRecord): boolean { if (!entityId) return false; return entityId === this.stack.ownerEntityId || entityId === record.createdBy?.subjectId; } @@ -473,9 +469,10 @@ export class ScopeAuthority { * Whether this request may decide who else reaches `record`: management * for a `_group`, so a demoted creator keeps no side door; otherwise the * owner-or-author rule, asked of both sides of a delegation, of a record - * the request can read. See docs/spec/access-control.md § Write implies read. + * the request can read. Asked of a create's Record before it exists too. + * See docs/spec/access-control.md § Write implies read. */ - async canReshare(record: StackRecord, snapshot: AuthoritySnapshot): Promise { + async canReshare(record: ReshareTarget, snapshot: AuthoritySnapshot): Promise { if (familyPolicy(record.typeId).writers === 'group-managers') { return this.isGroupManager(record); } @@ -483,17 +480,4 @@ export class ScopeAuthority { if (this.delegated && !this.mayReshare(this.subjectId, record)) return false; return this.canRead(record, snapshot); } - - /** - * canReshare() asked of the Record a create would make, before it exists: - * its type alone must reach the requester, so the `permissions` being - * written cannot supply the read that writing them needs. A `_group`'s - * creator is stamped its first admin. See docs/spec/access-control.md - * § Write implies read. - */ - async canReshareAtCreate(typeId: TypeId, snapshot: AuthoritySnapshot): Promise { - if (!this.mayGrantAccess()) return false; - if (familyPolicy(typeId).writers === 'group-managers') return true; - return this.canRead({ typeId, createdBy: this.requester }, snapshot); - } } diff --git a/packages/core/src/scoped-stack/scoped-stack.ts b/packages/core/src/scoped-stack/scoped-stack.ts index d3293e1c..2470443d 100644 --- a/packages/core/src/scoped-stack/scoped-stack.ts +++ b/packages/core/src/scoped-stack/scoped-stack.ts @@ -27,6 +27,7 @@ */ import { baseIdOf } from '../schema.js'; +import { stampGroupAdmin } from '../access.js'; import { validatePatchValues } from '../validate.js'; import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js'; import { SYSTEM_TYPES } from '../types/index.js'; @@ -382,9 +383,9 @@ export class ScopedStack implements StackClient { if (!principal) throw new StackPermissionError('Anonymous requesters cannot create records'); // A grantee is exactly the untrusted actor the `id` skew check below // exists to stop from forging a sort position, and a delegated app - // acting for the owner inherits none of the owner's extra trust — same - // reasoning as ScopeAuthority.mayGrantAccess(). Asked value-wise, since an - // `undefined` carries no date and Stack.create() reads it as absent. + // acting for the owner inherits none of the owner's extra trust. Asked + // value-wise, since an `undefined` carries no date and Stack.create() + // reads it as absent. if ( (opts.createdAt !== undefined || opts.updatedAt !== undefined) && !this.#authority.ownerActingAlone @@ -411,23 +412,8 @@ export class ScopedStack implements StackClient { ); } } - if (opts.permissions?.length && !this.#authority.mayGrantAccess()) { - throw new StackPermissionError( - 'A delegated principal cannot set permissions, at create time or after', - ); - } - if (opts.unlisted && !this.#authority.mayGrantAccess()) { - throw new StackPermissionError( - 'A delegated principal cannot create an unlisted record, at create time or after', - ); - } - if ( - (opts.permissions?.length || opts.unlisted) && - !(await this.#authority.canReshareAtCreate(typeId, snapshot)) - ) { - throw new StackPermissionError( - `Cannot set permissions or listing on a new ${typeId} record: only a creator who can read it may decide who else reaches it`, - ); + if (opts.permissions?.length || opts.unlisted) { + await this.requireReshareableAtCreate(typeId, opts.associations, snapshot); } this.requireOwnerFields(typeId, undefined, content); await this.requireAppIdMatchesPrincipal(opts.appId); @@ -689,6 +675,33 @@ export class ScopedStack implements StackClient { return record; } + /** + * The reshare gate, asked of the Record a create would make: stamped as + * `Stack.create()` stamps it, and without the `permissions` being + * written, so they cannot supply the read that writing them needs. + * See docs/spec/access-control.md § Write implies read. + */ + private async requireReshareableAtCreate( + typeId: TypeId, + associations: DataAssociation[] | undefined, + snapshot: AuthoritySnapshot, + ): Promise { + const createdBy = this.#authority.requester; + const record = { + typeId, + createdBy, + associations: + createdBy && baseIdOf(typeId) === SYSTEM_TYPES.GROUP + ? stampGroupAdmin(associations, createdBy.subjectId) + : associations, + }; + if (!(await this.#authority.canReshare(record, snapshot))) { + throw new StackPermissionError( + `Cannot set permissions or listing on a new ${typeId} record: only its author, holding read, or the stack owner can decide who else reaches it`, + ); + } + } + /** requireReshareable()'s gate, asked of a record already found in its family. */ private async gateReshare(record: StackRecord, snapshot: AuthoritySnapshot): Promise { await this.requireOwnerAloneWriter(record, snapshot); diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index b63d316f..7a8894e2 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -5355,6 +5355,9 @@ describe('ScopedStack — delegation', () => { { permissions: [{ kind: 'anyone', label: 'read' }] }, ), ).rejects.toThrow(StackPermissionError); + await expect( + view.create(COMMENT, { text: 'meant to stay listed' }, { unlisted: true }), + ).rejects.toThrow(StackPermissionError); // Refused before the write, so there is no record to have leaked. const all = await stack.query({ filter: { typeId: COMMENT } }); From 6422224c2a4b9091f135fbef0ed30283d4452840 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 12:31:23 +0000 Subject: [PATCH 5/6] fix(core): a roster entry never counts on the principal's side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegated app could name itself admin on a Group it created, then manage and reshare that Group — at create time once the reshare gate replaced mayGrantAccess(), and through editAccess() afterwards on main. Group-targeted grants already refuse roster-derived authority on the principal's side; group management now follows the same rule: under delegation the principal must be the owner and the subject an admin or the owner. Creating a Group writes its roster, so the create is asked the management rule too, of the Group it would make — the same move as the reshare gate on create-time permissions. Both now share one prospective Record, built by createAssociations(), which Stack.create() also uses to stamp a Group's first admin. Spec: identity.md § Group states the delegated management rule and the create gate; access-control.md fixes the per-verb row, the delegation sentence, and names unlisted beside permissions at create. Refs #419 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY --- .changeset/reshare-needs-read.md | 6 +- docs/spec/access-control.md | 10 ++-- docs/spec/identity.md | 4 +- packages/core/src/access.ts | 20 ++++++- packages/core/src/scoped-stack/authority.ts | 10 ++-- .../core/src/scoped-stack/scoped-stack.ts | 42 ++++++++----- packages/core/src/stack/stack.ts | 12 ++-- packages/core/tests/scoped-stack.test.ts | 60 +++++++++++++++++++ 8 files changed, 130 insertions(+), 34 deletions(-) diff --git a/.changeset/reshare-needs-read.md b/.changeset/reshare-needs-read.md index ad12493e..5cb1bc8c 100644 --- a/.changeset/reshare-needs-read.md +++ b/.changeset/reshare-needs-read.md @@ -2,4 +2,8 @@ '@haverstack/core': minor --- -Resharing needs read on the Record. `editAccess()`, `setAccess()` and `setUnlisted()` refuse a creator who cannot read their Record — one removed from the roster that conveyed it, or a drop-box contributor. `permissions` and `unlisted` at `create()` are refused to a non-owner creator whose type grants would not let them read the Record. +Authority is gated the same way whenever it is written. + +- Resharing needs read on the Record: `editAccess()`, `setAccess()` and `setUnlisted()` refuse a creator who cannot read their Record, such as one removed from the roster that conveyed it, or a drop-box contributor. +- `permissions` and `unlisted` at `create()` pass the same gate, asked of the Record the create would make. +- A roster entry no longer counts on the principal's side of a delegated request, so a delegated app neither manages a `_group` nor creates one, even with its own DID on the roster. The owner's own software acting for a subject still can. diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 49d86ea7..2eb02ab0 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -290,7 +290,7 @@ Two identities are then in play, and one rule separates them: | | Record-level `permissions` resolution | | | The `getAttachment()` uploader clause | -Two rules sit across both columns rather than in either. **Resharing and `_group` management are asked of both identities**: each is a privileged capability, so the principal must hold it, and each acts on a named Record, so the subject must be able to reach that Record. Both identities must independently satisfy the rule — owner-or-creator to reshare, owner-or-admin for a Group. Requiring only the principal would let an owner principal — software the owner trusts unconditionally — carry its subject to Records the subject could not otherwise touch, which is the reach `create()` already withholds. +Two rules sit across both columns rather than in either. **Resharing and `_group` management are asked of both identities**: each is a privileged capability, so the principal must hold it, and each acts on a named Record, so the subject must be able to reach that Record. Both identities must independently satisfy the rule — owner-or-creator to reshare; for a Group, owner-or-admin for the subject and the owner for the principal, since a roster entry is roster-derived authority and [never counts on the principal's side](#who-a-grant-reaches). Requiring only the principal would let an owner principal — software the owner trusts unconditionally — carry its subject to Records the subject could not otherwise touch, which is the reach `create()` already withholds. Omitting `principalId`, or giving it equal to `subjectId`, makes the two the same entity — the undelegated case, where the second check asks the same question of the same identity. `asEntity(did)` is shorthand for `asActor({ subjectId: did })`. An Actor always names a subject, so an anonymous principal has no way to act on behalf of anyone. @@ -328,7 +328,7 @@ The right-hand column is why delegation is worth having: `-own` keeps meaning "t Standing, not a grant, is what the two-sided rule asks for, and that has a consequence worth naming: an app the owner rosters as a Group `admin` may mutate that Group when acting for another admin, holding no grant on `_group` at all — while still needing a `read-any`/`read-own` grant to _read_ the Group Record, since `_group` is grantable. Mutating a Group widens access to every Record shared with it, so rostering an app's DID as `admin` is the same trust decision as granting it a type, and should be made as deliberately. -Refusing a delegated reshare only contains an app if the same reach isn't available a step earlier, and it isn't: **`permissions` and `unlisted` passed to `create()` are [the same gate](#write-implies-read)**, asked of the Record being made, so an app principal — neither owner nor creator — is refused them with `StackPermissionError` unless the principal is the owner. +Refusing a delegated reshare only contains an app if the same reach isn't available a step earlier, and it isn't: **authority a `create()` writes is gated as a later write of it would be**, asked of the Record being made. `permissions` and `unlisted` pass [the reshare gate](#write-implies-read), and a `_group`'s roster passes the [management rule](./identity.md#group). An app principal is neither owner nor creator, and a roster never names it on the principal's side, so all three refuse it with `StackPermissionError`; the owner's own software acting for its subject passes them as that subject would. The create is refused outright rather than written with the `permissions` quietly stripped: silently narrowing a write leaves the app believing it shared something it didn't, and a containment boundary an app can't see isn't one it can respect. An empty `permissions` array carries no request and is not refused. @@ -403,7 +403,7 @@ A missing target and an existing-but-inaccessible one **always produce the same **`setParent()` applies the same gate as `create()`**, so a move cannot reach a container an authoring call could not have named. It is the only route: [a restore settles no containment](./versioning.md#restore-semantics), so it can neither reach a container nor need gating on one. It is otherwise an ordinary write on the record — not reshare-gated like the access verbs and `setUnlisted()`, because containment decides which listings enumerate a record rather than who may read it, so a move discloses it to nobody who could not already read it and withdraws it from nobody who could. The origin container is ungated: naming it requires reading the record, which the caller has already had to do. See [Data model § Reparenting](./data-model.md#reparenting). -`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (see [App](./identity.md#app) for what can and cannot be checked after the fact), never a permission input. `permissions` at create time is the reshare capability exercised earlier, not a new one, so it passes [the reshare gate](#write-implies-read) instead. +`appId` and `permissions` are deliberately **not** gated by this: `appId` is self-reported, untrusted metadata everywhere (see [App](./identity.md#app) for what can and cannot be checked after the fact), never a permission input. `permissions` at create time, like `unlisted`, is the reshare capability exercised earlier, not a new one, so both pass [the reshare gate](#write-implies-read) instead. ### Per-verb gates @@ -414,9 +414,9 @@ Each [write verb](./data-model.md#mutations) moves one aspect, and passes exactl | `patchContent` | record-level `write`, or an `update-own`/`update-any` grant on the type — required of the principal and the subject both | | `editAssociations`, `setAssociations` | the same, plus [reference-creation gating](#reference-creation-gating) on every Association the write adds | | `setParent` | the same, plus read access to the destination | -| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator, holding [read](#write-implies-read); never a delegated principal | +| `editAccess`, `setAccess`, `setUnlisted` | owner or the Record's creator, holding [read](#write-implies-read), asked of both sides of a delegation | -The family fences apply to every verb alike, whatever the table says: a `_group` Record is writable only by an admin or the owner — its roster is its associations, so on a group the association verbs carry the admin gate — a `_grant` or `_install` Record only by the owner acting alone, and an `_app` card's `did`/`appId`, like an `_entity` card naming the owner's own DID, only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). A content fence is asked of the content a write would leave, by whichever verb writes it — `create()`, `patchContent()`, `restoreVersion()` and `commitMigration()` alike — and compared value-wise, so re-sending a value the Record already holds claims nothing. +The family fences apply to every verb alike, `create()` included — asked of the Record it would make — whatever the table says: a `_group` Record is writable only by an admin or the owner — its roster is its associations, so on a group the association verbs carry the admin gate — a `_grant` or `_install` Record only by the owner acting alone, and an `_app` card's `did`/`appId`, like an `_entity` card naming the owner's own DID, only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). A content fence is asked of the content a write would leave, by whichever verb writes it — `create()`, `patchContent()`, `restoreVersion()` and `commitMigration()` alike — and compared value-wise, so re-sending a value the Record already holds claims nothing. **No call can combine gates**, so none can bootstrap its own authority: widening access cannot satisfy the read check on a destination, and naming oneself `admin` in a roster cannot satisfy the admin check the same write must pass. A caller who means to do several of these does each one, and is held to each gate in turn. diff --git a/docs/spec/identity.md b/docs/spec/identity.md index e998b175..3f2be7ec 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -167,13 +167,13 @@ This gives roles for free via association labels, and membership is queryable li **A roster entry and a permission element are the same shape of edge.** Both are elements on the Record they govern, journalled and delta'd identically — see [Access control § Record-level permissions](./access-control.md#record-level-permissions). What separates them is the aspect: a roster is the Group's associations, moving through `editAssociations()`/`setAssociations()` under the Group's admin rule, and an ACL is its permissions, moving through `editAccess()`/`setAccess()` under the reshare rule. -**Role-gated group management.** Mutating a `_group` Record — `patchContent`, `setParent`, `setUnlisted`, `editAssociations`/`setAssociations` of roster associations, `editAccess`/`setAccess`, `delete`, `undelete` — requires the requester to be an `admin` of that Group, or the stack owner. This replaces the ordinary write-bit/grant check for `_group` Records specifically: membership rosters live on the very Record that write access would otherwise let a write-holder rewrite, so "can write this record" is deliberately not sufficient to add or remove members. `ScopedStack` enforces this — _who_ may write a `_group`; the invariant below is a separate question about _what_ a write may leave behind, and `Stack` enforces that for every requester including the owner. Under [delegation](./access-control.md#delegation-principal-and-subject) the rule is asked of both identities: the principal and the subject must each be an `admin` or the owner, so neither a contained app nor the owner's own software seizes a Group on the other's behalf. +**Role-gated group management.** Mutating a `_group` Record — `patchContent`, `setParent`, `setUnlisted`, `editAssociations`/`setAssociations` of roster associations, `editAccess`/`setAccess`, `delete`, `undelete` — requires the requester to be an `admin` of that Group, or the stack owner. This replaces the ordinary write-bit/grant check for `_group` Records specifically: membership rosters live on the very Record that write access would otherwise let a write-holder rewrite, so "can write this record" is deliberately not sufficient to add or remove members. `ScopedStack` enforces this — _who_ may write a `_group`; the invariant below is a separate question about _what_ a write may leave behind, and `Stack` enforces that for every requester including the owner. Under [delegation](./access-control.md#delegation-principal-and-subject) the subject must be an `admin` or the owner, and the principal must be the owner: a roster entry is roster-derived authority, which [never counts on the principal's side](./access-control.md#who-a-grant-reaches). So a contained app manages no Group, whatever roster it is on, and the owner's own software manages one only for a subject who could. **At least one `admin`, always.** A `_group` Record's roster carries at least one `admin` entry, and that is an invariant of the Record rather than a convenience at create time — no Group is ever management-orphaned. The stack owner can always manage any Group regardless of roster, per the owner's unconditional-access rule. Three rules hold it, one per way a roster can change: -- **Create** stamps the creator as the first `admin` (a `relationship` association added at create time), so the invariant is true from the Record's first version. A create is otherwise gated as an ordinary create — a `create` grant on `_group@1`, or the owner — and the roster it names is not admin-gated, because there is no prior roster for the rule to protect: a Group reaches nobody until something points a permission or a grant at it, and both are the owner's to write. The creator stamped is the **subject**, so a [delegated](./access-control.md#delegation-principal-and-subject) app creating a Group for someone manages it afterwards only if it named its own DID `admin` in that same create — the management rule asks both identities, and a `create` grant is not standing on the roster. +- **Create** stamps the creator — the **subject** — as the first `admin` (a `relationship` association added at create time), so the invariant is true from the Record's first version. A create needs a `create` grant on `_group@1`, or the owner, and because it writes a roster it is also asked the management rule above, of the Group it would make. Undelegated that always holds, since the creator is its stamped admin; under delegation it refuses an app principal, so no app creates a Group it could not then manage. - **Any write that would leave the roster with no `admin`** is refused with `StackConflictError` — whether reached through `setAssociations()`, which replaces the roster wholesale, or through `editAssociations()`, which removes entries. The check reads the roster the write would _produce_, not the one it started from, which is what makes it precise: an `admin` may remove themselves while another remains, and may not remove the last one, without either case needing to name who is being removed. - **Restore** never rolls the roster back at all — see [Restore semantics](./versioning.md#restore-semantics). A Group's content rolls back; its roster is authority and changes only deliberately. So no restore can empty a roster, and none has to be refused to keep from it. diff --git a/packages/core/src/access.ts b/packages/core/src/access.ts index 19c3ea74..1cf0bfc2 100644 --- a/packages/core/src/access.ts +++ b/packages/core/src/access.ts @@ -15,6 +15,7 @@ import { SYSTEM_TYPES } from './types/index.js'; import type { + Actor, Association, AuthorityAssociation, DataAssociation, @@ -25,6 +26,7 @@ import type { RecordId, RelationshipAssociation, StackRecord, + TypeId, } from './types/index.js'; import { associationKey } from './associations/identity.js'; import { baseIdOf } from './schema.js'; @@ -164,8 +166,22 @@ function isGroupAdminAssociation( ); } -/** Bootstraps a `_group` record's first admin at create time. */ -export function stampGroupAdmin( +/** + * The associations a create stores: as given, plus a `_group`'s creator — + * the owner when none is named — stamped as its first admin. + * See docs/spec/identity.md § Group. + */ +export function createAssociations( + typeId: TypeId, + associations: DataAssociation[] | undefined, + createdBy: Actor | undefined, + ownerEntityId: EntityId, +): DataAssociation[] | undefined { + if (baseIdOf(typeId) !== SYSTEM_TYPES.GROUP) return associations; + return stampGroupAdmin(associations, createdBy?.subjectId ?? ownerEntityId); +} + +function stampGroupAdmin( associations: DataAssociation[] | undefined, creator: EntityId, ): DataAssociation[] { diff --git a/packages/core/src/scoped-stack/authority.ts b/packages/core/src/scoped-stack/authority.ts index 4d95d796..effc9f92 100644 --- a/packages/core/src/scoped-stack/authority.ts +++ b/packages/core/src/scoped-stack/authority.ts @@ -305,12 +305,14 @@ export class ScopeAuthority { /** * `_group` records are managed, not merely written: only the owner or an * `admin` roster holder may mutate them — ordinary write permissions and - * grants don't apply. Asked of both identities under delegation, like - * a reshare. See docs/spec/identity.md § Group. + * grants don't apply. Under delegation the subject must manage the Group + * and the principal must be the owner: a roster entry, like a group + * grant, never counts on the principal's side. + * See docs/spec/access-control.md § Delegation: principal and subject. */ isGroupManager(record: Pick): boolean { - if (!this.managesGroup(this.principalId, record)) return false; - return !this.delegated || this.managesGroup(this.subjectId, record); + if (!this.delegated) return this.managesGroup(this.principalId, record); + return this.principalUnbounded && this.managesGroup(this.subjectId, record); } /** Whether one identity, on its own, manages `record` — see isGroupManager(). */ diff --git a/packages/core/src/scoped-stack/scoped-stack.ts b/packages/core/src/scoped-stack/scoped-stack.ts index 2470443d..78858dd1 100644 --- a/packages/core/src/scoped-stack/scoped-stack.ts +++ b/packages/core/src/scoped-stack/scoped-stack.ts @@ -27,7 +27,7 @@ */ import { baseIdOf } from '../schema.js'; -import { stampGroupAdmin } from '../access.js'; +import { createAssociations } from '../access.js'; import { validatePatchValues } from '../validate.js'; import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js'; import { SYSTEM_TYPES } from '../types/index.js'; @@ -412,9 +412,7 @@ export class ScopedStack implements StackClient { ); } } - if (opts.permissions?.length || opts.unlisted) { - await this.requireReshareableAtCreate(typeId, opts.associations, snapshot); - } + await this.requireAuthorityAtCreate(typeId, opts, snapshot); this.requireOwnerFields(typeId, undefined, content); await this.requireAppIdMatchesPrincipal(opts.appId); if (opts.id !== undefined) { @@ -676,28 +674,42 @@ export class ScopedStack implements StackClient { } /** - * The reshare gate, asked of the Record a create would make: stamped as - * `Stack.create()` stamps it, and without the `permissions` being - * written, so they cannot supply the read that writing them needs. + * Authority a create writes — a Group's roster, the Record's permissions + * and listing — gated as a later write of it would be, asked of the + * Record the create would make. That Record carries no `permissions`, so + * the ones being written cannot supply the read that writing them needs. * See docs/spec/access-control.md § Write implies read. */ - private async requireReshareableAtCreate( + private async requireAuthorityAtCreate( typeId: TypeId, - associations: DataAssociation[] | undefined, + opts: BackdatableCreateRecordOptions, snapshot: AuthoritySnapshot, ): Promise { const createdBy = this.#authority.requester; const record = { typeId, createdBy, - associations: - createdBy && baseIdOf(typeId) === SYSTEM_TYPES.GROUP - ? stampGroupAdmin(associations, createdBy.subjectId) - : associations, + associations: createAssociations( + typeId, + opts.associations, + createdBy, + this.stack.ownerEntityId, + ), }; - if (!(await this.#authority.canReshare(record, snapshot))) { + if ( + familyPolicy(typeId).writers === 'group-managers' && + !this.#authority.isGroupManager(record) + ) { + throw new StackPermissionError( + `Cannot create a ${baseIdOf(typeId)} record: only its admins can manage it, and an app acting for someone is not one`, + ); + } + if ( + (opts.permissions?.length || opts.unlisted) && + !(await this.#authority.canReshare(record, snapshot)) + ) { throw new StackPermissionError( - `Cannot set permissions or listing on a new ${typeId} record: only its author, holding read, or the stack owner can decide who else reaches it`, + `Cannot set permissions or listing on a new ${typeId} record: only its author or the stack owner can decide who else reaches it`, ); } } diff --git a/packages/core/src/stack/stack.ts b/packages/core/src/stack/stack.ts index c9bdde71..ebf7c8ed 100644 --- a/packages/core/src/stack/stack.ts +++ b/packages/core/src/stack/stack.ts @@ -39,7 +39,7 @@ import { } from '../validate.js'; import { applyMergePatch } from '../merge.js'; import { compareRecordedAttachments } from '../attachment-records.js'; -import { stampGroupAdmin, validatePermissions } from '../access.js'; +import { createAssociations, validatePermissions } from '../access.js'; import { ChangeEmitter, RelayDelivery, PendingChange, assertSinceUsable } from './changes.js'; import { SYSTEM_TYPES } from '../types/index.js'; import type { ValidationError } from '../validate.js'; @@ -682,10 +682,12 @@ export class Stack implements StackClient { } const createdBy = normalizeActor(opts.createdBy); - const associations = - baseIdOf(typeId) === SYSTEM_TYPES.GROUP - ? stampGroupAdmin(opts.associations, createdBy?.subjectId ?? this.ownerEntityId) - : opts.associations; + const associations = createAssociations( + typeId, + opts.associations, + createdBy, + this.ownerEntityId, + ); // createdAt drives the id, so the two agree by construction rather than // by coincidence. An explicit one mints via generateIdForTimestamp(), diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 7a8894e2..0e92ddc7 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -5421,6 +5421,66 @@ describe('ScopedStack — delegation', () => { ).rejects.toThrow(StackNotFoundError); }); + // A roster entry is roster-derived authority, which never counts on the + // principal's side: an app naming itself `admin` gains nothing by it. + test('an app on a roster cannot manage the group for its subject', async () => { + const appAdmin: DataAssociation = { + kind: 'relationship', + label: 'admin', + target: { kind: 'entity', entityId: APP }, + }; + await grantAll({ kind: 'entity', entityId: MEMBER }, '_group@1'); + await grantAll({ kind: 'entity', entityId: APP }, '_group@1'); + const group = await stack.asEntity(MEMBER).create( + '_group@1', + { name: 'Book Club' }, + { + associations: [appAdmin], + }, + ); + const view = stack.asActor({ principalId: APP, subjectId: MEMBER }); + await expect( + view.editAccess(group.id, [{ op: 'add', association: { kind: 'anyone', label: 'read' } }]), + ).rejects.toThrow(StackPermissionError); + await expect(view.patchContent(group.id, { name: 'Renamed' })).rejects.toThrow( + StackPermissionError, + ); + }); + + // Creating a Group writes its roster, so the create is gated as managing + // the Group it would make: the subject is stamped admin, the app is not + // the owner, and naming itself admin does not count. + test('an app cannot create a group for its subject, roster or not', async () => { + await grantAll({ kind: 'entity', entityId: APP }, '_group@1'); + await grantAll({ kind: 'entity', entityId: MEMBER }, '_group@1'); + const view = stack.asActor({ principalId: APP, subjectId: MEMBER }); + await expect(view.create('_group@1', { name: 'x' })).rejects.toThrow(StackPermissionError); + await expect( + view.create( + '_group@1', + { name: 'x' }, + { + associations: [ + { kind: 'relationship', label: 'admin', target: { kind: 'entity', entityId: APP } }, + ], + permissions: [{ kind: 'anyone', label: 'read' }], + }, + ), + ).rejects.toThrow(StackPermissionError); + expect((await stack.query({ filter: { typeId: '_group@1' } })).records).toHaveLength(0); + }); + + test("the owner's own software may create and manage a group for its subject", async () => { + await grantAll({ kind: 'entity', entityId: MEMBER }, '_group@1'); + const view = stack.asActor({ principalId: OWNER, subjectId: MEMBER }); + const group = await view.create( + '_group@1', + { name: 'Book Club' }, + { permissions: [{ kind: 'anyone', label: 'read' }] }, + ); + expect((await view.patchContent(group.id, { name: 'Renamed' })).content.name).toBe('Renamed'); + }); + // Default grants say "any authenticated entity" — people who turn up, // not software the owner installed. An app reaches only what it is named // in, which is the whole of what containment promises. From 4366c685f303c6fc8d8c0aec64c98272b857fc61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:56:56 +0000 Subject: [PATCH 6/6] refactor(core): one family-fence predicate for create and update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit passesWriterFence() decides every family's fence — none for ordinary families, the owner acting alone for _grant and _install, the Group's managers for _group — and is asked of a stored Record on update and of the Record a create would make. It replaces requireOwnerAloneWriter() and gateVerb()'s own group branch, so "the family fences apply to create() too" is the same code rather than an outcome of grant rules. The create-time Record now carries none of the authority the call writes: the caller's roster is left out as its permissions already were, per "no call can combine gates". Spec: the delegation paragraph that let a rostered app manage a Group for another admin states the rule as it is. Refs #419 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C1hFUmrN4Jzp8KUz6mdMsY --- docs/spec/access-control.md | 4 +- packages/core/src/scoped-stack/authority.ts | 23 ++++++- .../core/src/scoped-stack/scoped-stack.ts | 60 ++++++++----------- 3 files changed, 47 insertions(+), 40 deletions(-) diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 2eb02ab0..f02251b7 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -129,7 +129,7 @@ What this buys is an invariant the mutate gate already assumed: **anything that **Resharing reads the same way.** It mutates the Record's authority aspect, so the owner-or-creator rule is asked only of a requester who can read the Record. Authorship alone confers no read: a creator reaches their own Record through its permissions or a grant, `read-own` included, and once that lapses — a roster that conveyed it drops them, a grant is revoked — so does their standing to reshare. Otherwise a creator shut out of a Record could grant their way back in. An element naming them individually, written while they could read, is theirs and outlasts the roster. The owner always reads, and `_group` management is roster standing, as above. -**At create time the same rule is asked of the Record the create would make**, stamped as it will be — a `_group`'s creator its first admin — and without the `permissions` being written, so they cannot supply the read that writing them needs. It gates `permissions` and `unlisted` alike, and refuses with `StackPermissionError`. +**At create time the same rule is asked of the Record the create would make**: its creator, and a `_group`'s creator stamped as its first admin, but none of the authority the call is writing — neither the `permissions` nor a roster — since [no call can combine gates](#per-verb-gates). It gates `permissions` and `unlisted` alike, and refuses with `StackPermissionError`. **Blind write is offered, and it is `create`.** Contribute-without-reading — a tip box, a contact form, comments held for moderation, an app filing telemetry — is a `create` grant with no read action, and that combination is deliberately left alone: writing a Record you cannot read back discloses nothing. The contributor can neither re-read their own submission nor enumerate the type; `query()` returns nothing to them and the owner sees everything in the box. `putAttachment()` follows the same gate, so a drop box can take files. Three edges are worth knowing before building one: @@ -326,7 +326,7 @@ The one place an owner principal is deliberately unbounded is grant lookup — i The right-hand column is why delegation is worth having: `-own` keeps meaning "this person's Records, through whichever app they used", so two apps writing the same commons type still interoperate. The principal's column is why it is safe. Those operations have no grant fence at all — resharing has only owner-or-creator behind it, and `_group` mutation bypasses grants entirely — so resolving them against the subject _alone_ would let any delegated app reshare its subject's data or seize a group it was never granted. An app with no standing of its own is refused them outright. -Standing, not a grant, is what the two-sided rule asks for, and that has a consequence worth naming: an app the owner rosters as a Group `admin` may mutate that Group when acting for another admin, holding no grant on `_group` at all — while still needing a `read-any`/`read-own` grant to _read_ the Group Record, since `_group` is grantable. Mutating a Group widens access to every Record shared with it, so rostering an app's DID as `admin` is the same trust decision as granting it a type, and should be made as deliberately. +Standing, not a grant, is what the two-sided rule asks for, and a roster entry is not standing on the principal's side: an app rostered as a Group `admin` manages that Group only when signing as itself, never while acting for someone. Mutating a Group widens access to every Record shared with it, and a roster is editable by admins other than the owner, so a roster is no way to hand that to an app — the same reason [group-targeted grants](#who-a-grant-reaches) don't count there. Refusing a delegated reshare only contains an app if the same reach isn't available a step earlier, and it isn't: **authority a `create()` writes is gated as a later write of it would be**, asked of the Record being made. `permissions` and `unlisted` pass [the reshare gate](#write-implies-read), and a `_group`'s roster passes the [management rule](./identity.md#group). An app principal is neither owner nor creator, and a roster never names it on the principal's side, so all three refuse it with `StackPermissionError`; the owner's own software acting for its subject passes them as that subject would. diff --git a/packages/core/src/scoped-stack/authority.ts b/packages/core/src/scoped-stack/authority.ts index effc9f92..44a79eda 100644 --- a/packages/core/src/scoped-stack/authority.ts +++ b/packages/core/src/scoped-stack/authority.ts @@ -57,8 +57,8 @@ import type { } from '../types/index.js'; import type { Stack } from '../stack/stack.js'; -/** What a reshare decision reads: reach, plus the roster for a `_group`. */ -export type ReshareTarget = ReachedRecord & Pick; +/** What a fence or reshare decision reads: reach, plus a `_group`'s roster. */ +export type GatedRecord = ReachedRecord & Pick; /** * How each side of a request reads a grant. The subject is the entity a @@ -315,6 +315,23 @@ export class ScopeAuthority { return this.principalUnbounded && this.managesGroup(this.subjectId, record); } + /** + * Whether this request passes `record`'s family fence: none for an + * ordinary family, the owner acting alone for `_grant` and `_install`, the + * Group's managers for `_group`. Asked of the Record a create would make + * as of a stored one. See docs/spec/access-control.md § Per-verb gates. + */ + passesWriterFence(record: GatedRecord): boolean { + switch (familyPolicy(record.typeId).writers) { + case 'owner-alone': + return this.ownerActingAlone; + case 'group-managers': + return this.isGroupManager(record); + case 'ordinary': + return true; + } + } + /** Whether one identity, on its own, manages `record` — see isGroupManager(). */ private managesGroup( entityId: EntityId | null, @@ -474,7 +491,7 @@ export class ScopeAuthority { * the request can read. Asked of a create's Record before it exists too. * See docs/spec/access-control.md § Write implies read. */ - async canReshare(record: ReshareTarget, snapshot: AuthoritySnapshot): Promise { + async canReshare(record: GatedRecord, snapshot: AuthoritySnapshot): Promise { if (familyPolicy(record.typeId).writers === 'group-managers') { return this.isGroupManager(record); } diff --git a/packages/core/src/scoped-stack/scoped-stack.ts b/packages/core/src/scoped-stack/scoped-stack.ts index 78858dd1..a5cd689a 100644 --- a/packages/core/src/scoped-stack/scoped-stack.ts +++ b/packages/core/src/scoped-stack/scoped-stack.ts @@ -276,17 +276,11 @@ export class ScopedStack implements StackClient { const { writers } = familyPolicy(record.typeId); // The owner-alone fence is on writes only: reading how a grant came to // be is not the escalation it exists to stop. - if (fenceGrantRecord) await this.requireOwnerAloneWriter(record, snapshot); - if (writers === 'group-managers') { - if (!this.#authority.isGroupManager(record)) { - throw await this.#authority.denialFor( - record, - `Cannot change group "${id}": only its admins can manage it`, - snapshot, - ); - } - return; + if (fenceGrantRecord || writers !== 'owner-alone') { + await this.requireFamilyWriter(record, snapshot); } + // Management replaces the write bit and grants for a Group. + if (writers === 'group-managers') return; if (await this.#authority.may(verb, record, snapshot)) return; const what = `Cannot ${verb} "${id}" (${record.typeId})`; throw await this.#authority.denialFor( @@ -676,33 +670,23 @@ export class ScopedStack implements StackClient { /** * Authority a create writes — a Group's roster, the Record's permissions * and listing — gated as a later write of it would be, asked of the - * Record the create would make. That Record carries no `permissions`, so - * the ones being written cannot supply the read that writing them needs. + * Record the create would make. That Record carries none of the authority + * being written, so no call can satisfy its own gate. * See docs/spec/access-control.md § Write implies read. */ private async requireAuthorityAtCreate( typeId: TypeId, - opts: BackdatableCreateRecordOptions, + opts: Pick, snapshot: AuthoritySnapshot, ): Promise { const createdBy = this.#authority.requester; const record = { typeId, createdBy, - associations: createAssociations( - typeId, - opts.associations, - createdBy, - this.stack.ownerEntityId, - ), + associations: createAssociations(typeId, undefined, createdBy, this.stack.ownerEntityId), }; - if ( - familyPolicy(typeId).writers === 'group-managers' && - !this.#authority.isGroupManager(record) - ) { - throw new StackPermissionError( - `Cannot create a ${baseIdOf(typeId)} record: only its admins can manage it, and an app acting for someone is not one`, - ); + if (!this.#authority.passesWriterFence(record)) { + throw new StackPermissionError(writerFenceRefusal(typeId)); } if ( (opts.permissions?.length || opts.unlisted) && @@ -716,7 +700,7 @@ export class ScopedStack implements StackClient { /** requireReshareable()'s gate, asked of a record already found in its family. */ private async gateReshare(record: StackRecord, snapshot: AuthoritySnapshot): Promise { - await this.requireOwnerAloneWriter(record, snapshot); + await this.requireFamilyWriter(record, snapshot); if (!(await this.#authority.canReshare(record, snapshot))) { throw await this.#authority.denialFor( record, @@ -759,20 +743,18 @@ export class ScopedStack implements StackClient { } /** - * Refuse anyone but the owner acting alone a write to a family only the - * owner writes — `_grant` and `_install`, which are authority — with the - * refusal an unreadable Record answers with. Any other family passes. - * See docs/spec/access-control.md § What a grant covers. + * Refuse a write its family fences off — see + * ScopeAuthority.passesWriterFence() — with the refusal an unreadable + * Record answers with. */ - private async requireOwnerAloneWriter( + private async requireFamilyWriter( record: StackRecord, snapshot: AuthoritySnapshot, ): Promise { - if (familyPolicy(record.typeId).writers !== 'owner-alone') return; - if (this.#authority.ownerActingAlone) return; + if (this.#authority.passesWriterFence(record)) return; throw await this.#authority.denialFor( record, - `Only the stack owner may write a ${baseIdOf(record.typeId)} record`, + writerFenceRefusal(record.typeId, record.id), snapshot, ); } @@ -1027,3 +1009,11 @@ export class ScopedStack implements StackClient { ); } } + +/** Why a family fence refused a write: to `id`, or to a new Record when absent. */ +function writerFenceRefusal(typeId: TypeId, id?: RecordId): string { + if (familyPolicy(typeId).writers === 'group-managers') { + return `Cannot ${id ? `change group "${id}"` : 'create this group'}: only its admins can manage it`; + } + return `Only the stack owner may write a ${baseIdOf(typeId)} record`; +}