diff --git a/docs/spec.md b/docs/spec.md index 11cc87f..c09539b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -128,6 +128,7 @@ A Grant authorises one or more Entities to perform specific actions on Records o type GrantContent = { typeId: TypeId; // Which record type this grant covers actions: GrantAction[]; // Which actions are permitted + granteeEntityId?: string; // Who the grant applies to. Absent = default grant (any authenticated entity). }; type GrantAction = @@ -140,6 +141,8 @@ type GrantAction = | 'delete-any'; // Delete all records of this type ``` +The grantee lives in `content.granteeEntityId`, not `record.entityId`. `entityId` means "author" on every other Record in the system, and a `_grant` Record is always authored by the stack owner (the only caller of `grant()`) — never by the entity it names. A grant Record therefore carries no `entityId` of its own, consistent with the owner-authored-records invariant, and "everything this entity authored" queries (`filter: { entityId }`) don't pick up grants that merely name that entity. + `Stack.grant()` is the owner-facing helper for creating grant records: ```ts @@ -148,15 +151,30 @@ await stack.grant('bob-entity-id', [ { typeId: 'com.example/comment@1', actions: ['create', 'read-own', 'update-own', 'delete-own'] }, ]); -// Default grant — applies to any authenticated entity (null entityId on the grant record) +// Default grant — applies to any authenticated entity (no granteeEntityId in content) await stack.grant(null, [{ typeId: 'com.example/comment@1', actions: ['create', 'read-own'] }]); ``` +`Stack.listGrants(entityId?)` and `Stack.revoke(entityId, grants)` are the read/undo counterparts, both owner-facing like `grant()`: + +```ts +await stack.listGrants(); // every grant record, any grantee +await stack.listGrants(null); // only default grants +await stack.listGrants('bob-entity-id'); // grants naming Bob, plus every default grant — what currently applies to him + +// The inverse of grant(): soft-deletes the _grant record(s) matching entityId +// (null for a default grant) and each { typeId, actions } pair, matched by +// typeId baseId and action set — the same granularity grant() writes at. +await stack.revoke('bob-entity-id', [{ typeId: 'com.example/comment@1', actions: ['create'] }]); +``` + +A revocation is a soft delete like any other mutation — the owner can `undelete()` it the same as an accidental delete anywhere else (see [Versions](#versions)). + **Design decisions:** - **No wildcard `typeId`**: there is no `*` or catch-all. Every grant is opt-in per type. Adding a new type never implicitly inherits existing grants — it starts default-deny. -- **Grants target the type family, not the exact version**: a grant naming `com.example/comment@1` also covers `com.example/comment@2` — matching is by `baseId`, derived from whichever form the grant's `typeId` was given in. This is what keeps a version bump from silently orphaning existing grants (previously, registering a migration broke every grant pinned to the old version, with no data change at all — grants are checked in memory _before_ any migration applies). -- **Default grants** (grant record has no `entityId`): apply to any authenticated entity. Useful for "any logged-in user can comment" scenarios. Anonymous requesters (no `entityId`) are always denied, even under a default grant. +- **Grants target the type family, not the exact version**: a grant naming `com.example/comment@1` also covers `com.example/comment@2` — matching is by `baseId`, derived from whichever form the grant's `typeId` was given in. This is what keeps a version bump from silently orphaning existing grants (previously, registering a migration broke every grant pinned to the old version, with no data change at all — grants are checked in memory _before_ any migration applies). `revoke()` matches at the same granularity. +- **Default grants** (grant content has no `granteeEntityId`): apply to any authenticated entity. Useful for "any logged-in user can comment" scenarios. Anonymous requesters (no `entityId`) are always denied, even under a default grant. - **Actions are independent**: `'create'` does not imply `'read-own'`, and so on. The combination `['create', 'read-own', 'update-own', 'delete-own']` is a common bundle for contributor access, but each action must be listed explicitly. - **`-own` scope**: `-own` actions apply only to Records where `record.entityId` equals the requester — Records the entity authored. Records with no `entityId` (owner-created) do not satisfy any `-own` check. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 9809044..061433e 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -1300,6 +1300,10 @@ export class Stack implements StackClient { * for specific record types. Pass null as entityId for a default grant * that applies to any authenticated entity. * + * The grantee lives in `GrantContent.granteeEntityId`, not `record.entityId` + * — `entityId` means "author" everywhere else, and the owner (who calls + * grant()) authored this record, never the entity it names (#57). + * * The _grant@1 type is defined automatically on first use. */ async grant( @@ -1309,16 +1313,66 @@ export class Stack implements StackClient { const records: StackRecord[] = []; for (const g of grants) { records.push( - await this.create( - `${SYSTEM_TYPES.GRANT}@1`, - { typeId: g.typeId, actions: g.actions }, - entityId ? { entityId } : {}, - ), + await this.create(`${SYSTEM_TYPES.GRANT}@1`, { + typeId: g.typeId, + actions: g.actions, + ...(entityId && { granteeEntityId: entityId }), + }), ); } return records; } + /** + * List _grant records. Omit `entityId` for every grant regardless of + * grantee. Pass `null` for only default grants (no `granteeEntityId` — + * apply to any authenticated entity). Pass a specific entityId for the + * grants that currently apply to that entity: ones naming them, plus + * every default grant — the same resolution ScopedStack's hasGrant() + * uses internally. + */ + async listGrants(entityId?: string | null): Promise { + const all = await queryAllPages((q) => this.query(q), { + filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` }, + }); + if (entityId === undefined) return all; + return all.filter((r) => { + const granteeEntityId = (r.content as GrantContent).granteeEntityId; + return entityId === null + ? !granteeEntityId + : !granteeEntityId || granteeEntityId === entityId; + }); + } + + /** + * The inverse of grant(): soft-deletes _grant records exactly matching + * `entityId` (null for default grants) and each `{ typeId, actions }` + * pair — matched by typeId baseId and action set, the same granularity + * grant() writes at. A soft delete like any other mutation: the owner can + * undelete a revocation the same as any other write (#59/#61). + */ + async revoke( + entityId: string | null, + grants: Array<{ actions: GrantAction[]; typeId: TypeId }>, + ): Promise { + const all = await queryAllPages((q) => this.query(q), { + filter: { typeId: `${SYSTEM_TYPES.GRANT}@1` }, + }); + for (const g of grants) { + const familyId = baseIdOf(g.typeId); + const actionSet = new Set(g.actions); + const matches = all.filter((r) => { + const c = r.content as GrantContent; + if (baseIdOf(c.typeId) !== familyId) return false; + if ((c.granteeEntityId ?? null) !== entityId) return false; + return c.actions.length === actionSet.size && c.actions.every((a) => actionSet.has(a)); + }); + for (const match of matches) { + await this.delete(match.id); + } + } + } + // ------------------------------------------------------- // Private helpers // ------------------------------------------------------- @@ -1344,6 +1398,7 @@ export class Stack implements StackClient { await this.defineType(`${SYSTEM_TYPES.GRANT}@1`, 'Grant', { typeId: { kind: 'string', required: true }, actions: { kind: 'array', items: { kind: 'string' }, required: true }, + granteeEntityId: { kind: 'string' }, }); await this.defineType(`${SYSTEM_TYPES.ATTACHMENT}@1`, 'Attachment', { fileId: { kind: 'string', required: true }, @@ -1518,7 +1573,7 @@ export class ScopedStack implements StackClient { return grantRecords.some((r) => { const c = r.content as GrantContent; if (baseIdOf(c.typeId) !== familyId) return false; - if (r.entityId && r.entityId !== this.requesterEntityId) return false; + if (c.granteeEntityId && c.granteeEntityId !== this.requesterEntityId) return false; return actions.some((action) => { if (!(c.actions as string[]).includes(action)) return false; if (action.endsWith('-own')) return record?.entityId === this.requesterEntityId; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3703ef..c721344 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -208,6 +208,8 @@ export type GrantContent = { typeId: TypeId; /** Which actions are permitted. */ actions: GrantAction[]; + /** Who the grant applies to. Absent = default grant, applies to any authenticated entity. */ + granteeEntityId?: string; }; /** Content for _attachment records — one per upload, tracks file metadata. */ diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index b4842eb..1549c43 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -1019,13 +1019,20 @@ describe('grant', () => { test('creates a grant record for the given entity and type', async () => { const records = await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); expect(records).toHaveLength(1); - expect(records[0].entityId).toBe('entity-abc'); - expect(records[0].content).toEqual({ typeId: NOTE_V1, actions: ['create'] }); + // The grantee lives in content, not record.entityId — entityId means + // "author", and the owner (who called grant()) authored this record (#57). + expect(records[0].entityId).toBeUndefined(); + expect(records[0].content).toEqual({ + typeId: NOTE_V1, + actions: ['create'], + granteeEntityId: 'entity-abc', + }); }); - test('null entityId creates a default grant (no entityId on the record)', async () => { + test('null entityId creates a default grant (no granteeEntityId in content)', async () => { const records = await stack.grant(null, [{ actions: ['create'], typeId: NOTE_V1 }]); expect(records[0].entityId).toBeUndefined(); + expect(records[0].content).toEqual({ typeId: NOTE_V1, actions: ['create'] }); }); test('creates multiple grant records in one call', async () => { @@ -1047,6 +1054,106 @@ describe('grant', () => { test('_attachment@1 type is available immediately after Stack.create()', async () => { expect(await stack.getType('_attachment@1')).not.toBeNull(); }); + + // A grant record used to carry the grantee in record.entityId, which + // means "author" everywhere else — so "everything Alice authored" queries + // picked up grants *about* Alice that she never touched. Moving the + // grantee into content fixes this (#57). + test('an authorship query does not pick up grants naming that entity', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + const result = await stack.query({ filter: { entityId: 'entity-abc' } }); + expect(result.records).toHaveLength(0); + }); + + test('a grant record still resolves through ScopedStack for its named grantee', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + const record = await stack.asEntity('entity-abc').create(NOTE_V1, { text: 'hi' }); + expect(record.content.text).toBe('hi'); + }); +}); + +// ------------------------------------------------------- +// listGrants +// ------------------------------------------------------- + +describe('listGrants', () => { + test('omitting entityId returns every grant record', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['read-any'], typeId: NOTE_V1 }]); + const grants = await stack.listGrants(); + expect(grants).toHaveLength(2); + }); + + test('entityId: null returns only default grants', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['read-any'], typeId: NOTE_V1 }]); + const grants = await stack.listGrants(null); + expect(grants).toHaveLength(1); + expect(grants[0].content).toMatchObject({ actions: ['read-any'] }); + }); + + test('a specific entityId returns grants naming it plus every default grant', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant('entity-xyz', [{ actions: ['delete-own'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['read-any'], typeId: NOTE_V1 }]); + + const grants = await stack.listGrants('entity-abc'); + expect(grants).toHaveLength(2); + const actionSets = grants.map((g) => (g.content as { actions: string[] }).actions); + expect(actionSets).toContainEqual(['create']); + expect(actionSets).toContainEqual(['read-any']); + }); +}); + +// ------------------------------------------------------- +// revoke +// ------------------------------------------------------- + +describe('revoke', () => { + test('deletes the grant record matching entityId, typeId, and actions', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + const grants = await stack.listGrants('entity-abc'); + expect(grants).toHaveLength(0); + }); + + test('revocation is a soft delete — the owner can undelete it like any other mutation', async () => { + const [granted] = await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants('entity-abc')).toHaveLength(0); + + await stack.undelete(granted.id); + expect(await stack.listGrants('entity-abc')).toHaveLength(1); + }); + + test('does not affect a grant for a different entity or a default grant', async () => { + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.grant(null, [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-xyz', [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants()).toHaveLength(2); + }); + + test('does not affect a grant for the same entity with a different action set', async () => { + await stack.grant('entity-abc', [{ actions: ['create', 'read-own'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants('entity-abc')).toHaveLength(1); + }); + + test('matches by baseId, covering every version of the type family', async () => { + await stack.defineType(NOTE_V2, 'Note v2', { + text: { kind: 'text', required: true }, + title: { kind: 'string' }, + }); + await stack.grant('entity-abc', [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke('entity-abc', [{ actions: ['create'], typeId: NOTE_V2 }]); + expect(await stack.listGrants('entity-abc')).toHaveLength(0); + }); + + test('null entityId revokes a default grant', async () => { + await stack.grant(null, [{ actions: ['create'], typeId: NOTE_V1 }]); + await stack.revoke(null, [{ actions: ['create'], typeId: NOTE_V1 }]); + expect(await stack.listGrants(null)).toHaveLength(0); + }); }); // -------------------------------------------------------