Skip to content

Commit fda01f5

Browse files
feat(permissions): allow opting out of implicit READ via READ: false (#469)
Every `PermissionsBlock` previously emitted an implicit READ chain — the short-circuit `action === 'READ' || action in block` in both `generatePermissions` and `addPermissions` always pushed READ regardless of whether the block declared one. That made it impossible to declare a mutation-only sub-block (e.g. `user: { UPDATE: true }`) without also contributing a READ chain to the OR'd EXISTS subtree on the linked type. This adds an explicit opt-out: set `READ: false` on any block to suppress the implicit READ-chain emission while keeping the other action grants intact. Default behavior is unchanged (omit READ ⇒ READ emitted as before; `READ: true` ⇒ same). - `PermissionsBlock` type widens `READ?: boolean` (other actions stay `true`-only). - Generated per-model `${Model}Permissions` type widens `READ?: boolean` to match. - Both READ short-circuits switch to `block.READ !== false`. Unblocks consumer-side cleanup of redundant implicit READ chains where the same path is already covered by a flatter grant on the same role — shrinks the OR'd EXISTS subtree on heavily-joined types like User. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 32e41f3 commit fda01f5

3 files changed

Lines changed: 127 additions & 4 deletions

File tree

src/bin/gqm/permissions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const generatePermissionTypes = (models: Models) => {
4848
sourceFile.addStatements((writer) =>
4949
writer.write(`export type ${model.name}Permissions = `).inlineBlock(() => {
5050
for (const action of ACTIONS) {
51-
writer.writeLine(`${action}?: true,`);
51+
writer.writeLine(`${action}?: ${action === 'READ' ? 'boolean' : 'true'},`);
5252
}
5353
writer.writeLine(`WHERE?: ${model.name}Where,`);
5454
const relations = [...model.relations, ...model.reverseRelations];

src/permissions/generate.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ export const ACTIONS: PermissionAction[] = ['READ', 'CREATE', 'UPDATE', 'DELETE'
1010
*/
1111
export type PermissionsConfig = Record<string, true | Record<string, PermissionsBlock>>;
1212

13-
export type PermissionsBlock = Partial<Record<PermissionAction, true>> & {
13+
export type PermissionsBlock = Partial<Record<Exclude<PermissionAction, 'READ'>, true>> & {
14+
/**
15+
* READ is implicit by default (any block emits a READ chain). Set `READ: false`
16+
* on a block to opt out — useful when the same READ path is already covered by
17+
* a flatter grant on the same role and the block exists only to declare
18+
* mutation actions (UPDATE / DELETE / RESTORE / LINK / CREATE).
19+
*/
20+
READ?: boolean;
1421
WHERE?: Record<string, any>;
1522
RELATIONS?: Record<string, PermissionsBlock>;
1623
};
@@ -53,7 +60,7 @@ export const generatePermissions = (models: Models, config: PermissionsConfig) =
5360
if (key !== 'me' && !('WHERE' in block)) {
5461
rolePermissions[type] = {};
5562
for (const action of ACTIONS) {
56-
if (action === 'READ' || action in block) {
63+
if (action === 'READ' ? block.READ !== false : action in block) {
5764
rolePermissions[type][action] = true;
5865
}
5966
}
@@ -82,7 +89,7 @@ const addPermissions = (models: Models, permissions: RolePermissions, links: Per
8289
const model = models.getModel(type, 'entity');
8390

8491
for (const action of ACTIONS) {
85-
if (action === 'READ' || action in block) {
92+
if (action === 'READ' ? block.READ !== false : action in block) {
8693
if (!permissions[type]) {
8794
permissions[type] = {};
8895
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { type ModelDefinitions, Models } from '../../src/models';
2+
import { generatePermissions, type PermissionsConfig, type Permissions } from '../../src/permissions/generate';
3+
4+
const modelDefinitions: ModelDefinitions = [
5+
{ name: 'Role', kind: 'enum', values: ['ADMIN', 'EXPERT'] },
6+
{
7+
kind: 'entity',
8+
name: 'User',
9+
fields: [{ name: 'role', kind: 'enum', type: 'Role', nonNull: true }],
10+
},
11+
{
12+
kind: 'entity',
13+
name: 'Expert',
14+
fields: [
15+
{ name: 'user', kind: 'relation', type: 'User', toOne: true, reverse: 'experts' },
16+
],
17+
},
18+
];
19+
20+
const models = new Models(modelDefinitions);
21+
22+
const getRole = (permissions: Permissions, role: string) => {
23+
const rolePermissions = permissions[role];
24+
if (rolePermissions === true || rolePermissions === undefined) {
25+
throw new Error(`expected per-type permissions for role ${role}`);
26+
}
27+
return rolePermissions;
28+
};
29+
30+
describe('generatePermissions — implicit READ', () => {
31+
it('emits an implicit READ chain on a relation sub-block by default', () => {
32+
const config: PermissionsConfig = {
33+
WOP_ADMIN: {
34+
Expert: { RELATIONS: { user: { UPDATE: true } } },
35+
},
36+
};
37+
38+
const role = getRole(generatePermissions(models, config), 'WOP_ADMIN');
39+
40+
expect(role.User?.READ).toEqual([
41+
[
42+
{ type: 'Expert' },
43+
{ type: 'User', foreignKey: 'userId', reverse: true },
44+
],
45+
]);
46+
expect(role.User?.UPDATE).toEqual([
47+
[
48+
{ type: 'Expert' },
49+
{ type: 'User', foreignKey: 'userId', reverse: true },
50+
],
51+
]);
52+
});
53+
54+
it('skips the implicit READ chain when READ: false is set on a sub-block', () => {
55+
const config: PermissionsConfig = {
56+
WOP_ADMIN: {
57+
Expert: { RELATIONS: { user: { READ: false, UPDATE: true } } },
58+
},
59+
};
60+
61+
const role = getRole(generatePermissions(models, config), 'WOP_ADMIN');
62+
63+
expect(role.User?.READ).toBeUndefined();
64+
expect(role.User?.UPDATE).toEqual([
65+
[
66+
{ type: 'Expert' },
67+
{ type: 'User', foreignKey: 'userId', reverse: true },
68+
],
69+
]);
70+
});
71+
72+
it('skips the unconditional READ grant when READ: false is set on a top-level block', () => {
73+
const config: PermissionsConfig = {
74+
WOP_ADMIN: {
75+
User: { READ: false, UPDATE: true },
76+
},
77+
};
78+
79+
const role = getRole(generatePermissions(models, config), 'WOP_ADMIN');
80+
81+
expect(role.User?.READ).toBeUndefined();
82+
expect(role.User?.UPDATE).toBe(true);
83+
});
84+
85+
it('treats READ: true as equivalent to omitting READ', () => {
86+
const withExplicit = generatePermissions(models, {
87+
WOP_ADMIN: { Expert: { RELATIONS: { user: { READ: true, UPDATE: true } } } },
88+
});
89+
const withImplicit = generatePermissions(models, {
90+
WOP_ADMIN: { Expert: { RELATIONS: { user: { UPDATE: true } } } },
91+
});
92+
93+
expect(withExplicit).toEqual(withImplicit);
94+
});
95+
96+
it('READ: false on one sub-block does not suppress sibling READ chains on the same type', () => {
97+
const config: PermissionsConfig = {
98+
WOP_ADMIN: {
99+
User: { WHERE: { role: 'EXPERT' } },
100+
Expert: { RELATIONS: { user: { READ: false, UPDATE: true } } },
101+
},
102+
};
103+
104+
const role = getRole(generatePermissions(models, config), 'WOP_ADMIN');
105+
106+
expect(role.User?.READ).toEqual([
107+
[{ type: 'User', where: { role: 'EXPERT' } }],
108+
]);
109+
expect(role.User?.UPDATE).toEqual([
110+
[
111+
{ type: 'Expert' },
112+
{ type: 'User', foreignKey: 'userId', reverse: true },
113+
],
114+
]);
115+
});
116+
});

0 commit comments

Comments
 (0)