Skip to content

Commit af06da7

Browse files
committed
test(dogfood): a delegated_admin is offered Invite User on sys_user; changeset
The org-admin reach dogfood pinned `sys_user.invite_user` as withheld below tenant-admin grade by the field mask. It is now served to every grade, and a new case shows, on a real showcase boot, that a delegated_admin and a member are both denied `sys_user.role` but served `sys_member.role`, that the delegated_admin is offered the action, and that the member is not because the served reach predicate excludes its grade. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
1 parent f858694 commit af06da7

2 files changed

Lines changed: 50 additions & 14 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-core': minor
3+
'@objectstack/rest': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
The object-schema field mask (ADR-0106 D1) judges an action param that names another object's field through `objectOverride` against that object, not the one being served.
8+
9+
Clause-②: yes (widening)
10+
11+
**What a user saw.** A `delegated_admin` may invite members, and the invite door admits them, but `GET /meta/object/sys_user` served that principal no `invite_user` action. The action's `role` param is `{ field: 'role', objectOverride: 'sys_member' }`: it names `sys_member.role`. The mask read every param's `field` as a field of the served object, so a caller denied `sys_user.role` lost the whole action. A plain `member` lost it the same way. The member is now served the action too, and still not offered it: the action's `requiresMembershipReach` predicate excludes the member grade.
12+
13+
**The rule.** A param whose `objectOverride` names another object reads that object's field. It is judged against the caller's readable fields on that object, and it is not a reference to the served object's fields. The action is still dropped when the caller cannot read the field there, and when that object's readable fields cannot be determined (no answer from the security service, a security service that throws, or an object that does not exist). Nothing about the other object is served on a guess. The rest of the param is still read against the served object: `visible`, an option's `visibleWhen`, `defaultValue`, and an explicit `name` that differs from `field`. A `name` that only repeats `field` is read as that field. With `defaultFromRow`, the param also reads `field` from the served object's row, so `field` is judged against the served object too. An exempt caller (platform admin, `isSystem`) is served the whole schema, as before.
14+
15+
**The API (`@objectstack/metadata-core`), additive.**
16+
17+
- `relateObjectSchemaMaskPosture(posture, ...documents)` completes a `project` posture for the documents it is about to mask. It reads the caller's readable fields on each other object their action params name through `objectOverride`. It runs after the fetch, because only the document names those objects. It returns every other posture, and any document with no such param, unchanged, and it never throws.
18+
- The `project` member of `ObjectSchemaMaskPosture` gains two optional fields. `relate` asks the posture's question (same caller, same security service) about another object. `resolveObjectSchemaMaskPosture` sets it. `related` holds the answers. A `project` posture built without `related` gets no answers, so `applyObjectSchemaMask` drops every action with such a param.
19+
- `applyObjectSchemaMask` folds each related read it withholds into the fingerprint, written as `object.field`. Two callers who are denied the same fields on the served object but differ on the other object get different validators. An unrestricted caller's ETag is unchanged.
20+
- The shared contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) gains two actions whose params read `contact` fields through `objectOverride`. The contract's projection cases now require the readable one to be served and the denied one to be dropped. An exit that never relates its posture fails the contract by name.
21+
22+
**Every exit relates its posture (`@objectstack/rest`, `@objectstack/runtime`).** These exits relate the posture after the fetch, before the projection: the shared item, layered and list chains, `RestServer`'s cached read and published read, and the runtime dispatcher's mask. The `/meta` diff route masks `fields` only and needs no relate step.
23+
24+
**Measured on a showcase boot.** We read every object schema (78 objects, by-name read and list read) as five principals: a platform admin, an org owner, an admin, a `delegated_admin` and a `member`. Before and after this change, the only served action that moved is `sys_user.invite_user`, which is now served to the `delegated_admin` and the `member`. This repository has two authored params with `objectOverride`: `sys_user.invite_user`'s `role` and `sys_member.invite_user`'s `email` (on `sys_invitation`). The second was served to all five principals before and after.

‎packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts‎

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ describe('org-admin affordances follow the membership grade (served metadata ×
115115
const tokens = {} as Record<Grade, string>;
116116
const sessionUser = {} as Record<Grade, Record<string, unknown>>;
117117
const served = {} as Record<Grade, Map<string, ServedAction>>;
118+
/** The field names each grade is served per object — what the metadata-plane field mask left. */
119+
const servedFields = {} as Record<Grade, Record<string, string[]>>;
118120
let features: Record<string, unknown>;
119121
/** A representative row per object — the binding a row action is evaluated against. */
120122
const rowOf = {} as Record<string, Record<string, unknown>>;
@@ -181,11 +183,13 @@ describe('org-admin affordances follow the membership grade (served metadata ×
181183
expect(session.status).toBe(200);
182184
sessionUser[grade] = ((await session.json()) as { user: Record<string, unknown> }).user;
183185
served[grade] = new Map();
186+
servedFields[grade] = {};
184187
for (const object of OBJECTS) {
185188
const meta = await stack.apiAs(tokens[grade], 'GET', `/meta/object/${object}`);
186189
expect(meta.status, `${grade} reads /meta/object/${object}`).toBe(200);
187-
const body = (await meta.json()) as { item?: { actions?: ServedAction[] } };
190+
const body = (await meta.json()) as { item?: { actions?: ServedAction[]; fields?: Record<string, unknown> } };
188191
for (const action of body.item?.actions ?? []) served[grade].set(`${object}.${action.name}`, action);
192+
servedFields[grade][object] = Object.keys(body.item?.fields ?? {});
189193
}
190194
}
191195
}, 240_000);
@@ -224,16 +228,6 @@ describe('org-admin affordances follow the membership grade (served metadata ×
224228
return gateAdmits(grade, site);
225229
};
226230

227-
/**
228-
* The one site the metadata-plane field mask (ADR-0106) withholds below
229-
* tenant-admin grade: `sys_user.invite_user`'s `role` param names
230-
* `sys_user.role`, a field those callers cannot read, so the whole action is
231-
* dropped from their `/meta/object/sys_user` — independently of this gate.
232-
* Every other site must be served to every grade, or a verdict below would be
233-
* the mask's rather than the gate's.
234-
*/
235-
const MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user'];
236-
237231
it('the session face carries each grade under its projected name, and no other grade', () => {
238232
// The capability half of every composed predicate is ON here, so each
239233
// verdict below is decided by the grade term alone.
@@ -251,12 +245,30 @@ describe('org-admin affordances follow the membership grade (served metadata ×
251245
expect(ALL.filter((site) => gateAdmits(grade, site))).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site)));
252246
});
253247

248+
// Every site is served to every grade, so each verdict below is the gate's
249+
// rather than the metadata-plane field mask's.
254250
it.each(Object.keys(EXPECTED) as Grade[])('%s is offered, on its own served metadata, exactly those it is served', (grade) => {
255251
const unserved = ALL.filter((site) => !served[grade].has(site));
256-
const tenantAdmin = grade === 'owner' || grade === 'admin';
257-
expect(unserved).toEqual(tenantAdmin ? [] : MASKED_BELOW_TENANT_ADMIN);
252+
expect(unserved).toEqual([]);
258253
const shown = ALL.filter((site) => offered(grade, site));
259-
expect(shown).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site) && !unserved.includes(site)));
254+
expect(shown).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site)));
255+
});
256+
257+
it('[#21884] a delegated_admin is offered Invite User on sys_user; a plain member is not — by the reach gate, not the field mask', () => {
258+
// `invite_user`'s `role` param names `sys_member.role` through
259+
// `objectOverride`. Neither grade is served `sys_user.role`, so a mask that
260+
// read the param as THIS object's field withheld the action from both. Both
261+
// ARE served `sys_member.role`, the field the param actually names.
262+
for (const grade of ['delegated_admin', 'member'] as const) {
263+
expect(servedFields[grade].sys_user, `${grade} is not served sys_user.role`).not.toContain('role');
264+
expect(servedFields[grade].sys_member, `${grade} is served sys_member.role`).toContain('role');
265+
expect(served[grade].has('sys_user.invite_user'), `${grade} is served sys_user.invite_user`).toBe(true);
266+
}
267+
expect(offered('delegated_admin', 'sys_user.invite_user')).toBe(true);
268+
// The member is served the same action and is still not offered it: the
269+
// served `requiresMembershipReach` predicate excludes its grade.
270+
expect(gateAdmits('member', 'sys_user.invite_user')).toBe(false);
271+
expect(offered('member', 'sys_user.invite_user')).toBe(false);
260272
});
261273

262274
it('a plain member sees none of them on the member, invitation and team lists', () => {

0 commit comments

Comments
 (0)