Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/21886-add-member-platform-admin-visibility.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/platform-objects/src/platform-objects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`],
Expand Down
106 changes: 106 additions & 0 deletions packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -122,6 +136,8 @@ describe('org-admin affordances follow the membership grade (served metadata ×
const rowOf = {} as Record<string, Record<string, unknown>>;
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<string, unknown>; served: Map<string, ServedAction> };

beforeAll(async () => {
stack = await bootStack(showcaseStack, {});
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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<string, unknown> }).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 () => {
Expand Down Expand Up @@ -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<string, unknown>, own: Map<string, ServedAction>): 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<string, unknown>, Map<string, ServedAction>, 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);
});
Loading