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 0000000000..4bf3beef56 --- /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 05fc308d0e..9beac68ce1 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 7264b48660..ead40140fe 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 cb4d5ca959..9df2b5d9ec 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,74 @@ 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'); + + // 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. + 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); });