Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,12 +340,35 @@ Group permissions reference a `_group` Record by ID. The group may be a simple p

Cross-stack group resolution (where the `_group` Record lives in a different stack than the Record being accessed) requires the server to have read access to that stack.

### The `write` bit: a recoverability trust model

Record-level `write` stays a single coarse bit — no per-verb fencing (`update` vs `associate` vs `delete`) at the record level. That's a deliberate scale decision: per-record sharing among a small, cohesive group doesn't call for maintained per-verb ACLs. What makes one bit safe is this:

> **Anything a write-holder does, the owner can undo.** Recoverability is the backstop, not per-verb precision.

| Verb, via `write: true` | Reversible? | How |
| -------------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `update` | Yes | Versioned — `restoreVersion()` undoes it |
| `associate` / `dissociate` | Yes | Versioned (see [Versions](#versions)) — `restoreVersion()` restores prior associations |
| `restoreVersion` | Yes | Update-shaped; itself creates a new, restorable version |
| `delete` (soft) | Yes | `undelete()` reverses it — the owner can always undelete, regardless of who deleted (see [Deletion](#deletion)) |
| `delete` (hard) | Never reachable via `write` | Owner-only, unconditionally — see below |

Two operations sit outside the `write` bit entirely, regardless of grants:

- **Hard delete** is owner-only (see [Deletion](#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.
- **`setPermissions()`** is owner-or-creator-only. A write-holder who is neither the owner nor the Record's creator cannot change who else can access it — otherwise a `write: true` grant would let its holder escalate to granting others access, defeating the point of scoping access in the first place. This is not a special case bolted onto `setPermissions` alone; it's the same pattern as hard delete: a privilege-bearing operation stays outside the coarse bit. (`restoreVersion()` reinforces this from the other direction — it restores `content` and `associations` but never `permissions`, so a content rollback can't silently change who has access either.)

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.

### Layer 2: Type-level grants

`_grant` records (see [Grant](#grant)) authorise Entities to perform actions across all Records of a given Type, without touching individual records. A read-any grant on `comment@1`, for example, makes all comments of that type readable by the grantee without setting `permissions` on each one.

`ScopedStack` checks both layers on every operation: if either the Record's own `permissions` or a matching `_grant` record permits the action, access is granted. The owner always has full access and bypasses both checks.

The two layers deliberately use different granularities. Record-level `write` is one coarse bit (above); grants are precise per verb (`update-own`/`update-any`, `delete-own`/`delete-any`, etc. — see [Grant](#grant)). Type-wide access for a third-party app warrants verb precision in a way per-record sharing among intimates doesn't. `associate()`/`dissociate()` don't get their own grant action — they ride `update-own`/`update-any`, the same as content changes, keeping the grant vocabulary from growing a verb for every mutation kind.

### Enforcement: `Stack.asEntity()`

The core library ships a permission-enforcing wrapper so server implementations don't need to reimplement this resolution logic. `stack.asEntity(entityId)` — `entityId` is `null` for an anonymous/unauthenticated requester — returns a `ScopedStack`: the same surface as `Stack`, but every operation is checked against both permission layers.
Expand Down
14 changes: 14 additions & 0 deletions packages/core/tests/scoped-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,20 @@ describe('ScopedStack — write access', () => {
expect(undeleted.deletedAt).toBeUndefined();
});

test('a write-holder soft-delete is undeletable by the owner — recoverability holds', async () => {
const record = await adapter.createRecord(
makeRecord({
permissions: [{ access: 'entity', entityId: MEMBER, read: true, write: true }],
}),
);
await stack.asEntity(MEMBER).delete(record.id);
expect((await adapter.getRecord(record.id))?.deletedAt).toBeDefined();

const undeleted = await stack.asEntity(OWNER).undelete(record.id);
expect(undeleted.deletedAt).toBeUndefined();
expect((await adapter.getRecord(record.id))?.deletedAt).toBeUndefined();
});

test('associate/dissociate/setPermissions enforce write access', async () => {
const record = await adapter.createRecord(makeRecord());
const tag: Association = { kind: 'tag', label: 'starred' };
Expand Down
Loading