Skip to content

Commit 77a94d8

Browse files
fix(platform-objects): offer Add Member only to a platform admin (#22064)
Fixes #21886 Clause-②: no ## What changes `sys_member.add_member` ("Add Member" on an organization's member list) is now offered only to a platform administrator. That is the one standing its endpoint admits. - **Before:** the action was gated on the `organization` feature alone, so every member of the organization saw it: plain members, admins and owners. `POST /api/v1/auth/organization/add-member` then answered each of them 403 `PERMISSION_DENIED`, because the mount runs the ADR-0068 platform-admin gate before anything else. - **After:** the action declares `visible: 'current_user.isPlatformAdmin == true'`, the predicate the maintainer's ruling A-lite fixed (record 6019378035). ADR-0068 D4 names that predicate, and since PR #22018 `EvalUserSchema.isPlatformAdmin` describes it as the PLATFORM_ADMIN standing of ADR-0095 D3. `requiresFeature: 'organization'` composes onto it at parse time, so the served predicate is `(current_user.isPlatformAdmin == true) && features.organization != false`. The door and the callers it admits are unchanged. No key, export or parameter is added, and the label is unchanged. That makes `Clause-②: no`, and the changeset is a `@objectstack/platform-objects` patch. **File surface, as the claim declared it.** The producer is `packages/platform-objects/src/identity/sys-member.object.ts`. The other files are the lowering-matrix pin in `packages/platform-objects/src/platform-objects.test.ts`, one dogfood case in `packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts`, and `.changeset/21886-add-member-platform-admin-visibility.md`. There is no `packages/spec`, `plugin-auth` or `@objectstack/formula` edit. ## The dispatch's hypotheses, measured at `56c88446ea` - **H1: confirmed. The two keys compose with AND, the authored term first.** `lowerRequiresFeature` (`packages/spec/src/kernel/public-auth-features.ts:352-419`) turns an existing CEL `visible` into `(existing) && gate` (`:415-418`). The first-party precedent that carries both keys is `sys_user.enable_two_factor` (`sys-user.object.ts:534-535`), pinned at `platform-objects.test.ts` as `(...) && features.twoFactor == true`. - **H2: confirmed.** The served `visible` is exactly `(current_user.isPlatformAdmin == true) && features.organization != false`. This was read from the parsed `SysMember` object in the matrix test, and from the served `/meta/object/sys_member` on a real boot through the dogfood case. `EvalUserSchema.isPlatformAdmin` (`eval-user.zod.ts:225-241`) now describes the ADR-0095 D3 standing. - **H3: confirmed, with one correction.** - The console binds `current_user` through `extra`. At the current objectui pin `a58626c88d`: - `fieldRules.ts:284` passes the scope as `extra`; - `ExpressionProvider.tsx:190` builds one subject under `current_user`, `user`, `ctx.user` and `os.user`, with `features` beside it; - `expressionUser.ts:176` forwards `isPlatformAdmin` from the session. - The session value is `grants.posture === 'PLATFORM_ADMIN'` (plugin-auth `auth-manager.ts:4083`, emitted at `:4107`). - Under `user:`, `@objectstack/formula` re-derives it from `positions` (`stdlib.ts:417-430` through `createEvalUser`). Under `extra`, the bag is merged verbatim (`stdlib.ts:462`). - **The correction:** the file's existing `owner` principal is the harness's seeded dev admin, and on this boot its served session carries `isPlatformAdmin: true`. It is the platform-admin principal, reused. Nothing on this boot declares `OS_PLATFORM_OWNER_EMAIL`: it is unset in the environment, absent from the dogfood package and the showcase config, and the harness sets it only under a walled posture (`packages/verify/src/harness.ts:481-497`). By `EvalUserSchema`'s definition, the rung therefore comes from the `single` posture's unscoped `admin_full_access` grant. The file had no org owner who is not a platform admin, so the case signs one up and sets its membership to `owner`, the way the file already sets grades. - The existing cases still bind through `user:`, unchanged. - **H4: confirmed. No server path evaluates an action `visible`.** - `rest`, `runtime`, `objectql`, `services`, `metadata`, `metadata-protocol`, `core` and `plugins` have zero non-test hits for an action-`visible` evaluation. - The eight `ExpressionEngine.evaluate` / `celEngine.evaluate` call sites in package sources evaluate other things: formula fields and defaults (`objectql/src/engine.ts:2263`, `:6190`), hook conditions (`hook-wrappers.ts:309`), approval conditions, share-link gates and automation steps. - So nothing on the server binds `current_user.isPlatformAdmin` for this predicate, and the door stays the authority. ## Tests All runs below are at the final HEAD (`bb53601015`) unless marked otherwise. - **Pin (a), `platform-objects`:** the lowering-matrix row `SysMember#add_member` now expects `(current_user.isPlatformAdmin == true) && features.organization != false`. - `pnpm --filter @objectstack/platform-objects exec vitest run`: 62 files, 996 passed. - The package `typecheck` passed, with 0 TS errors. Its main program does not compile `platform-objects.test.ts`. `check:test-typecheck` compiles it through `tsconfig.test.json`: `--listFiles` counts 1 hit there and 0 in the main program. - **Pin (b), one dogfood case on a real showcase boot:** `Add Member is offered to a platform admin alone — current_user bound as the console binds it — and the door agrees`. - It reads each principal's served session, its own served `/meta/object/sys_member` and the served `/auth/config` flags. - It asserts each session's `isPlatformAdmin`: `true` for the seeded admin, `false` for the second owner, the admin, the delegated admin and the plain member. It also asserts that the second owner carries `org_owner`. - It evaluates the served predicate with `celEngine`, with `current_user` bound through `extra` the way the console binds it. Exactly the platform admin is offered the action. - It then probes the door on the same boot: the owner and the plain member get 403 `PERMISSION_DENIED` (envelope `error.code`) and no row is written; the platform admin gets 200 `success: true` and the `sys_member` row lands. - Result: `test/org-admin-affordance-reach.dogfood.test.ts` passed 14 of 14. The dogfood `typecheck` passed, and its program compiles the touched file (`--listFiles`: 1 hit). - **Builds:** `turbo run build --filter='@objectstack/dogfood^...'` passed 63 of 63 after the merge of `origin/main`. ### Reverse verification, on committed HEAD `57b18bf94e` - **Mutation:** `scripts/ablation-replace.mjs` deleted the line `visible: 'current_user.isPlatformAdmin == true',`. The anchor count went from 1 to 0 and the file's blob from `9beac68ce1c7` to `b8792313cac4`. An outer `trap … EXIT INT TERM` used absolute paths. - **Proof the mutation reached `dist/`:** `@objectstack/platform-objects` was rebuilt, then `scripts/ablation-dist-preflight.mjs @objectstack/platform-objects 'visible: "current_user.isPlatformAdmin == true"' --absent --source-marker=…` reported the marker absent from all 66 built files. Before the mutation, the same spelling was present in `dist/index.mjs:2219`. - **(a) turned red, 1 failed of 127:** `expected 'features.organization != false' to be '(current_user.isPlatformAdmin == true) && features.organization != false'`. - **(b) turned red, 1 failed of 14:** the set offered "Add Member" was received as `platform admin`, `org owner, not a platform admin`, `org admin`, `delegated admin` and `plain member`, against the expected `platform admin` alone. So the owner and member cells (and the admin cells) went red. - **Direction:** the run turned red, as predicted. - **Restore:** `git checkout HEAD --` on the absolute path. The blob after restore and the HEAD blob are both `9beac68ce1c7ed2b7143505916818a0a2e39a456`, and `git diff HEAD` is empty. Both `ablation-replace` and the outer trap proved this. - **After the restore:** the package was rebuilt, and the preflight in present mode found the marker in 6 built files on a clean tree. (a) passed 127 of 127 and (b) passed 14 of 14. ## Gates These ran locally at `bb53601015`, after the merge of `origin/main` (`5cfd8661c4`). - **The derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 67 commands for this change set (35 pnpm, 32 node). All 67 ran and exited 0. `--ran` reconciles them: `67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN`. - `check:dual-build-cjs-loads` first answered PREREQUISITE NOT MET (exit 3), because the build had covered only the dogfood closure. It was rerun after a full workspace build (`turbo run build`, 72 of 72) and loaded 106 require entry points across 66 packages. - `check:dts-closure`, `check:lean-entry-closure`, `check:published-files` and `check:sourcemap-no-sources-content` were rerun on that full build too, and all four passed. - **The artifact-roster block:** the derivation prints 53 more commands outside its total, and all 53 ran. - 50 exited 0. - Three answered NOT WIRED / NOT MEASURED (exit 2) because they need pull-request context: `check-partof-closing-keyword`, `check-closing-target-claim` and `check-single-claim-paths`. `check-partof-closing-keyword` was then run with this body as `PR_BODY` and passed. The other two need this PR's number and CI runs them on it. - `check-sdui-manifest` passed, but its objectui version comparison is NOT CHECKED, because the container has no objectui checkout. - `check:console-injection` is NOT MEASURED: there is no console `dist/` here, so only its self-test ran. This diff touches neither surface. - **The four symbol-anchor sweeps:** `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all passed. - **Lint, as a proven narrowing:** `pnpm exec eslint --no-inline-config --format json` ran over the three touched TypeScript files. - The population is read from eslint's own config: `--print-config` resolves for each of the three, so none is ignored. - The JSON output counts 3 files, with 0 errors and 0 warnings. - The narrowing cannot hide a verdict elsewhere. Type-aware linting is off: the resolved configs carry no `parserOptions.project` or `projectService`, and `eslint.config.mjs:327-328` states the repo never enables it. So this diff cannot move the verdict on any untouched file. - **Left to CI, as a declared narrowing:** the type-check lanes, Test Core, Dogfood Regression Gate, Build Core and the repo-wide `pnpm lint`. ## Acceptance notes - **The door also admits a legacy `user.role === 'admin'` scalar** (`platform-admin-gate.ts:80-84`), and this predicate does not read it. A deployment that still carries that pre-ADR-0068-D2 scalar on a user who lacks the posture rung would be admitted by the door, but would not be offered the button. The gate's own header says nothing in ObjectStack writes that scalar for a platform admin, and that re-synthesizing it is vetoed. The predicate is the one the ruling fixed, so this is noted, not filed. - **Only one platform-admin route is exercised here.** The dogfood boot uses the `single` posture, where the standing comes from the seeded admin's unscoped grant. The `OS_PLATFORM_OWNER_EMAIL` route of the walled postures is not exercised by this case. - **Only the new case binds through `extra`.** The grade cases above it still bind through `user:`, as the dispatch required. Their predicates read only `current_user.positions`, which is identical under either binding. - **Out of this PR:** #21903 (`Blocked-by: #21886`) remains open and carries the thirteen sibling platform-admin actions and the enumeration pin. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5cfd866 commit 77a94d8

4 files changed

Lines changed: 132 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
---
4+
5+
"Add Member" is offered only to a platform administrator, the one standing its endpoint admits.
6+
7+
Clause-②: no
8+
9+
- `sys_member`'s `add_member` toolbar action now declares `visible: 'current_user.isPlatformAdmin == true'`. `requiresFeature: 'organization'` composes onto it at parse time, so the served predicate reads `(current_user.isPlatformAdmin == true) && features.organization != false`.
10+
- Its endpoint, `POST /api/v1/auth/organization/add-member`, has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the button was still shown to every member of the organization.
11+
- ⛔ Nothing you author changes. The endpoint and the callers it admits are unchanged, and no key, export or parameter is added. The action's label is unchanged.

‎packages/platform-objects/src/identity/sys-member.object.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,17 @@ export const SysMember = ObjectSchema.create({
151151
locations: ['list_toolbar'],
152152
type: 'api',
153153
target: '/api/v1/auth/organization/add-member',
154+
// Offered only to the one standing the door admits (ADR-0068 D4):
155+
// `current_user.isPlatformAdmin` is the ADR-0095 D3 PLATFORM_ADMIN
156+
// posture rung — what the session payload emits and what the mount's
157+
// platform-admin gate judges. Gated on the feature alone, the button was
158+
// offered to every member, owners and admins included, and the door
159+
// refused each with 403 `PERMISSION_DENIED`. ⛔ Never
160+
// `'platform_admin' in current_user.positions`: `EvalUserSchema` rules
161+
// that standing is read from this key, never from the array.
162+
// `requiresFeature` below composes onto it at parse time:
163+
// `(current_user.isPlatformAdmin == true) && features.organization != false`.
164+
visible: 'current_user.isPlatformAdmin == true',
154165
// Gated on the org CAPABILITY, not multi-org (ADR-0093 D9): the
155166
// better-auth endpoints resolve the session's active org, which
156167
// single-org mode now guarantees via plugin-auth's default-org

‎packages/platform-objects/src/platform-objects.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,10 @@ describe('feature-gate lowering matrix (#2874)', () => {
590590
['SysMember', SysMember, 'invite_user', `${INVITE} && ${ORG}`],
591591
// No grade term: add-member is gated on platform-admin standing, not on a
592592
// membership grade, so the action carries no `requiresMembershipReach`.
593-
['SysMember', SysMember, 'add_member', ORG],
593+
// The standing term is the authored `visible` — the ADR-0095 D3
594+
// PLATFORM_ADMIN rung the door's gate judges (ADR-0068 D4) — composed
595+
// ahead of the feature gate. ⛔ Never a `current_user.positions` read.
596+
['SysMember', SysMember, 'add_member', `(current_user.isPlatformAdmin == true) && ${ORG}`],
594597
['SysMember', SysMember, 'update_member_role', `${ADMINS} && ${ORG}`],
595598
['SysMember', SysMember, 'remove_member', `${ADMINS} && ${ORG}`],
596599
['SysMember', SysMember, 'transfer_ownership', `((has(record.role) && record.role != 'owner') && ${OWNER}) && ${ORG}`],

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@
4242
* mints the organization and sets membership roles in system context — the
4343
* shape `delegated-admin-invite.dogfood.test.ts` uses, and the only writer
4444
* better-auth-managed tables accept (ADR-0092).
45+
*
46+
* ## The one affordance no grade reaches: "Add Member"
47+
*
48+
* `sys_member.add_member` targets a door gated on PLATFORM-admin standing
49+
* (ADR-0068), not on a grade, so an org owner is refused it too. Its `visible`
50+
* reads that standing, `current_user.isPlatformAdmin == true` (ADR-0068 D4,
51+
* the ADR-0095 D3 rung). That case binds `current_user` the way the CONSOLE
52+
* does — the whole scope handed to the engine as `extra`, one subject built
53+
* from the served session — and ⛔ not through `user:`, under which
54+
* `@objectstack/formula` re-derives `isPlatformAdmin` from `positions` and so
55+
* measures the name instead of the rung the session carries. The grade cases
56+
* above keep their `user:` binding: their predicates read `positions` only.
57+
* The platform admin is the seeded dev admin (the harness's signed-in
58+
* principal); a second org owner who is NOT one is minted for the case.
4559
*/
4660

4761
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -122,6 +136,8 @@ describe('org-admin affordances follow the membership grade (served metadata ×
122136
const rowOf = {} as Record<string, Record<string, unknown>>;
123137
let plainMemberRowId: string;
124138
let pendingInvitationId: string;
139+
/** An org owner who is NOT a platform admin — the principal "Add Member" must not be offered to. */
140+
const standingOwner = {} as { token: string; userId: string; session: Record<string, unknown>; served: Map<string, ServedAction> };
125141

126142
beforeAll(async () => {
127143
stack = await bootStack(showcaseStack, {});
@@ -155,6 +171,16 @@ describe('org-admin affordances follow the membership grade (served metadata ×
155171
}
156172
}
157173

174+
// A second owner of the same org, signed up rather than seeded, so the
175+
// owner grade is measured apart from platform-admin standing.
176+
standingOwner.token = await stack.signUp('reach.owner@example.com', 'Reach!Pass123', 'Reach second owner');
177+
{
178+
const [user] = await findRows(ql, 'sys_user', { email: 'reach.owner@example.com' }, 1);
179+
standingOwner.userId = String(user.id);
180+
const membership = await waitForMembership(ql, standingOwner.userId);
181+
await ql.update('sys_member', { id: membership.id, role: 'owner' }, { context: SYSTEM_CTX });
182+
}
183+
158184
// Rows for the row actions to bind against: a pending invitation, a team,
159185
// and a team membership — created through the doors, as the owner.
160186
const invite = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/invite-member', {
@@ -192,6 +218,16 @@ describe('org-admin affordances follow the membership grade (served metadata ×
192218
servedFields[grade][object] = Object.keys(body.item?.fields ?? {});
193219
}
194220
}
221+
222+
{
223+
const session = await stack.apiAs(standingOwner.token, 'GET', '/auth/get-session');
224+
expect(session.status).toBe(200);
225+
standingOwner.session = ((await session.json()) as { user: Record<string, unknown> }).user;
226+
const meta = await stack.apiAs(standingOwner.token, 'GET', '/meta/object/sys_member');
227+
expect(meta.status, 'the second owner reads /meta/object/sys_member').toBe(200);
228+
const body = (await meta.json()) as { item?: { actions?: ServedAction[] } };
229+
standingOwner.served = new Map((body.item?.actions ?? []).map((a) => [`sys_member.${a.name}`, a]));
230+
}
195231
}, 240_000);
196232

197233
afterAll(async () => {
@@ -324,4 +360,74 @@ describe('org-admin affordances follow the membership grade (served metadata ×
324360
memberId: plainMemberRowId, role: 'owner', organizationId: orgId,
325361
}), 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER');
326362
}, 60_000);
363+
364+
it('Add Member is offered to a platform admin alone — current_user bound as the console binds it — and the door agrees', async () => {
365+
const servedAddMember = served.owner.get('sys_member.add_member')?.visible;
366+
expect(servedAddMember, 'add_member is served with a predicate').toBeDefined();
367+
368+
/**
369+
* The console's evaluation, not this file's `user:` one: objectui hands
370+
* its whole scope to the engine as `extra`, ONE subject built from the
371+
* served session under `current_user` / `user` / `ctx.user` / `os.user`,
372+
* with `features` beside it — so `isPlatformAdmin` is the session's own
373+
* value, the rung the door's gate judges.
374+
*/
375+
const consoleOffers = (session: Record<string, unknown>, own: Map<string, ServedAction>): boolean => {
376+
const action = own.get('sys_member.add_member');
377+
if (!action) throw new Error('sys_member.add_member is not served to this principal');
378+
expect(action.visible, 'one predicate is served to every principal').toEqual(servedAddMember);
379+
const subject = {
380+
id: session.id,
381+
name: session.name,
382+
email: session.email,
383+
isPlatformAdmin: session.isPlatformAdmin,
384+
positions: session.positions,
385+
};
386+
const result = celEngine.evaluate(action.visible as never, {
387+
extra: { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject }, features },
388+
});
389+
if (!result.ok) throw new Error(`sys_member.add_member faulted: ${JSON.stringify(result)}`);
390+
return result.value === true;
391+
};
392+
393+
// The standing each principal's served session carries — measured, not
394+
// assumed: the seeded admin holds the rung, and no org grade does.
395+
const principals: Array<[string, Record<string, unknown>, Map<string, ServedAction>, boolean]> = [
396+
['platform admin (the seeded admin)', sessionUser.owner, served.owner, true],
397+
['org owner, not a platform admin', standingOwner.session, standingOwner.served, false],
398+
['org admin', sessionUser.admin, served.admin, false],
399+
['delegated admin', sessionUser.delegated_admin, served.delegated_admin, false],
400+
['plain member', sessionUser.member, served.member, false],
401+
];
402+
for (const [who, session, , platformAdmin] of principals) {
403+
expect(session.isPlatformAdmin, `${who}: the session's standing`).toBe(platformAdmin);
404+
}
405+
// The second owner really is an owner on the session face, so its hidden
406+
// verdict is about standing and not about a missing grade.
407+
expect(standingOwner.session.positions as string[]).toContain('org_owner');
408+
409+
// Both halves at once, every principal named: who IS offered it, and so
410+
// who is not.
411+
const offeredTo = principals.filter(([, session, own]) => consoleOffers(session, own)).map(([who]) => who);
412+
expect(offeredTo).toEqual(['platform admin (the seeded admin)']);
413+
414+
// The door's own verdicts on the same boot: the owner and the plain member
415+
// are refused before anything is written; the platform admin is admitted.
416+
const org2 = await ql.insert('sys_organization', { name: 'Reach Org Two', slug: 'reach-org-two' }, { context: SYSTEM_CTX });
417+
const attach = { userId: standingOwner.userId, role: 'member', organizationId: String(org2.id) };
418+
for (const [who, token] of [['org owner', standingOwner.token], ['plain member', tokens.member]] as const) {
419+
const res = await stack.apiAs(token, 'POST', '/auth/organization/add-member', attach);
420+
const body = (await res.clone().json()) as { success?: boolean; error?: { code?: string } };
421+
expect(res.status, `${who}: ${JSON.stringify(body)}`).toBe(403);
422+
expect(body.error?.code, who).toBe('PERMISSION_DENIED');
423+
}
424+
expect(await findRows(ql, 'sys_member', { user_id: standingOwner.userId, organization_id: String(org2.id) }, 5)).toEqual([]);
425+
426+
const admitted = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/add-member', attach);
427+
const admittedBody = (await admitted.clone().json()) as { success?: boolean };
428+
expect(admitted.status, JSON.stringify(admittedBody)).toBe(200);
429+
expect(admittedBody.success).toBe(true);
430+
const attached = await findRows(ql, 'sys_member', { user_id: standingOwner.userId, organization_id: String(org2.id) }, 5);
431+
expect(attached.map((m) => m.role)).toEqual(['member']);
432+
}, 60_000);
327433
});

0 commit comments

Comments
 (0)