diff --git a/.changeset/reshare-needs-read.md b/.changeset/reshare-needs-read.md new file mode 100644 index 00000000..5cb1bc8c --- /dev/null +++ b/.changeset/reshare-needs-read.md @@ -0,0 +1,9 @@ +--- +'@haverstack/core': minor +--- + +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 2070c690..f02251b7 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 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. @@ -127,6 +127,10 @@ 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 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**: 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: - **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. @@ -286,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. @@ -322,9 +326,9 @@ 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, 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: **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. @@ -399,20 +403,20 @@ 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, 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 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, 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 ebf088eb..3f2be7ec 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -163,15 +163,17 @@ 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. +**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 fb1719e0..44a79eda 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 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 * grant is written about, so every tier counts; on the principal's side @@ -275,7 +278,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); } @@ -302,16 +305,38 @@ 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: StackRecord): boolean { - if (!this.managesGroup(this.principalId, record)) return false; - return !this.delegated || this.managesGroup(this.subjectId, record); + isGroupManager(record: Pick): boolean { + if (!this.delegated) return this.managesGroup(this.principalId, record); + 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, 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 +473,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; } @@ -472,14 +487,16 @@ 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. Asked of a create's Record before it exists too. + * See docs/spec/access-control.md § Write implies read. */ - canReshare(record: StackRecord): boolean { + async canReshare(record: GatedRecord, 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..a5cd689a 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 { createAssociations } from '../access.js'; import { validatePatchValues } from '../validate.js'; import { ChangeEmitter, assertSinceUsable } from '../stack/changes.js'; import { SYSTEM_TYPES } from '../types/index.js'; @@ -275,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( @@ -382,9 +377,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,16 +406,7 @@ 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', - ); - } + await this.requireAuthorityAtCreate(typeId, opts, snapshot); this.requireOwnerFields(typeId, undefined, content); await this.requireAppIdMatchesPrincipal(opts.appId); if (opts.id !== undefined) { @@ -681,10 +667,41 @@ export class ScopedStack implements StackClient { return record; } + /** + * 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 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: Pick, + snapshot: AuthoritySnapshot, + ): Promise { + const createdBy = this.#authority.requester; + const record = { + typeId, + createdBy, + associations: createAssociations(typeId, undefined, createdBy, this.stack.ownerEntityId), + }; + if (!this.#authority.passesWriterFence(record)) { + throw new StackPermissionError(writerFenceRefusal(typeId)); + } + 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 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); - if (!this.#authority.canReshare(record)) { + await this.requireFamilyWriter(record, snapshot); + 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`, @@ -726,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, ); } @@ -805,9 +820,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. */ @@ -991,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`; +} 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 0055e6ce..0e92ddc7 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); }); @@ -2525,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); + }); }); // ------------------------------------------------------- @@ -3902,6 +3951,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 // ------------------------------------------------------- @@ -5238,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 } }); @@ -5301,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.