From db7adb5c3ee203cd1629878917b04e4fee27d38d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:44:43 +0000 Subject: [PATCH 1/2] fix(platform-objects): offer Add Member only to a platform admin sys_member.add_member targets /auth/organization/add-member, which the ADR-0068 platform-admin gate admits for a platform admin alone; every other caller, org owners and admins included, gets 403 PERMISSION_DENIED. The action was gated on the organization feature only, so the button was offered to every member. Its visible now reads the standing the door reads, current_user.isPlatformAdmin == true (ADR-0068 D4, the ADR-0095 D3 rung), and requiresFeature composes onto it at parse time. - platform-objects: the lowering-matrix row pins the served predicate. - dogfood: on a real showcase boot, with current_user bound the way the console binds it (through extra, from each served session), a platform admin is offered Add Member and an org owner, an admin, a delegated admin and a plain member are not; the door refuses the owner and the member and admits the platform admin. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...86-add-member-platform-admin-visibility.md | 11 ++ .../src/identity/sys-member.object.ts | 11 ++ .../src/platform-objects.test.ts | 5 +- ...org-admin-affordance-reach.dogfood.test.ts | 105 ++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 .changeset/21886-add-member-platform-admin-visibility.md diff --git a/.changeset/21886-add-member-platform-admin-visibility.md b/.changeset/21886-add-member-platform-admin-visibility.md new file mode 100644 index 00000000000..4bf3beef56b --- /dev/null +++ b/.changeset/21886-add-member-platform-admin-visibility.md @@ -0,0 +1,11 @@ +--- +"@objectstack/platform-objects": patch +--- + +"Add Member" is offered only to a platform administrator, the one standing its endpoint admits. + +Clause-②: no + +- `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`. +- 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. +- ⛔ 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. diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 05fc308d0e1..9beac68ce1c 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -151,6 +151,17 @@ export const SysMember = ObjectSchema.create({ locations: ['list_toolbar'], type: 'api', target: '/api/v1/auth/organization/add-member', + // Offered only to the one standing the door admits (ADR-0068 D4): + // `current_user.isPlatformAdmin` is the ADR-0095 D3 PLATFORM_ADMIN + // posture rung — what the session payload emits and what the mount's + // platform-admin gate judges. Gated on the feature alone, the button was + // offered to every member, owners and admins included, and the door + // refused each with 403 `PERMISSION_DENIED`. ⛔ Never + // `'platform_admin' in current_user.positions`: `EvalUserSchema` rules + // that standing is read from this key, never from the array. + // `requiresFeature` below composes onto it at parse time: + // `(current_user.isPlatformAdmin == true) && features.organization != false`. + visible: 'current_user.isPlatformAdmin == true', // Gated on the org CAPABILITY, not multi-org (ADR-0093 D9): the // better-auth endpoints resolve the session's active org, which // single-org mode now guarantees via plugin-auth's default-org diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 7264b486602..ead40140fe9 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -590,7 +590,10 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysMember', SysMember, 'invite_user', `${INVITE} && ${ORG}`], // No grade term: add-member is gated on platform-admin standing, not on a // membership grade, so the action carries no `requiresMembershipReach`. - ['SysMember', SysMember, 'add_member', ORG], + // The standing term is the authored `visible` — the ADR-0095 D3 + // PLATFORM_ADMIN rung the door's gate judges (ADR-0068 D4) — composed + // ahead of the feature gate. ⛔ Never a `current_user.positions` read. + ['SysMember', SysMember, 'add_member', `(current_user.isPlatformAdmin == true) && ${ORG}`], ['SysMember', SysMember, 'update_member_role', `${ADMINS} && ${ORG}`], ['SysMember', SysMember, 'remove_member', `${ADMINS} && ${ORG}`], ['SysMember', SysMember, 'transfer_ownership', `((has(record.role) && record.role != 'owner') && ${OWNER}) && ${ORG}`], diff --git a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts index cb4d5ca9590..7aa76e5933a 100644 --- a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts @@ -42,6 +42,20 @@ * mints the organization and sets membership roles in system context — the * shape `delegated-admin-invite.dogfood.test.ts` uses, and the only writer * better-auth-managed tables accept (ADR-0092). + * + * ## The one affordance no grade reaches: "Add Member" + * + * `sys_member.add_member` targets a door gated on PLATFORM-admin standing + * (ADR-0068), not on a grade, so an org owner is refused it too. Its `visible` + * reads that standing, `current_user.isPlatformAdmin == true` (ADR-0068 D4, + * the ADR-0095 D3 rung). That case binds `current_user` the way the CONSOLE + * does — the whole scope handed to the engine as `extra`, one subject built + * from the served session — and ⛔ not through `user:`, under which + * `@objectstack/formula` re-derives `isPlatformAdmin` from `positions` and so + * measures the name instead of the rung the session carries. The grade cases + * above keep their `user:` binding: their predicates read `positions` only. + * The platform admin is the seeded dev admin (the harness's signed-in + * principal); a second org owner who is NOT one is minted for the case. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -122,6 +136,8 @@ describe('org-admin affordances follow the membership grade (served metadata × const rowOf = {} as Record>; let plainMemberRowId: string; let pendingInvitationId: string; + /** An org owner who is NOT a platform admin — the principal "Add Member" must not be offered to. */ + const standingOwner = {} as { token: string; userId: string; session: Record; served: Map }; beforeAll(async () => { stack = await bootStack(showcaseStack, {}); @@ -155,6 +171,16 @@ describe('org-admin affordances follow the membership grade (served metadata × } } + // A second owner of the same org, signed up rather than seeded, so the + // owner grade is measured apart from platform-admin standing. + standingOwner.token = await stack.signUp('reach.owner@example.com', 'Reach!Pass123', 'Reach second owner'); + { + const [user] = await findRows(ql, 'sys_user', { email: 'reach.owner@example.com' }, 1); + standingOwner.userId = String(user.id); + const membership = await waitForMembership(ql, standingOwner.userId); + await ql.update('sys_member', { id: membership.id, role: 'owner' }, { context: SYSTEM_CTX }); + } + // Rows for the row actions to bind against: a pending invitation, a team, // and a team membership — created through the doors, as the owner. 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 × servedFields[grade][object] = Object.keys(body.item?.fields ?? {}); } } + + { + const session = await stack.apiAs(standingOwner.token, 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + standingOwner.session = ((await session.json()) as { user: Record }).user; + const meta = await stack.apiAs(standingOwner.token, 'GET', '/meta/object/sys_member'); + expect(meta.status, 'the second owner reads /meta/object/sys_member').toBe(200); + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + standingOwner.served = new Map((body.item?.actions ?? []).map((a) => [`sys_member.${a.name}`, a])); + } }, 240_000); afterAll(async () => { @@ -324,4 +360,73 @@ describe('org-admin affordances follow the membership grade (served metadata × memberId: plainMemberRowId, role: 'owner', organizationId: orgId, }), 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER'); }, 60_000); + + it('Add Member is offered to a platform admin alone — current_user bound as the console binds it — and the door agrees', async () => { + const servedAddMember = served.owner.get('sys_member.add_member')?.visible; + expect(servedAddMember, 'add_member is served with a predicate').toBeDefined(); + + /** + * The console's evaluation, not this file's `user:` one: objectui hands + * its whole scope to the engine as `extra`, ONE subject built from the + * served session under `current_user` / `user` / `ctx.user` / `os.user`, + * with `features` beside it — so `isPlatformAdmin` is the session's own + * value, the rung the door's gate judges. + */ + const consoleOffers = (session: Record, own: Map): boolean => { + const action = own.get('sys_member.add_member'); + if (!action) throw new Error('sys_member.add_member is not served to this principal'); + expect(action.visible, 'one predicate is served to every principal').toEqual(servedAddMember); + const subject = { + id: session.id, + name: session.name, + email: session.email, + isPlatformAdmin: session.isPlatformAdmin, + positions: session.positions, + }; + const result = celEngine.evaluate(action.visible as never, { + extra: { current_user: subject, user: subject, ctx: { user: subject }, os: { user: subject }, features }, + }); + if (!result.ok) throw new Error(`sys_member.add_member faulted: ${JSON.stringify(result)}`); + return result.value === true; + }; + + // The standing each principal's served session carries — measured, not + // assumed: the seeded admin holds the rung, and no org grade does. + const principals: Array<[string, Record, Map, boolean]> = [ + ['platform admin (the seeded admin)', sessionUser.owner, served.owner, true], + ['org owner, not a platform admin', standingOwner.session, standingOwner.served, false], + ['org admin', sessionUser.admin, served.admin, false], + ['delegated admin', sessionUser.delegated_admin, served.delegated_admin, false], + ['plain member', sessionUser.member, served.member, false], + ]; + for (const [who, session, , platformAdmin] of principals) { + expect(session.isPlatformAdmin, `${who}: the session's standing`).toBe(platformAdmin); + } + // The second owner really is an owner on the session face, so its hidden + // verdict is about standing and not about a missing grade. + expect(standingOwner.session.positions as string[]).toContain('org_owner'); + + for (const [who, session, own, platformAdmin] of principals) { + expect(consoleOffers(session, own), `${who} is offered Add Member`).toBe(platformAdmin); + } + + // The door's own verdicts on the same boot: the owner and the plain member + // are refused before anything is written; the platform admin is admitted. + const org2 = await ql.insert('sys_organization', { name: 'Reach Org Two', slug: 'reach-org-two' }, { context: SYSTEM_CTX }); + const attach = { userId: standingOwner.userId, role: 'member', organizationId: String(org2.id) }; + for (const [who, token] of [['org owner', standingOwner.token], ['plain member', tokens.member]] as const) { + const res = await stack.apiAs(token, 'POST', '/auth/organization/add-member', attach); + const body = (await res.clone().json()) as { success?: boolean; error?: { code?: string } }; + expect(res.status, `${who}: ${JSON.stringify(body)}`).toBe(403); + expect(body.error?.code, who).toBe('PERMISSION_DENIED'); + } + expect(await findRows(ql, 'sys_member', { user_id: standingOwner.userId, organization_id: String(org2.id) }, 5)).toEqual([]); + + const admitted = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/add-member', attach); + const admittedBody = (await admitted.clone().json()) as { success?: boolean }; + expect(admitted.status, JSON.stringify(admittedBody)).toBe(200); + expect(admittedBody.success).toBe(true); + const attached = await findRows(ql, 'sys_member', { user_id: standingOwner.userId, organization_id: String(org2.id) }, 5); + expect(attached.map((m) => m.role)).toEqual(['member']); + }, 60_000); }); From 57b18bf94e142f0f7e8401502c7b634cf31fd49d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 06:56:33 +0000 Subject: [PATCH 2/2] test(dogfood): assert the Add Member verdicts as one named set One equality over the principals offered the action names every cell in its failure output, instead of stopping at the first principal that disagrees. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../test/org-admin-affordance-reach.dogfood.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts index 7aa76e5933a..9df2b5d9ec4 100644 --- a/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts +++ b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts @@ -406,9 +406,10 @@ describe('org-admin affordances follow the membership grade (served metadata × // verdict is about standing and not about a missing grade. expect(standingOwner.session.positions as string[]).toContain('org_owner'); - for (const [who, session, own, platformAdmin] of principals) { - expect(consoleOffers(session, own), `${who} is offered Add Member`).toBe(platformAdmin); - } + // Both halves at once, every principal named: who IS offered it, and so + // who is not. + const offeredTo = principals.filter(([, session, own]) => consoleOffers(session, own)).map(([who]) => who); + expect(offeredTo).toEqual(['platform admin (the seeded admin)']); // The door's own verdicts on the same boot: the owner and the plain member // are refused before anything is written; the platform admin is admitted.