Skip to content

Commit b727956

Browse files
fix(permissions)!: an absent CRUD bit on a /me/permissions row denies New, Edit and Delete (#26273)
Fixes #26193 Clause-②: yes (narrowing) — the console no longer offers Create / Edit / Delete when `/me/permissions` serves an object row that omits the bit. That is exactly when the server refuses it, because the spec grants only on `=== true` (permission.zod.ts:196, :218). The anonymous default and the no-provider fail-open are unchanged. Metadata-protocol: none ## What was wrong The console offered New on object lists, and Edit / Delete on records, to users who hold no Create, Edit or Delete grant, for example an auditor (read + View All) or a read-only finance user. They filled in the form and the server refused the save with 403. The input that answered "allowed" was the permission provider's polarity. `MePermissionsProvider.check` (behind `usePermissions().can`, which `resolveAffordance('listNew')` asks) and `checkField`'s object-level fallback read a served object row with `bit !== false`. The server serves only the bits a set grants. Its plain-wildcard coverage (`materializePlainWildcardCoverage` in core `effective-object-permissions.ts`) copies only `true` bits, so a read-only `'*': { allowRead: true }` set is served as `{ allowRead: true }`, and an auditor's row as `{ allowRead: true, viewAllRecords: true }`. The absent `allowCreate` read as allowed on the client and as no grant on the server, which decides through the spec's `objectPermissionGrants` (`=== true`, plus the super-user bypasses). The card's two suspected causes are ruled out on `origin/main`, as the claim's premise says. (1) Loading: the provider renders only its fallback until data arrives, and `check` fails closed without data. (2) View All cascading: `check` maps `create` to `allowCreate`, never to `viewAllRecords`. ## The change `ui/packages/permissions/src/MePermissionsProvider.tsx`: - `check` reads a served row through `objectPermissionGrants` from `@objectstack/spec/security`, the same fold the server's `PermissionEvaluator.checkObjectPermission` decides from. A bit grants only when it is `true`. Modify All Records also grants edit and delete, and View All Records also grants read. Nothing grants create except `allowCreate: true`. - `checkField`'s object-level fallback (no field entry) asks the same fold for `allowRead`, `allowCreate` or `allowEdit`. - `check` matches the object key case-insensitively first, as `checkField` already did. - Unchanged: an anonymous session (`authenticated !== true`) keeps the permissive default, a tree with no provider keeps `can: () => true`, an object the response does not mention is still denied for a signed-in user, and explicit field-level entries answer as before. ## Reproduction (before / after) Measured at `ccd94f7b` (the fix plus both pin files). The provider at `BASE` `df8aac41f2` was restored into the tree (on-disk hash `9ab00a25…` equals the base blob), then restored to `HEAD` (hash `6cf0d345…` equals the HEAD blob) under a trap. - **Before:** `Tests 15 failed | 57 passed (72)`. The failures include `listNew` allowed for both the read-only `'*'` fixture and the auditor fixture, `recordEdit` / `recordDelete` allowed for both, `check` allowing every write action, `checkField` allowing create and write, and the `affordanceGrantMap-12082` auditor shape failing all four of its checks. - **After:** `Test Files 3 passed (3) · Tests 72 passed (72)`. ## Tests - New `ui/packages/permissions/src/__tests__/MePermissionsProvider.absentBitDenies-26193.test.tsx`: read-only `'*'` and auditor fixtures (`check` and `can` deny create, update, delete and import with `denied-by-permission-set`, read and export stay allowed, and `checkField` denies create and write). Also the spec fold (Modify All grants edit and delete but not create; a Create-only row grants only Create), case-insensitive keys, and controls (explicit `true`, anonymous, no provider). - New `ui/packages/plugin-form/src/listNewAbsentCreateBit-26193.test.tsx`: the `resolveAffordance('listNew')` verdict, plus `recordEdit` and `recordDelete`, through the real provider and the real map, on both fixtures. Controls: a user holding Create still gets New, and with no provider New stays fail-open. - `ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx`: a fifth grant shape, `auditor (write bits absent)`, so every map row and every field row is held under it. **Existing fixtures that needed explicit bits: none.** At `2dcfe6e5`, all 131 test files that mount `MePermissionsProvider` or read `effectiveObjects` passed unchanged (`Test Files 131 passed (131) · Tests 1666 passed (1666)`), and so did the 45 further test files that reach `/me/permissions` through other paths (`Test Files 45 passed (45) · Tests 515 passed (515)`). Every existing fixture already spelled its bits out. Local runs, all under `os-verify-lock.sh`: - `pnpm --filter @object-ui/permissions type-check` and `pnpm --filter @object-ui/plugin-form type-check`: exit 0. - At `c95c1986`: `vitest run packages/permissions/ …listNewAbsentCreateBit-26193 …affordanceGrantMap-12082`: `Test Files 15 passed (15) · Tests 212 passed (212)`. The commits after it are the manifest record and a merge of `origin/main`, which touches none of these files. - `eslint` over the four changed source and test files: exit 0. - `node scripts/gen-sdui-manifest-node.mjs`: 1 of 27 records re-recorded (`permissions`), artefact unchanged. `node scripts/check-sdui-manifest.mjs`: exit 0. ## Gates (head `1c888a6e`) `node scripts/pm/dispatch-gates.mjs --commands` derived 47 families. 46 exited 0. `pnpm check:type-check-debt` exited 3 (PREREQUISITE NOT MET: its closure build never got a turn on the shared verify lock), so it is **NOT MEASURED** locally, and CI runs it. `--ran` reconciliation: `✓ 47 derived famil(ies) accounted for — 46 run, 1 NOT-MEASURED`. Dogfood battery (owed for the `(narrowing)` arm): **NOT MEASURED** when this PR opened, because the shared lock's queue had not reached it. The reading goes in the card's `os-dev-report` comment. ## Acceptance notes - `check('export')` still maps to `allowRead`, while the server's export grant is `allowExport ∧ read` (the spec fold's `allowExport` cell). This is dormant: no production reader in `ui/` calls `can(…, 'export')` (the list and grid Export buttons read the effective API operation set). Noted, not filed. Carrier: none. - `current_user.can()` in `@object-ui/app-shell` reads the response's `objects` map through `@objectstack/formula`'s `toEvalPermissions`, not through `check`, and this PR does not touch it. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef5d0a4 commit b727956

6 files changed

Lines changed: 294 additions & 17 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/permissions': patch
3+
---
4+
5+
fix(permissions)!: the console stops offering New, Edit and Delete to a user whose `/me/permissions` row omits the Create, Edit or Delete bit, such as an auditor or a read-only finance user
6+
7+
Clause-②: yes (narrowing) — the console no longer offers Create / Edit / Delete when `/me/permissions` serves an object row that omits the bit. That is exactly when the server refuses it, because the spec grants only on `=== true` (permission.zod.ts:196, :218). The anonymous default and the no-provider fail-open are unchanged.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) A console permission verdict: no key of any metadata schema is added, removed, renamed or re-shaped, and no export, type or error code changes. The client now reads a served row with the spec's own objectPermissionGrants, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
**BREAKING** (a narrowing of what the console offers) in `MePermissionsProvider` (`@object-ui/permissions`).
13+
14+
- **Before.** `check` (behind `usePermissions().can`) and `checkField`'s object-level fallback read a served object row with `bit !== false`, so a bit the row left out counted as granted. The server serves only the bits a set grants: a read-only set `'*': { allowRead: true }` is served as `{ allowRead: true }` on each object it covers, and an auditor's row as `{ allowRead: true, viewAllRecords: true }`. Those users were offered New on object lists, and Edit and Delete on records. They filled in the form, and the server refused the save with `403`.
15+
- **Now.** A served row grants an operation only through `objectPermissionGrants` from `@objectstack/spec/security`, the fold the server's permission evaluator decides from. A bit grants only when it is `true`. Modify All Records also grants edit, delete and transfer, and View All Records also grants read. Nothing grants create but `allowCreate: true`. `check` also matches the object key case-insensitively, as `checkField` already did.
16+
- **Unchanged.** An anonymous session (`authenticated` not `true`) keeps its permissive default. A tree with no permission provider keeps the fail-open `can`. An object the response does not mention is still denied for a signed-in user. A row that carries the bit as `true` grants exactly as before.
17+
- **Who sees it.** A user whose permission sets grant read but not Create, Edit or Delete on an object. The console now hides or disables those actions, matching the server's answer. A user whose sets grant the bit sees no change.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"package":"ui/packages/permissions","sources":11,"sha256":"098953c65195fea2549322059e6b1e823aa18c69e1c2452393e3294273376aeb","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
1+
{"package":"ui/packages/permissions","sources":11,"sha256":"ac8f73ebbacf1fcb6939ac59bf5969403e111303de0e93febbaaeebbe9c1edf2","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}

‎ui/packages/permissions/src/MePermissionsProvider.tsx‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import type {
1919
PermissionCheckResult,
2020
FieldLevelPermission,
2121
} from '@object-ui/types';
22-
import { objectPermissionGrants } from '@objectstack/spec/security';
22+
import { objectPermissionGrants, type ObjectPermissionVerbTarget } from '@objectstack/spec/security';
2323
import { SystemFieldName } from '@objectstack/spec/system';
2424
import { PermCtx, type PermissionContextValue } from './PermissionContext.js';
2525
import { createDiscardProofCache } from './discardProofCache.js';
@@ -36,7 +36,11 @@ export interface MePermissionsResponse {
3636
permissionSets: string[];
3737
/** [ADR-0066] System capabilities (union of permission-set systemPermissions): manage_users, setup.access, … */
3838
systemPermissions?: string[];
39-
/** object-level perms: { "*": {...}, "account": {...} } */
39+
/**
40+
* object-level perms: { "*": {...}, "account": {...} }. A row grants only
41+
* what the spec's `objectPermissionGrants` reads from it — a bit the row
42+
* omits is NOT granted, exactly as the server enforces it.
43+
*/
4044
objects: Record<string, {
4145
allowCreate?: boolean;
4246
allowRead?: boolean;
@@ -356,18 +360,24 @@ export function MePermissionsProvider({
356360
// would brick public forms.
357361
return data.authenticated !== true;
358362
}
359-
if (action === 'read') return objPerm.allowRead !== false;
360-
return action === 'create'
361-
? objPerm.allowCreate !== false
362-
: objPerm.allowEdit !== false;
363+
// An object row grants an operation only through the spec's own fold —
364+
// the one the server's evaluator answers from: a bit that is not
365+
// `=== true` is no grant, so a row the server served WITHOUT the bit
366+
// (a read-only wildcard copies only its `true` bits) reads as denied.
367+
return objectPermissionGrants(
368+
objPerm,
369+
action === 'read' ? 'allowRead' : action === 'create' ? 'allowCreate' : 'allowEdit',
370+
);
363371
},
364372
);
365373

366374
const check = CHECK([dataKey], () =>
367375
(object: string, action: PermissionAction): PermissionCheckResult => {
368376
if (!data) return { allowed: false, reason: 'permissions-loading' };
369-
const objPerm = data.objects?.[object] ?? data.objects?.['*'];
370-
const map: Record<string, keyof NonNullable<typeof objPerm>> = {
377+
// Same key normalization as checkField: the backend keys objects lowercase.
378+
const objKey = (object ?? '').toLowerCase();
379+
const objPerm = data.objects?.[objKey] ?? data.objects?.[object] ?? data.objects?.['*'];
380+
const map: Record<string, ObjectPermissionVerbTarget> = {
371381
read: 'allowRead',
372382
view: 'allowRead',
373383
create: 'allowCreate',
@@ -382,8 +392,14 @@ export function MePermissionsProvider({
382392
export: 'allowRead',
383393
};
384394
const k = map[action as string] ?? 'allowRead';
385-
// Same authentication-gated default as checkField (objectstack-ai/objectstack#2926 ④).
386-
const allowed = objPerm ? (objPerm as any)[k] !== false : data.authenticated !== true;
395+
// A served row grants only through the spec's fold (`=== true`, plus the
396+
// super-user bypasses), exactly as the server's evaluator decides: the
397+
// server's plain-wildcard coverage serves a read-only set as
398+
// `{ allowRead: true }` with no `allowCreate`, and reading that absent bit
399+
// as allowed offered New / Edit / Delete the server refuses with 403.
400+
// No row at all keeps the authentication-gated default of checkField
401+
// (objectstack-ai/objectstack#2926 ④).
402+
const allowed = objPerm ? objectPermissionGrants(objPerm, k) : data.authenticated !== true;
387403
return { allowed, reason: allowed ? undefined : 'denied-by-permission-set' };
388404
},
389405
);
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* An object row's CRUD bit that `/me/permissions` leaves OUT is not a grant.
11+
*
12+
* The spec's `objectPermissionGrants` (`@objectstack/spec/security`) is the one
13+
* fold the server's `PermissionEvaluator.checkObjectPermission` answers from: a
14+
* bit grants only when it is `=== true` (plus the super-user bypasses), and "an
15+
* ABSENT entry is `false`". The server's plain-wildcard coverage copies only
16+
* the `true` bits onto each object it covers, so a read-only set
17+
* `'*': { allowRead: true }` is served as `{ allowRead: true }` with no
18+
* `allowCreate`, and an auditor (`allowRead` + `viewAllRecords`) is served with
19+
* no write bit at all. The provider used to read such a row with
20+
* `bit !== false`, so it offered New / Edit / Delete that the server then
21+
* refused with 403.
22+
*
23+
* Unchanged, and pinned here as controls: an explicit `true` still grants, an
24+
* anonymous session keeps its permissive default, and a tree with no provider
25+
* keeps the fail-open `can`.
26+
*/
27+
import { describe, it, expect, afterEach } from 'vitest';
28+
import { render, cleanup } from '@testing-library/react';
29+
import React from 'react';
30+
import { MePermissionsProvider, type MePermissionsResponse } from '../MePermissionsProvider';
31+
import { usePermissions } from '../usePermissions';
32+
33+
afterEach(cleanup);
34+
35+
const envelope = (objects: MePermissionsResponse['objects']): MePermissionsResponse => ({
36+
authenticated: true,
37+
userId: 'u-auditor',
38+
tenantId: null,
39+
roles: ['auditor'],
40+
permissionSets: ['auditor'],
41+
objects,
42+
fields: {},
43+
});
44+
45+
/** A read-only permission set, as the server serves it: the wildcard's `true` bits only. */
46+
const READ_ONLY_WILDCARD = envelope({ '*': { allowRead: true } });
47+
48+
/** An auditor set, per object: read plus View All, no write bit served. */
49+
const AUDITOR = envelope({
50+
account: { allowRead: true, viewAllRecords: true },
51+
invoice: { allowRead: true, viewAllRecords: true },
52+
});
53+
54+
function principalUnder(perms: MePermissionsResponse | null): ReturnType<typeof usePermissions> {
55+
let captured: ReturnType<typeof usePermissions> | null = null;
56+
function Probe() {
57+
captured = usePermissions();
58+
return null;
59+
}
60+
render(perms ? (
61+
<MePermissionsProvider initialPermissions={perms}>
62+
<Probe />
63+
</MePermissionsProvider>
64+
) : (
65+
<Probe />
66+
));
67+
if (!captured) throw new Error('the probe did not render');
68+
return captured;
69+
}
70+
71+
const WRITE_ACTIONS = ['create', 'update', 'delete', 'import'] as const;
72+
const READ_ACTIONS = ['read', 'export'] as const;
73+
74+
describe.each([
75+
{ shape: "read-only '*'", perms: READ_ONLY_WILDCARD },
76+
{ shape: 'auditor (allowRead + viewAllRecords)', perms: AUDITOR },
77+
])('an absent CRUD bit denies — $shape', ({ perms }) => {
78+
it('check: every write action is denied with the permission-set reason', () => {
79+
const p = principalUnder(perms);
80+
for (const action of WRITE_ACTIONS) {
81+
expect(p.check('account', action), action).toEqual({ allowed: false, reason: 'denied-by-permission-set' });
82+
expect(p.can('account', action), action).toBe(false);
83+
}
84+
});
85+
86+
it('check: the read actions stay allowed (CONTROL — the row is honoured, not ignored)', () => {
87+
const p = principalUnder(perms);
88+
for (const action of READ_ACTIONS) {
89+
expect(p.can('account', action), action).toBe(true);
90+
}
91+
});
92+
93+
it("checkField's object-level fallback: create and write denied, read allowed", () => {
94+
const p = principalUnder(perms);
95+
expect(p.checkField('account', 'name', 'create')).toBe(false);
96+
expect(p.checkField('account', 'name', 'write')).toBe(false);
97+
expect(p.checkField('account', 'name', 'read')).toBe(true);
98+
});
99+
});
100+
101+
describe("a served row is read through the spec's fold", () => {
102+
it('a row granting only Create grants Create and nothing else', () => {
103+
const p = principalUnder(envelope({ account: { allowRead: true, allowCreate: true } }));
104+
expect(p.can('account', 'create')).toBe(true);
105+
expect(p.can('account', 'update')).toBe(false);
106+
expect(p.can('account', 'delete')).toBe(false);
107+
});
108+
109+
it('Modify All widens edit and delete, never create', () => {
110+
const p = principalUnder(envelope({ '*': { modifyAllRecords: true } }));
111+
expect(p.can('account', 'read')).toBe(true);
112+
expect(p.can('account', 'update')).toBe(true);
113+
expect(p.can('account', 'delete')).toBe(true);
114+
expect(p.can('account', 'create')).toBe(false);
115+
expect(p.checkField('account', 'name', 'write')).toBe(true);
116+
expect(p.checkField('account', 'name', 'create')).toBe(false);
117+
});
118+
119+
it('an object key is matched case-insensitively, as checkField already matches it', () => {
120+
const p = principalUnder(envelope({ '*': { allowRead: true }, account: { allowRead: true, allowCreate: true } }));
121+
expect(p.can('Account', 'create')).toBe(true);
122+
expect(p.can('Account', 'update')).toBe(false);
123+
});
124+
});
125+
126+
describe('what an absent bit does not change (controls)', () => {
127+
it('an explicit `true` bit still grants', () => {
128+
const p = principalUnder(envelope({ '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }));
129+
for (const action of WRITE_ACTIONS) expect(p.can('account', action), action).toBe(true);
130+
expect(p.checkField('account', 'name', 'create')).toBe(true);
131+
expect(p.checkField('account', 'name', 'write')).toBe(true);
132+
});
133+
134+
it('an anonymous session keeps the permissive default', () => {
135+
const p = principalUnder({ authenticated: false } as MePermissionsResponse);
136+
for (const action of WRITE_ACTIONS) expect(p.can('showcase_inquiry', action), action).toBe(true);
137+
expect(p.checkField('showcase_inquiry', 'message', 'create')).toBe(true);
138+
});
139+
140+
it('with no provider mounted, can() stays fail-open', () => {
141+
const p = principalUnder(null);
142+
expect(p.isLoaded).toBe(false);
143+
for (const action of WRITE_ACTIONS) expect(p.can('account', action), action).toBe(true);
144+
});
145+
});

‎ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,18 @@
2222
* 1. **The row set equals this file's own expectation table.** `EXPECTED_GRANT`
2323
* below is written here, by hand, NOT derived from the map: a row added to
2424
* the map without an expectation, or one dropped from it, turns this red.
25-
* 2. **Every row × four grant shapes** — create-only, edit-only, read-only and
26-
* full — through the REAL `MePermissionsProvider`: each affordance shows
25+
* 2. **Every row × five grant shapes** — create-only, edit-only, read-only,
26+
* full and an auditor's row that omits every write bit — through the REAL `MePermissionsProvider`: each affordance shows
2727
* exactly when its grant allows it. A row naming the wrong grant (the p1
2828
* defect was a create affordance reading the edit grant) fails at least two
2929
* shapes.
30-
* 3. **The field question** of every field row, over the same four shapes and
30+
* 3. **The field question** of every field row, over the same five shapes and
3131
* over explicit field-level entries: the create question follows the
3232
* server's insert rule (no entry → `allowCreate`), the edit question its
3333
* update rule (no entry → `allowEdit`), and an explicit entry answers both
3434
* alike.
3535
* 4. **The form reader itself** (`gateFormFields`, `closedFormAffordance`) over
36-
* the four shapes, both modes — the p1 card's reader, read through the map.
36+
* the five shapes, both modes — the p1 card's reader, read through the map.
3737
* 5. **Fail-open**: with no permission provider every row and every field
3838
* question reads open, as the `fieldWriteGate.ts` docblock states.
3939
* 6. **The census**: no console source file outside the map reads a CRUD
@@ -148,7 +148,7 @@ const EXPECTED_FIELD_GRANT: Record<string, Grant> = {
148148

149149
const OBJECT = 'pin_object';
150150

151-
/** The four grant shapes, as `/me/permissions` carries them, and the verbs each holds. */
151+
/** The five grant shapes, as `/me/permissions` carries them, and the verbs each holds. */
152152
const SHAPES: Array<{ shape: string; bits: Record<string, boolean>; holds: ReadonlySet<Grant> }> = [
153153
{
154154
shape: 'create-only',
@@ -170,6 +170,14 @@ const SHAPES: Array<{ shape: string; bits: Record<string, boolean>; holds: Reado
170170
bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true },
171171
holds: new Set<Grant>(['create', 'update', 'delete']),
172172
},
173+
{
174+
// The server serves only the bits a set grants: an auditor's row carries
175+
// no write bit at all, and an absent bit is no grant (spec
176+
// `objectPermissionGrants`), so it must read exactly like read-only.
177+
shape: 'auditor (write bits absent)',
178+
bits: { allowRead: true, viewAllRecords: true },
179+
holds: new Set<Grant>(),
180+
},
173181
];
174182

175183
const envelope = (bits: Record<string, boolean>, fields: MePermissionsResponse['fields'] = {}): MePermissionsResponse => ({
@@ -438,7 +446,7 @@ describe('the owner field: an update that writes it needs the transfer grant (ob
438446
// file must hold none, unless it is named in `OUT_OF_FAMILY` with the reason it
439447
// is not an affordance-to-grant reader. READ gates (`can(x, 'read')`, the
440448
// route, navigation and related-list visibility gates) are not matched: every
441-
// one of the four grant shapes holds read, so they are a different question —
449+
// one of the five grant shapes holds read, so they are a different question —
442450
// may the caller SEE — and not this family's.
443451
// ---------------------------------------------------------------------------
444452

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The `resolveAffordance('listNew')` verdict for a user with no Create grant.
11+
*
12+
* An auditor (read + View All) or a read-only finance user was offered New on
13+
* an object list, filled the form, and learned from the server's 403 that the
14+
* save could never land. The input that answered "allowed" was the permission
15+
* provider's polarity: `/me/permissions` serves only the bits a set grants (a
16+
* read-only `'*'` wildcard is served as `{ allowRead: true }`, an auditor's row
17+
* as `{ allowRead: true, viewAllRecords: true }`), and the provider read the
18+
* ABSENT `allowCreate` as allowed. The spec's `objectPermissionGrants`, which
19+
* the server's evaluator decides from, reads it as no grant.
20+
*
21+
* Pinned through the REAL `MePermissionsProvider` and the REAL map, for New and
22+
* for the record page's Edit and Delete on the same two fixtures, with the
23+
* controls that must not move: a user holding Create still gets New, and with
24+
* no provider mounted the verdict stays fail-open.
25+
*/
26+
import { describe, it, expect, afterEach } from 'vitest';
27+
import { render, cleanup } from '@testing-library/react';
28+
import React from 'react';
29+
import { resolveAffordance, type ConsoleAffordance } from '@object-ui/core';
30+
import { MePermissionsProvider, usePermissions, type MePermissionsResponse } from '@object-ui/permissions';
31+
32+
afterEach(cleanup);
33+
34+
const OBJECT = 'invoice';
35+
36+
const envelope = (objects: MePermissionsResponse['objects']): MePermissionsResponse => ({
37+
authenticated: true,
38+
userId: 'u-auditor',
39+
tenantId: null,
40+
roles: ['auditor'],
41+
permissionSets: ['auditor'],
42+
objects,
43+
fields: {},
44+
});
45+
46+
function principalUnder(perms: MePermissionsResponse | null): ReturnType<typeof usePermissions> {
47+
let captured: ReturnType<typeof usePermissions> | null = null;
48+
function Probe() {
49+
captured = usePermissions();
50+
return null;
51+
}
52+
render(perms ? (
53+
<MePermissionsProvider initialPermissions={perms}>
54+
<Probe />
55+
</MePermissionsProvider>
56+
) : (
57+
<Probe />
58+
));
59+
if (!captured) throw new Error('the probe did not render');
60+
return captured;
61+
}
62+
63+
const verdict = (affordance: ConsoleAffordance, perms: ReturnType<typeof usePermissions>) =>
64+
resolveAffordance(affordance, { objectSchema: null, objectName: OBJECT, perms }).allowed;
65+
66+
describe.each([
67+
{ shape: "read-only '*' wildcard", perms: envelope({ '*': { allowRead: true } }) },
68+
{ shape: 'auditor (allowRead + viewAllRecords per object)', perms: envelope({ [OBJECT]: { allowRead: true, viewAllRecords: true } }) },
69+
])('a user without Create — $shape', ({ perms }) => {
70+
it('is not offered New on the object list', () => {
71+
expect(verdict('listNew', principalUnder(perms))).toBe(false);
72+
});
73+
74+
it("is not offered the record page's Edit or Delete either", () => {
75+
const p = principalUnder(perms);
76+
expect(verdict('recordEdit', p)).toBe(false);
77+
expect(verdict('recordDelete', p)).toBe(false);
78+
});
79+
});
80+
81+
describe('controls', () => {
82+
it('a user holding Create is still offered New', () => {
83+
expect(verdict('listNew', principalUnder(envelope({ '*': { allowRead: true, allowCreate: true } })))).toBe(true);
84+
cleanup();
85+
expect(verdict('listNew', principalUnder(envelope({ [OBJECT]: { allowRead: true, allowCreate: true } })))).toBe(true);
86+
});
87+
88+
it('with no permission provider mounted, New stays fail-open', () => {
89+
expect(verdict('listNew', principalUnder(null))).toBe(true);
90+
});
91+
});

0 commit comments

Comments
 (0)