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
24 changes: 21 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -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
Expand All @@ -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.

Expand Down
67 changes: 61 additions & 6 deletions packages/core/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<StackRecord[]> {
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<void> {
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
// -------------------------------------------------------
Expand All @@ -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 },
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
113 changes: 110 additions & 3 deletions packages/core/tests/stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
});
});

// -------------------------------------------------------
Expand Down
Loading