diff --git a/.changeset/21795-platform-objects-org-admin-actions-by-grade.md b/.changeset/21795-platform-objects-org-admin-actions-by-grade.md new file mode 100644 index 00000000000..1206f864394 --- /dev/null +++ b/.changeset/21795-platform-objects-org-admin-actions-by-grade.md @@ -0,0 +1,12 @@ +--- +'@objectstack/platform-objects': patch +--- + +The organization's member, invitation and team actions are offered only to the membership grades the server admits. A plain member no longer sees "Invite User", "Change Role", "Remove Member", "Cancel Invitation", "Create Team" and the rest, each of which the server refused with 403. + +- `invite_user` (on the Users, Members and Invitations lists) and `resend_invitation`: owner, admin and delegated_admin. +- `update_member_role`, `remove_member`, `cancel_invitation`, `create_team`, `update_team`, `remove_team`, `add_team_member` and `remove_team_member`: owner and admin. +- `transfer_ownership`: the owner alone, on a non-owner row. +- `add_member` is unchanged. Its door is platform-admin standing, not a membership grade. + +Each action declares `requiresMembershipReach` from `@objectstack/spec`, which is lowered into its `visible` predicate. diff --git a/.changeset/21795-spec-membership-reach-action-gate.md b/.changeset/21795-spec-membership-reach-action-gate.md new file mode 100644 index 00000000000..29a2a59b459 --- /dev/null +++ b/.changeset/21795-spec-membership-reach-action-gate.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +An action can declare which organization membership grades it is offered to: `requiresMembershipReach` names a row of the new `MEMBERSHIP_REACH` table and is lowered at parse time into `visible`, the way `requiresFeature` is. + +Clause-②: yes (widening) + +- **`MEMBERSHIP_REACH`** (`@objectstack/spec/identity`) says which membership grades reach which better-auth organization endpoint. `invite_member` is reached by owner, admin and delegated_admin. `cancel_invitation`, `update_member_role`, `remove_member`, `create_team`, `update_team`, `remove_team`, `add_team_member` and `remove_team_member` are reached by owner and admin. `transfer_ownership` (setting the creator role on a member) is reached by the owner alone. The rows are read off better-auth's own access-control statements plus the `delegated_admin` registration, and plugin-auth pins them equal to the door. It is reach, not authority (ADR-0108 D1): a fourth fact beside the membership names, the administrative-grade rule and the identity projection, and merged into none of them. Also exported: `MEMBERSHIP_REACH_NAMES`, `membershipReachPredicate`, `lowerRequiresMembershipReach`, and the `MembershipReachEntry`, `MembershipReachName` and `MembershipReachStatement` types. +- **`ActionSchema.requiresMembershipReach`** is optional and enum-checked against the table's row names. At parse time it becomes one `'' in current_user.positions` term per grade, in the names `mapMembershipRole` projects them to (`org_owner`, `org_admin`, `delegated_admin`). The terms are AND-composed with an explicit `visible`, and the key is stripped from the parsed output. It composes ahead of `requiresFeature`, so a feature gate stays the last term. `visible: false`, a non-CEL or AST-only `visible`, and a blank `source` are refused at parse time, at the key. +- It is UI courtesy, not authorization: the endpoint's own door stays the authority. The capability channel (`current_user.can`) is unchanged and carries no grade (ADR-0108). + +Nothing that parsed before is refused now, and an action without the key lowers exactly as before. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 26b1bb96563..8c945c4fc5c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -468,6 +468,7 @@ const result = ApiMethod.parse(data); | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. | | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | +| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index f74024aa5c1..4207db71e36 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -338,6 +338,7 @@ const result = MetadataBulkResultSchema.parse(data); | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. | | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| …>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | +| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 786752d73cd..cbf25b9d9db 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -55,6 +55,7 @@ const result = ActionSchema.parse(data); | **resultDialog** | `{ title?: string \| Record; description?: string \| Record; acknowledge?: string \| Record; format?: Enum<'qrcode' \| 'code-list' \| 'secret' \| 'text' \| 'json'>; … }` | optional | Render API response in a one-shot reveal dialog (suppresses successMessage when set). | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is offered when it evaluates TRUE. Omit = always visible. | | **requiresFeature** | `Enum<'twoFactor' \| 'organization' \| 'multiOrgEnabled' \| 'degradedTenancy' \| 'oidcProvider' \| 'sso' \| 'ssoEnforced' \| 'deviceAuthorization' \| 'admin' \| 'phoneNumber' \| 'phoneNumberOtp'>` | optional | Public auth feature flag gating this action; lowered into `visible` at parse time. | +| **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +2 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on the platform action route (script/flow/modal + MCP) and mirrored as a UI hide; a `type: api` action pointed at a custom endpoint must re-check it there. | | **shortcut** | `never` | optional | [REMOVED] `action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out) — it never triggered anything: no keydown listener feeds ActionEngine.getShortcuts(), and objectui's keyboard stack (useKeyboardShortcuts) is hand-registered and never consults action metadata. Delete the key. For a real shortcut, register the key in the Console keyboard stack and have its handler invoke the action by name. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -78,6 +79,19 @@ const result = ActionSchema.parse(data); | **_packageVersion** | `string` | optional | Owning package version. | | **_lockDocsUrl** | `string` | optional | Optional documentation link surfaced next to _lockReason. | +### Allowed Values: `Action.requiresMembershipReach` + +* `invite_member` +* `cancel_invitation` +* `update_member_role` +* `transfer_ownership` +* `remove_member` +* `create_team` +* `update_team` +* `remove_team` +* `add_team_member` +* `remove_team_member` + ### Nested Shape: `Action.body[language='expression']` L1 expression body — pure formula, no IO diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index 0ced6bff7a6..ceb2cbbe0cd 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -247,10 +247,11 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // [#21095] 48 → 49: `outcomeMessages` joined the accepted set, and it // is named in the sample below so the served schema is held to // carrying it, not merely to having one more key than before. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(49); + // [#21795] 49 → 50: `requiresMembershipReach` joined the accepted set. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(50); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. - for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages']) { + for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach']) { expect(Object.keys(properties)).toContain(key); } }); diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index e2f0a0624cf..83aece2ce91 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -1772,6 +1772,10 @@ export const enMetadataForms: NonNullable = { label: "Requires Feature", helpText: "Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key." }, + requiresMembershipReach: { + label: "Requires Membership Reach", + helpText: "The organization endpoint this action calls, so it is offered only to members whose grade reaches that endpoint. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key." + }, requiredPermissions: { label: "Required Permissions", helpText: "Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them." diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 67e74385703..02f85bf5d8c 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -1772,6 +1772,10 @@ export const esESMetadataForms: NonNullable = label: "Requiere función", helpText: "Indicador público de función de autenticación que condiciona esta acción. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave." }, + requiresMembershipReach: { + label: "Requiere alcance de membresía", + helpText: "El endpoint de la organización al que llama esta acción, de modo que solo se ofrece a los miembros cuyo grado alcanza ese endpoint. Se traslada al predicado `visible` durante el análisis y se elimina de la salida, así que ningún consumidor posterior llega a ver la clave." + }, requiredPermissions: { label: "Permisos requeridos", helpText: "Capacidades (systemPermissions de conjuntos de permisos) que quien llama debe tener, todas las enumeradas, para invocar esta acción (ADR-0066 D4). La ruta de acciones de la plataforma rechaza a cualquier otro con 403 (acciones script, flow y modal, y la vía MCP/IA), y se le oculta el botón. Una acción de type api llama directamente a su endpoint, así que ese endpoint debe volver a comprobarlas." diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 72b0302d00d..4c8ee80e928 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -1772,6 +1772,10 @@ export const jaJPMetadataForms: NonNullable = label: "必要な認証機能", helpText: "このアクションの表示可否を決める公開認証機能フラグ。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。" }, + requiresMembershipReach: { + label: "必要なメンバーシップ到達先", + helpText: "このアクションが呼び出す組織エンドポイント。メンバーシップのグレードがそのエンドポイントに到達できるメンバーにだけ表示されます。解析時に `visible` の述語へ畳み込まれ、出力からは取り除かれるため、下流の利用側がこのキーを見ることはありません。" + }, requiredPermissions: { label: "必要な権限", helpText: "このアクションを実行するために呼び出し元が保持すべき機能(権限セットの systemPermissions)。列挙したすべてが必要です(ADR-0066 D4)。それ以外の呼び出し元はプラットフォームのアクションルートで 403 として拒否され(script、flow、modal アクションと MCP/AI 経路)、ボタンも表示されません。type が api のアクションはエンドポイントを直接呼び出すため、そのエンドポイントで改めてチェックする必要があります。" diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 9913c5f2502..1cc49d48b68 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1169,7 +1169,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // taking its label — authored in all three locales — out of the catalog. // 660 since #21765: the object form offers `imageField` beside // `nameField` — one new row label, authored in all three locales. - expect(translated.length, `${locale} positive control`).toBe(660); + // 661 since the action form offers `requiresMembershipReach` beside + // `requiresFeature` — one new row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(661); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index c5d45ceb16e..bed694af11b 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -1772,6 +1772,10 @@ export const zhCNMetadataForms: NonNullable = label: "所需认证特性", helpText: "用于控制该动作是否出现的公共认证特性开关。它在解析时被降解进 `visible` 断言并从输出中移除,因此下游消费方永远看不到这个键。" }, + requiresMembershipReach: { + label: "所需成员等级可达端点", + helpText: "该动作调用的组织端点:只有成员等级可达该端点的成员才会看到它。它在解析时被降解进 `visible` 断言并从输出中移除,因此下游消费方永远看不到这个键。" + }, requiredPermissions: { label: "所需权限", helpText: "调用此动作必须持有的能力(权限集的 systemPermissions),列出的每一项都必须持有(ADR-0066 D4)。平台动作路由会以 403 拒绝其他调用方(script、flow 和 modal 动作,以及 MCP/AI 路径),并对他们隐藏按钮。type 为 api 的动作由浏览器直接调用其端点,因此该端点必须自行再次检查。" diff --git a/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts b/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts index 8b6f1b39035..1f5527212f3 100644 --- a/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts +++ b/packages/platform-objects/src/identity/action-predicate-sparse-face.test.ts @@ -57,7 +57,16 @@ import { SysUserPreference } from './sys-user-preference.object.js'; import { SysBusinessUnit } from './sys-business-unit.object.js'; import { SysBusinessUnitMember } from './sys-business-unit-member.object.js'; -const USER = { id: 'u1', email: 'me@example.com' }; +/** + * The principal binding. `positions` is always present on the user an action + * predicate sees (`EvalUserSchema` defaults it, and the console forwards + * `user.positions ?? []`), and the org-admin actions AND-compose a + * membership-grade term over it. An owner is admitted by every such term — the + * same reasoning as the all-on {@link FEATURES} below: a grade term that + * answered false would short-circuit the composed `&&` and hide the record half + * this sweep exists to test. + */ +const USER = { id: 'u1', email: 'me@example.com', positions: ['org_owner'] }; /** * `defineObject` normalizes a CEL shorthand string into a `{dialect, source}` diff --git a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts index b03cf6bd04c..c9abdd72d70 100644 --- a/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts +++ b/packages/platform-objects/src/identity/invite-entry-toolbar.test.ts @@ -151,7 +151,13 @@ describe('invite_user — the three mirrors agree (#11544)', () => { // survive (pinned in platform-objects.test.ts), so the gate is read from // its lowered form. A mirror that lost the gate would render a button that // 404s wherever the org capability is off. - expect(action(object, 'invite_user').visible?.source).toBe('features.organization != false'); + // The grade gate composes AHEAD of it (`requiresMembershipReach: + // 'invite_member'`), so the three mirrors also agree on WHO is offered + // the button: owner, admin and delegated_admin — never a plain member. + expect(action(object, 'invite_user').visible?.source).toBe( + "('org_owner' in current_user.positions || 'org_admin' in current_user.positions" + + " || 'delegated_admin' in current_user.positions) && features.organization != false", + ); }); it.each(MIRRORS)('%s asks for the same two inputs, email and role', (_name, object) => { diff --git a/packages/platform-objects/src/identity/sys-invitation.object.ts b/packages/platform-objects/src/identity/sys-invitation.object.ts index fe566eca39b..7ebcdb8370b 100644 --- a/packages/platform-objects/src/identity/sys-invitation.object.ts +++ b/packages/platform-objects/src/identity/sys-invitation.object.ts @@ -66,6 +66,7 @@ export const SysInvitation = ObjectSchema.create({ // accept/reject actions below stay record-gated — they are // unreachable in single-org anyway (no invitation rows exist). requiresFeature: 'organization', + requiresMembershipReach: 'invite_member', successMessage: 'Invitation sent', refreshAfter: true, params: [ @@ -84,6 +85,7 @@ export const SysInvitation = ObjectSchema.create({ target: '/api/v1/auth/organization/cancel-invitation', recordIdParam: 'invitationId', requiresFeature: 'organization', + requiresMembershipReach: 'cancel_invitation', confirmText: 'Cancel this invitation? The recipient will no longer be able to accept it.', successMessage: 'Invitation canceled', refreshAfter: true, @@ -98,6 +100,9 @@ export const SysInvitation = ObjectSchema.create({ target: '/api/v1/auth/organization/invite-member', bodyExtra: { resend: true }, requiresFeature: 'organization', + // A resend IS a call to invite-member, so it is reached by the grades + // that reach that endpoint — delegated_admin included. + requiresMembershipReach: 'invite_member', successMessage: 'Invitation resent', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 4d97d3afa11..6469f03180f 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -99,6 +99,9 @@ export const SysMember = ObjectSchema.create({ // multi-org (ADR-0093 D9: single-org sessions carry an active org // via plugin-auth's default-org bootstrap). requiresFeature: 'organization', + // ...and the grades that reach the endpoint (ADR-0108 D1): owner, + // admin and delegated_admin — a plain member is not offered it. + requiresMembershipReach: 'invite_member', successMessage: 'Invitation sent', refreshAfter: true, params: [ @@ -153,6 +156,10 @@ export const SysMember = ObjectSchema.create({ // single-org mode now guarantees via plugin-auth's default-org // bootstrap. Same gate on every membership mutation below. requiresFeature: 'organization', + // ⛔ No `requiresMembershipReach`, deliberately: this URL is + // ObjectStack's mount over better-auth's server-only `addMember`, gated + // on PLATFORM-admin standing (ADR-0068), not on a membership grade — so + // it is no row of the reach table, and an org owner is refused it too. successMessage: 'Member added', refreshAfter: true, params: [ @@ -171,6 +178,7 @@ export const SysMember = ObjectSchema.create({ target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', requiresFeature: 'organization', + requiresMembershipReach: 'update_member_role', successMessage: 'Member role updated', refreshAfter: true, params: [ @@ -188,6 +196,7 @@ export const SysMember = ObjectSchema.create({ target: '/api/v1/auth/organization/remove-member', recordIdParam: 'memberIdOrEmail', requiresFeature: 'organization', + requiresMembershipReach: 'remove_member', confirmText: 'Remove this member from the organization? They will lose access to all org resources.', successMessage: 'Member removed', refreshAfter: true, @@ -209,8 +218,10 @@ export const SysMember = ObjectSchema.create({ target: '/api/v1/auth/organization/update-member-role', recordIdParam: 'memberId', bodyExtra: { role: 'owner' }, - // The residual row predicate stays hand-written; the feature gate is - // AND-composed onto it by the requiresFeature lowering. + // The residual row predicate stays hand-written; the grade gate and + // then the feature gate are AND-composed onto it by the lowerings. + // Owner only: better-auth refuses to SET the creator role unless the + // caller holds it, whatever an admin's statements say. // `has()` guards the SPARSE action face (#8990): this is a `list_item` // action, so a member list that does not project `role` would abort the // predicate at key resolution and drop the button silently. `has()` @@ -218,6 +229,7 @@ export const SysMember = ObjectSchema.create({ // `materializeDeclaredFields` in `@objectstack/objectql`). visible: "has(record.role) && record.role != 'owner'", requiresFeature: 'organization', + requiresMembershipReach: 'transfer_ownership', confirmText: 'Transfer ownership of this organization to the selected member? You will be demoted to admin and lose owner-only privileges.', successMessage: 'Ownership transferred', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index b3fc25d5c76..f6cb5eaf59e 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -59,6 +59,7 @@ export const SysTeamMember = ObjectSchema.create({ // both mutations so they vanish in single-org (mirrors // sys_organization.create_organization). requiresFeature: 'organization', + requiresMembershipReach: 'add_team_member', successMessage: 'Team member added', refreshAfter: true, params: [ @@ -80,6 +81,7 @@ export const SysTeamMember = ObjectSchema.create({ type: 'api', target: '/api/v1/auth/organization/remove-team-member', requiresFeature: 'organization', + requiresMembershipReach: 'remove_team_member', // Confirm question on `description`, not `confirmText`: this action collects // params, and pairing the two keys opens two dialogs for one decision // (#7278 ruling 2026-08-10, swept by #7309). diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index e22d35d9228..2c7e1140e86 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -49,6 +49,7 @@ export const SysTeam = ObjectSchema.create({ // Gate every team mutation on the multi-org flag so the affordances // disappear in single-org (mirrors sys_organization.create_organization). requiresFeature: 'organization', + requiresMembershipReach: 'create_team', successMessage: 'Team created', refreshAfter: true, params: [ @@ -70,6 +71,7 @@ export const SysTeam = ObjectSchema.create({ recordIdParam: 'teamId', bodyShape: { wrap: 'data' }, requiresFeature: 'organization', + requiresMembershipReach: 'update_team', successMessage: 'Team updated', refreshAfter: true, params: [ @@ -89,6 +91,7 @@ export const SysTeam = ObjectSchema.create({ target: '/api/v1/auth/organization/remove-team', recordIdParam: 'teamId', requiresFeature: 'organization', + requiresMembershipReach: 'remove_team', confirmText: 'Delete this team? Members will lose any team-scoped access. This cannot be undone.', successMessage: 'Team deleted', refreshAfter: true, diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 717dc89e7bd..c5bcf3e7f31 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -94,6 +94,7 @@ export const SysUser = ObjectSchema.create({ // and every add flows through better-auth invitations, never bespoke // sys_user CRUD. requiresFeature: 'organization', + requiresMembershipReach: 'invite_member', successMessage: 'Invitation sent', refreshAfter: true, params: [ diff --git a/packages/platform-objects/src/platform-objects.test.ts b/packages/platform-objects/src/platform-objects.test.ts index 9e9df128a60..7264b486602 100644 --- a/packages/platform-objects/src/platform-objects.test.ts +++ b/packages/platform-objects/src/platform-objects.test.ts @@ -567,6 +567,14 @@ describe('@objectstack/platform-objects', () => { describe('feature-gate lowering matrix (#2874)', () => { const ORG = 'features.organization != false'; const MULTI_ORG = 'features.multiOrgEnabled != false'; + // The org-admin rows also carry `requiresMembershipReach`, whose lowering + // runs FIRST and puts the membership-grade term ahead of the feature gate: + // one `'' in current_user.positions` term per grade the + // reach table admits to the endpoint (ADR-0108 D1). Spelled out here rather + // than computed, so this matrix pins the bytes the console evaluates. + const INVITE = "('org_owner' in current_user.positions || 'org_admin' in current_user.positions || 'delegated_admin' in current_user.positions)"; + const ADMINS = "('org_owner' in current_user.positions || 'org_admin' in current_user.positions)"; + const OWNER = "'org_owner' in current_user.positions"; const rows: Array<[string, { actions?: readonly { name?: string; visible?: unknown; params?: readonly unknown[] }[] }, string, string]> = [ ['SysOrganization', SysOrganization, 'create_organization', MULTI_ORG], @@ -575,23 +583,25 @@ describe('feature-gate lowering matrix (#2874)', () => { ['SysOrganization', SysOrganization, 'set_active_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'leave_organization', MULTI_ORG], ['SysOrganization', SysOrganization, 'change_slug', MULTI_ORG], - ['SysUser', SysUser, 'invite_user', ORG], + ['SysUser', SysUser, 'invite_user', `${INVITE} && ${ORG}`], ['SysUser', SysUser, 'create_user', 'features.admin == true'], // [#11544] Third mirror of `invite_user` — the Members tab's own copy of // the email-invite entry. Same gate as the sys_user / sys_invitation rows. - ['SysMember', SysMember, 'invite_user', ORG], + ['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], - ['SysMember', SysMember, 'update_member_role', ORG], - ['SysMember', SysMember, 'remove_member', ORG], - ['SysMember', SysMember, 'transfer_ownership', `(has(record.role) && record.role != 'owner') && ${ORG}`], - ['SysInvitation', SysInvitation, 'invite_user', ORG], - ['SysInvitation', SysInvitation, 'cancel_invitation', ORG], - ['SysInvitation', SysInvitation, 'resend_invitation', ORG], - ['SysTeam', SysTeam, 'create_team', ORG], - ['SysTeam', SysTeam, 'update_team', ORG], - ['SysTeam', SysTeam, 'remove_team', ORG], - ['SysTeamMember', SysTeamMember, 'add_team_member', ORG], - ['SysTeamMember', SysTeamMember, 'remove_team_member', 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}`], + ['SysInvitation', SysInvitation, 'invite_user', `${INVITE} && ${ORG}`], + ['SysInvitation', SysInvitation, 'cancel_invitation', `${ADMINS} && ${ORG}`], + ['SysInvitation', SysInvitation, 'resend_invitation', `${INVITE} && ${ORG}`], + ['SysTeam', SysTeam, 'create_team', `${ADMINS} && ${ORG}`], + ['SysTeam', SysTeam, 'update_team', `${ADMINS} && ${ORG}`], + ['SysTeam', SysTeam, 'remove_team', `${ADMINS} && ${ORG}`], + ['SysTeamMember', SysTeamMember, 'add_team_member', `${ADMINS} && ${ORG}`], + ['SysTeamMember', SysTeamMember, 'remove_team_member', `${ADMINS} && ${ORG}`], // #2874 P2b — audit gates: capability-dependent actions that previously // shipped UNGATED (rendered even with the backing plugin off, then 404'd). ['SysUser', SysUser, 'ban_user', 'features.admin == true'], @@ -629,6 +639,7 @@ describe('feature-gate lowering matrix (#2874)', () => { for (const [, object] of systemObjects) { for (const action of object.actions ?? []) { expect(action).not.toHaveProperty('requiresFeature'); + expect(action).not.toHaveProperty('requiresMembershipReach'); for (const param of (action as { params?: readonly unknown[] }).params ?? []) { expect(param).not.toHaveProperty('requiresFeature'); } diff --git a/packages/plugins/plugin-auth/src/membership-reach-table.test.ts b/packages/plugins/plugin-auth/src/membership-reach-table.test.ts new file mode 100644 index 00000000000..171b67a5bc6 --- /dev/null +++ b/packages/plugins/plugin-auth/src/membership-reach-table.test.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The spec's membership reach table (`MEMBERSHIP_REACH`, + * `@objectstack/spec/identity`) pinned EQUAL to the door it describes. + * + * The table says which membership grades reach which better-auth organization + * endpoint, and the `requiresMembershipReach` action sugar lowers it into the + * `visible` predicate the console evaluates. A row that disagrees with the + * server fails one of two ways, both silent: a button offered to a grade the + * endpoint refuses (the 403 the table exists to stop offering), or a button + * hidden from a grade the endpoint admits. So the table is not trusted — it is + * recomputed here, per row, from the two things the door actually reads: + * + * 1. THE ROLES MAP plugin-auth hands better-auth — read off the organization + * plugin's real constructor options, not rebuilt in this file. It is + * better-auth's own `defaultRoles` plus the `delegated_admin` registration + * (`customOrgRoles` in auth-manager.ts). Each grade's statements are asked + * through the vendor's own `authorize`, the call `hasPermission` makes. + * 2. THE STATEMENT each endpoint checks — read off the installed vendor route + * source, so a vendor bump that changes which statement an endpoint's + * `hasPermission` call names turns this red instead of leaving a row stale. + * The creator-role rule (better-auth refuses to SET the creator role unless + * the caller holds it; default `owner`) is read the same way. + * + * Only `better-auth` itself and the organization plugin's factory are mocked, + * exactly as auth-manager.test.ts does; the access-control module is the real + * one, so the roles compared here are the objects the vendor would evaluate. + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + BUILTIN_MEMBERSHIP_ROLES, + MEMBERSHIP_REACH, + type MembershipReachName, +} from '@objectstack/spec/identity'; +import { AuthManager } from './auth-manager'; + +vi.mock('better-auth', () => ({ + betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })), +})); +vi.mock('better-auth/plugins/organization', () => ({ + organization: vi.fn((opts: any) => ({ id: 'organization', _opts: opts })), +})); +vi.mock('better-auth/plugins/two-factor', () => ({ + twoFactor: vi.fn((opts: any) => ({ id: 'two-factor', _opts: opts })), +})); +vi.mock('better-auth/plugins/magic-link', () => ({ + magicLink: vi.fn(() => ({ id: 'magic-link' })), +})); +vi.mock('better-auth/plugins/custom-session', () => ({ + customSession: vi.fn((fn: any) => ({ id: 'custom-session', _fn: fn })), +})); +vi.mock('better-auth/plugins/haveibeenpwned', () => ({ + haveIBeenPwned: vi.fn((opts: any) => ({ id: 'have-i-been-pwned', _opts: opts })), +})); + +import { betterAuth } from 'better-auth'; + +type Role = { authorize: (request: Record) => { success: boolean }; statements: Record }; + +/** The installed vendor's organization route sources, one text per route file. */ +function vendorRouteSources(): string[] { + const accessEntry = createRequire(import.meta.url).resolve('better-auth/plugins/organization/access'); + const routesDir = join(dirname(accessEntry), '..', 'routes'); + return readdirSync(routesDir) + .filter((f) => f.endsWith('.mjs')) + .map((f) => readFileSync(join(routesDir, f), 'utf8')); +} + +/** + * Each pathed endpoint's handler slice — from its `createAuthEndpoint(""` + * to the next endpoint declaration in the same file. A server-only endpoint + * (declared with no path) has no slice, which is exactly right: it is no HTTP + * door. + */ +function endpointSlices(sources: string[]): Map { + const slices = new Map(); + for (const source of sources) { + for (const m of source.matchAll(/createAuthEndpoint\(\s*"(\/organization\/[a-z-]+)"/g)) { + const next = source.indexOf('createAuthEndpoint(', m.index! + m[0].length); + slices.set(m[1], source.slice(m.index!, next === -1 ? source.length : next)); + } + } + return slices; +} + +/** Every `permissions: { : [""] }` statement a slice checks. */ +function checkedStatements(slice: string): string[] { + return [...slice.matchAll(/permissions:\s*\{\s*([a-z]+):\s*\[\s*"([a-z]+)"\s*\]\s*\}/g)].map((m) => `${m[1]}:${m[2]}`); +} + +describe('MEMBERSHIP_REACH equals the door plugin-auth configures', () => { + const prevMcpEnv = process.env.OS_MCP_SERVER_ENABLED; + let roles: Record; + let creatorRoleOption: unknown; + let slices: Map; + + beforeAll(async () => { + // The MCP surface is default-ON and would append jwt + oauth-provider; + // pinned off as in auth-manager.test.ts — it has nothing to do with org roles. + process.env.OS_MCP_SERVER_ENABLED = 'false'; + let captured: any; + (betterAuth as any).mockImplementation((config: any) => { + captured = config; + return { handler: vi.fn(), api: {} }; + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { organization: true }, + }); + await manager.getAuthInstance(); + warn.mockRestore(); + const orgPlugin = captured.plugins.find((p: any) => p.id === 'organization'); + roles = orgPlugin._opts.roles; + creatorRoleOption = orgPlugin._opts.creatorRole; + slices = endpointSlices(vendorRouteSources()); + }); + + afterAll(() => { + if (prevMcpEnv === undefined) delete process.env.OS_MCP_SERVER_ENABLED; + else process.env.OS_MCP_SERVER_ENABLED = prevMcpEnv; + }); + + it('the roles map registers exactly the closed membership vocabulary', () => { + // Every grade the table can name is judged by a REAL registered role; a + // grade missing from the map would authorize nothing and read as "no reach". + expect(roles).toBeDefined(); + expect(Object.keys(roles).sort()).toEqual([...BUILTIN_MEMBERSHIP_ROLES].sort()); + }); + + it('the route reader sees the vendor endpoints — guards the extraction itself', () => { + // A reader gone blind would make every per-row statement check below fail + // as "no slice" rather than pass, but pin the floor so the failure names + // the reader, not the table. + expect(slices.size).toBeGreaterThanOrEqual(20); + expect(checkedStatements(slices.get('/organization/invite-member') ?? '')).toEqual(['invitation:create']); + }); + + it('plugin-auth leaves the creator role at the vendor default, and the vendor default is owner', () => { + expect(creatorRoleOption).toBeUndefined(); + expect(slices.get('/organization/update-member-role') ?? '').toContain('creatorRole || "owner"'); + }); + + const rows = Object.entries(MEMBERSHIP_REACH) as Array<[MembershipReachName, (typeof MEMBERSHIP_REACH)[MembershipReachName]]>; + + it.each(rows)('%s: the endpoint is a vendor HTTP door checking exactly the row’s statement', (_name, row) => { + const slice = slices.get(row.endpoint); + expect(slice, `${row.endpoint} is a pathed vendor endpoint`).toBeDefined(); + expect(checkedStatements(slice!)).toEqual([`${row.statement.resource}:${row.statement.action}`]); + }); + + it.each(rows)('%s: the grades equal the ones whose registered statements pass that check', (_name, row) => { + const creatorRole = typeof creatorRoleOption === 'string' ? creatorRoleOption : 'owner'; + const admitted = BUILTIN_MEMBERSHIP_ROLES.filter((grade) => { + const statementPasses = roles[grade].authorize({ [row.statement.resource]: [row.statement.action] }).success; + const creatorRulePasses = !('creatorRoleOnly' in row && row.creatorRoleOnly) || grade === creatorRole; + return statementPasses && creatorRulePasses; + }); + const declared = BUILTIN_MEMBERSHIP_ROLES.filter((grade) => (row.grades as readonly string[]).includes(grade)); + expect(declared).toEqual(admitted); + // The table lists no grade outside the vocabulary (the filter above would hide one). + expect(row.grades.length).toBe(declared.length); + }); +}); 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 new file mode 100644 index 00000000000..73db537b3f7 --- /dev/null +++ b/packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts @@ -0,0 +1,315 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The org-admin affordances on the organization's member, invitation and team + * lists follow the caller's MEMBERSHIP GRADE — measured on a real boot, on the + * wire, from the same three things the console combines. + * + * The defect this pins: every one of these actions was gated on the + * `organization` capability alone, so a plain member was offered "Invite + * User", "Remove Member", "Create Team" … and the server then refused each with + * 403. The server was right; the button had no declared way to ask the same + * question. The actions now declare `requiresMembershipReach: ''`, + * which the spec lowers at parse time into `visible` over + * `current_user.positions` from ONE reach table (`MEMBERSHIP_REACH`) — the + * table plugin-auth's `membership-reach-table.test.ts` pins equal to the door. + * + * ## What this measures + * + * For four principals in one organization — the owner, an `admin`, a + * `delegated_admin` and a plain `member` — it reads: + * + * 1. the SERVED session face (`GET /auth/get-session`): the `positions` the + * console binds as `current_user`; + * 2. the SERVED capability flags (`GET /auth/config`): the `features` the + * console binds; + * 3. the SERVED action metadata (`GET /meta/object/`) of `sys_member`, + * `sys_invitation`, `sys_team`, `sys_team_member` and `sys_user`; + * + * and evaluates each served `visible` against them with + * `@objectstack/formula`'s `celEngine`, the engine the console itself uses + * (the same composite the approvals override pin uses). The expected sets are + * spelled out below rather than computed from the table, so a table that + * drifted would not grade itself. + * + * Both halves are asserted for every principal — what it IS offered and what it + * is NOT — because a pin of only the first would stay green if a predicate + * degenerated to a constant `true`, and only the second if it degenerated to + * `false`. A handful of door probes then show the verdicts are the door's own: + * a hidden affordance is one the server refuses, a shown one is one it admits. + * + * Harness note: `bootStack` disables the default-org bootstrap, so this file + * 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). + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { celEngine } from '@objectstack/formula'; + +const SYSTEM_CTX = { isSystem: true }; + +type Grade = 'owner' | 'admin' | 'delegated_admin' | 'member'; +type ServedAction = { name: string; visible?: unknown }; + +/** The org-admin affordances that call `/organization/invite-member`. */ +const INVITE_MEMBER = [ + 'sys_user.invite_user', + 'sys_member.invite_user', + 'sys_invitation.invite_user', + 'sys_invitation.resend_invitation', +] as const; +/** The ones whose endpoints better-auth grants to `owner` / `admin` only. */ +const ADMIN_ONLY = [ + 'sys_member.update_member_role', + 'sys_member.remove_member', + 'sys_invitation.cancel_invitation', + 'sys_team.create_team', + 'sys_team.update_team', + 'sys_team.remove_team', + 'sys_team_member.add_team_member', + 'sys_team_member.remove_team_member', +] as const; +/** Setting the creator role on a member: the owner alone. */ +const OWNER_ONLY = ['sys_member.transfer_ownership'] as const; +const ALL = [...INVITE_MEMBER, ...ADMIN_ONLY, ...OWNER_ONLY]; + +const EXPECTED: Record = { + owner: ALL, + admin: [...INVITE_MEMBER, ...ADMIN_ONLY], + delegated_admin: INVITE_MEMBER, + member: [], +}; + +/** The projected name each grade must carry on the session face. */ +const PROJECTED: Record = { + owner: 'org_owner', + admin: 'org_admin', + delegated_admin: 'delegated_admin', + member: 'org_member', +}; + +const OBJECTS = ['sys_member', 'sys_invitation', 'sys_team', 'sys_team_member', 'sys_user'] as const; + +async function findRows(ql: any, object: string, where: any, limit = 50): Promise { + const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX }); + return Array.isArray(rows) ? rows : (rows?.records ?? []); +} + +/** The sign-up reconciler's membership row lands after the signup transaction. */ +async function waitForMembership(ql: any, userId: string): Promise { + for (let i = 0; i < 40; i++) { + const rows = await findRows(ql, 'sys_member', { user_id: userId }, 5); + if (rows.length > 0) return rows[0]; + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`no sys_member row appeared for ${userId}`); +} + +describe('org-admin affordances follow the membership grade (served metadata × served session)', () => { + let stack: VerifyStack; + let ql: any; + let orgId: string; + const tokens = {} as Record; + const sessionUser = {} as Record>; + const served = {} as Record>; + let features: Record; + /** A representative row per object — the binding a row action is evaluated against. */ + const rowOf = {} as Record>; + let plainMemberRowId: string; + let pendingInvitationId: string; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, {}); + tokens.owner = await stack.signIn(); // the seeded dev admin + ql = await stack.kernel.getServiceAsync('objectql'); + + const org = await ql.insert('sys_organization', { name: 'Reach Org', slug: 'reach-org' }, { context: SYSTEM_CTX }); + orgId = String(org.id); + + const [ownerUser] = await findRows(ql, 'sys_user', { email: 'admin@objectos.ai' }, 1); + const ownerMembers = await findRows(ql, 'sys_member', { user_id: ownerUser.id }, 5); + if (ownerMembers.length > 0) { + await ql.update('sys_member', { id: ownerMembers[0].id, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } else { + await ql.insert('sys_member', { user_id: ownerUser.id, organization_id: orgId, role: 'owner' }, { context: SYSTEM_CTX }); + } + + for (const [grade, email] of [ + ['admin', 'reach.admin@example.com'], + ['delegated_admin', 'reach.delegate@example.com'], + ['member', 'reach.member@example.com'], + ] as const) { + tokens[grade] = await stack.signUp(email, 'Reach!Pass123', `Reach ${grade}`); + const [user] = await findRows(ql, 'sys_user', { email }, 1); + const membership = await waitForMembership(ql, String(user.id)); + expect(membership.role).toBe('member'); // the reconciler's default + if (grade !== 'member') { + await ql.update('sys_member', { id: membership.id, role: grade }, { context: SYSTEM_CTX }); + } else { + plainMemberRowId = String(membership.id); + } + } + + // 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', { + email: 'reach.pending@example.com', role: 'member', organizationId: orgId, + }); + expect(invite.status, await invite.clone().text()).toBe(200); + pendingInvitationId = String(((await invite.json()) as { id: string }).id); + const team = await stack.apiAs(tokens.owner, 'POST', '/auth/organization/create-team', { name: 'Reach Team', organizationId: orgId }); + expect(team.status, await team.clone().text()).toBe(200); + + const [memberRow] = await findRows(ql, 'sys_member', { id: plainMemberRowId }, 1); + const [invitationRow] = await findRows(ql, 'sys_invitation', { id: pendingInvitationId }, 1); + const [teamRow] = await findRows(ql, 'sys_team', { organization_id: orgId }, 1); + rowOf.sys_member = memberRow; + rowOf.sys_invitation = invitationRow; + rowOf.sys_team = teamRow; + rowOf.sys_team_member = { team_id: teamRow.id, user_id: memberRow.user_id }; + rowOf.sys_user = {}; + + const config = await stack.apiAs(tokens.member, 'GET', '/auth/config'); + expect(config.status).toBe(200); + features = ((await config.json()) as { data: { features: Record } }).data.features; + + for (const grade of Object.keys(PROJECTED) as Grade[]) { + const session = await stack.apiAs(tokens[grade], 'GET', '/auth/get-session'); + expect(session.status).toBe(200); + sessionUser[grade] = ((await session.json()) as { user: Record }).user; + served[grade] = new Map(); + for (const object of OBJECTS) { + const meta = await stack.apiAs(tokens[grade], 'GET', `/meta/object/${object}`); + expect(meta.status, `${grade} reads /meta/object/${object}`).toBe(200); + const body = (await meta.json()) as { item?: { actions?: ServedAction[] } }; + for (const action of body.item?.actions ?? []) served[grade].set(`${object}.${action.name}`, action); + } + } + }, 240_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + /** + * The grade gate's verdict for one principal: the predicate as SERVED (read + * from the owner's copy, which the metadata-plane field mask leaves whole), + * evaluated against THAT principal's served session and the served flags. + */ + const gateAdmits = (grade: Grade, site: string): boolean => { + const action = served.owner.get(site); + if (!action) throw new Error(`served metadata has no action ${site}`); + if (action.visible === undefined) return true; + const result = celEngine.evaluate(action.visible as never, { + record: rowOf[site.split('.')[0]], + user: sessionUser[grade] as never, + extra: { features }, + }); + if (!result.ok) throw new Error(`${site} faulted for ${grade}: ${JSON.stringify(result)}`); + return result.value === true; + }; + + /** + * What the console offers: an action served TO that principal whose served + * predicate admits it. The served copy is the principal's own — a different + * text from the owner's would be a defect this reads as such. + */ + const offered = (grade: Grade, site: string): boolean => { + const own = served[grade].get(site); + if (!own) return false; + expect(own.visible, `${site} serves one predicate to every grade`).toEqual(served.owner.get(site)?.visible); + return gateAdmits(grade, site); + }; + + /** + * The one site the metadata-plane field mask (ADR-0106) withholds below + * tenant-admin grade: `sys_user.invite_user`'s `role` param names + * `sys_user.role`, a field those callers cannot read, so the whole action is + * dropped from their `/meta/object/sys_user` — independently of this gate. + * Every other site must be served to every grade, or a verdict below would be + * the mask's rather than the gate's. + */ + const MASKED_BELOW_TENANT_ADMIN = ['sys_user.invite_user']; + + it('the session face carries each grade under its projected name, and no other grade', () => { + // The capability half of every composed predicate is ON here, so each + // verdict below is decided by the grade term alone. + expect(features.organization).toBe(true); + for (const [grade, projected] of Object.entries(PROJECTED) as Array<[Grade, string]>) { + const positions = sessionUser[grade].positions as string[]; + expect(positions, grade).toContain(projected); + for (const other of Object.values(PROJECTED).filter((p) => p !== projected)) { + expect(positions, `${grade} must not carry ${other}`).not.toContain(other); + } + } + }); + + it.each(Object.keys(EXPECTED) as Grade[])('the gate admits %s to exactly the org-admin affordances of its grade', (grade) => { + expect(ALL.filter((site) => gateAdmits(grade, site))).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site))); + }); + + it.each(Object.keys(EXPECTED) as Grade[])('%s is offered, on its own served metadata, exactly those it is served', (grade) => { + const unserved = ALL.filter((site) => !served[grade].has(site)); + const tenantAdmin = grade === 'owner' || grade === 'admin'; + expect(unserved).toEqual(tenantAdmin ? [] : MASKED_BELOW_TENANT_ADMIN); + const shown = ALL.filter((site) => offered(grade, site)); + expect(shown).toEqual(ALL.filter((site) => EXPECTED[grade].includes(site) && !unserved.includes(site))); + }); + + it('a plain member sees none of them on the member, invitation and team lists', () => { + for (const site of ALL) expect(offered('member', site), site).toBe(false); + }); + + it('add_member carries no grade term — its door is platform-admin standing, not a membership grade', () => { + const source = String((served.member.get('sys_member.add_member')?.visible as { source?: string })?.source); + expect(source).not.toContain('current_user.positions'); + }); + + it('each verdict matches the door: hidden means refused, shown means admitted', async () => { + /** A door refusal: 403 with the vendor's own code — never a bare status. */ + const refused = async (res: Response, code: string) => { + expect(res.status).toBe(403); + expect(((await res.json()) as { code?: string }).code).toBe(code); + }; + + // invite-member: the plain member is refused, the delegate is admitted. + await refused(await stack.apiAs(tokens.member, 'POST', '/auth/organization/invite-member', { + email: 'reach.from-member@example.com', role: 'member', organizationId: orgId, + }), 'YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION'); + const delegateInvite = await stack.apiAs(tokens.delegated_admin, 'POST', '/auth/organization/invite-member', { + email: 'reach.from-delegate@example.com', role: 'member', organizationId: orgId, + }); + expect(delegateInvite.status, await delegateInvite.clone().text()).toBe(200); + + // resend IS invite-member: the delegate is admitted there too. + const delegateResend = await stack.apiAs(tokens.delegated_admin, 'POST', '/auth/organization/invite-member', { + email: 'reach.pending@example.com', role: 'member', organizationId: orgId, resend: true, + }); + expect(delegateResend.status, await delegateResend.clone().text()).toBe(200); + + // cancel-invitation: the delegate is refused (`create` without `cancel`). + await refused(await stack.apiAs(tokens.delegated_admin, 'POST', '/auth/organization/cancel-invitation', { + invitationId: pendingInvitationId, + }), 'YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION'); + + // create-team: the plain member and the delegate are refused, the admin admitted. + await refused( + await stack.apiAs(tokens.member, 'POST', '/auth/organization/create-team', { name: 'From Member', organizationId: orgId }), + 'YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION', + ); + await refused( + await stack.apiAs(tokens.delegated_admin, 'POST', '/auth/organization/create-team', { name: 'From Delegate', organizationId: orgId }), + 'YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION', + ); + const adminTeam = await stack.apiAs(tokens.admin, 'POST', '/auth/organization/create-team', { name: 'From Admin', organizationId: orgId }); + expect(adminTeam.status, await adminTeam.clone().text()).toBe(200); + + // transfer ownership (set the creator role): the admin is refused. + await refused(await stack.apiAs(tokens.admin, 'POST', '/auth/organization/update-member-role', { + memberId: plainMemberRowId, role: 'owner', organizationId: orgId, + }), 'YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER'); + }, 60_000); +}); diff --git a/packages/spec/api-surface/identity.json b/packages/spec/api-surface/identity.json index 2c52656d64f..dd8319f07da 100644 --- a/packages/spec/api-surface/identity.json +++ b/packages/spec/api-surface/identity.json @@ -32,12 +32,17 @@ "InvitationSchema (const)", "InvitationStatus (const)", "InvitationStatus (type)", + "MEMBERSHIP_REACH (const)", + "MEMBERSHIP_REACH_NAMES (const)", "MEMBERSHIP_ROLE_ADMIN (const)", "MEMBERSHIP_ROLE_DELEGATED_ADMIN (const)", "MEMBERSHIP_ROLE_MEMBER (const)", "MEMBERSHIP_ROLE_OWNER (const)", "Member (type)", "MemberSchema (const)", + "MembershipReachEntry (type)", + "MembershipReachName (type)", + "MembershipReachStatement (type)", "ORGANIZATION_ADMIN (const)", "ORGANIZATION_ADMIN_GRANTS (const)", "ORGANIZATION_ADMIN_NO_BYPASS (const)", @@ -103,7 +108,9 @@ "VerificationTokenSchema (const)", "createEvalUser (function)", "definePosition (function)", + "lowerRequiresMembershipReach (function)", "mapMembershipRole (function)", + "membershipReachPredicate (function)", "positionForm (const)" ] } diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 43f54526a8a..ed790fd2b5d 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -43,6 +43,7 @@ "ui/Action:refreshAfter", "ui/Action:requiredPermissions", "ui/Action:requiresFeature", + "ui/Action:requiresMembershipReach", "ui/Action:resultDialog", "ui/Action:shortcut [RETIRED]", "ui/Action:successMessage", diff --git a/packages/spec/export-origins/identity.json b/packages/spec/export-origins/identity.json index 3cd97e694e8..d495e62f1f6 100644 --- a/packages/spec/export-origins/identity.json +++ b/packages/spec/export-origins/identity.json @@ -31,12 +31,17 @@ "InvitationParsed": "src/identity/organization.zod.ts#InvitationParsed (type)", "InvitationSchema": "src/identity/organization.zod.ts#InvitationSchema (const)", "InvitationStatus": "src/identity/organization.zod.ts#InvitationStatus (type)", + "MEMBERSHIP_REACH": "src/identity/membership-reach.ts#MEMBERSHIP_REACH (const)", + "MEMBERSHIP_REACH_NAMES": "src/identity/membership-reach.ts#MEMBERSHIP_REACH_NAMES (const)", "MEMBERSHIP_ROLE_ADMIN": "src/identity/membership-role.ts#MEMBERSHIP_ROLE_ADMIN (const)", "MEMBERSHIP_ROLE_DELEGATED_ADMIN": "src/identity/membership-role.ts#MEMBERSHIP_ROLE_DELEGATED_ADMIN (const)", "MEMBERSHIP_ROLE_MEMBER": "src/identity/membership-role.ts#MEMBERSHIP_ROLE_MEMBER (const)", "MEMBERSHIP_ROLE_OWNER": "src/identity/membership-role.ts#MEMBERSHIP_ROLE_OWNER (const)", "Member": "src/identity/organization.zod.ts#Member (type)", "MemberSchema": "src/identity/organization.zod.ts#MemberSchema (const)", + "MembershipReachEntry": "src/identity/membership-reach.ts#MembershipReachEntry (type)", + "MembershipReachName": "src/identity/membership-reach.ts#MembershipReachName (type)", + "MembershipReachStatement": "src/identity/membership-reach.ts#MembershipReachStatement (type)", "ORGANIZATION_ADMIN": "src/identity/eval-user.zod.ts#ORGANIZATION_ADMIN (const)", "ORGANIZATION_ADMIN_GRANTS": "src/identity/eval-user.zod.ts#ORGANIZATION_ADMIN_GRANTS (const)", "ORGANIZATION_ADMIN_NO_BYPASS": "src/identity/eval-user.zod.ts#ORGANIZATION_ADMIN_NO_BYPASS (const)", @@ -102,7 +107,9 @@ "VerificationTokenSchema": "src/identity/identity.zod.ts#VerificationTokenSchema (const)", "createEvalUser": "src/identity/eval-user.zod.ts#createEvalUser (function)", "definePosition": "src/identity/position.zod.ts#definePosition (function)", + "lowerRequiresMembershipReach": "src/identity/membership-reach.ts#lowerRequiresMembershipReach (function)", "mapMembershipRole": "src/identity/eval-user.zod.ts#mapMembershipRole (function)", + "membershipReachPredicate": "src/identity/membership-reach.ts#membershipReachPredicate (function)", "positionForm": "src/identity/position.form.ts#positionForm (const)" } } diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 13d57f9572b..ac857e1d6f7 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -139,6 +139,12 @@ "evidence": "packages/spec/src/kernel/public-auth-features.ts (lowerRequiresFeature), wired as a .transform() in packages/spec/src/ui/action.zod.ts", "note": "Author-facing sugar, consumed AT PARSE TIME: lowered into the (already-live) `visible` predicate per the PUBLIC_AUTH_FEATURES registry semantics and stripped from the output, so no downstream consumer ever sees it. Guarded by the #2874 drift + completeness tests (plugin-auth public-feature-registry.test.ts, platform-objects feature-gate-guard.test.ts)." }, + "requiresMembershipReach": { + "status": "live", + "verifiedAt": "2026-10-05", + "evidence": "packages/spec/src/identity/membership-reach.ts#lowerRequiresMembershipReach (the lowering, composed inside the ActionSchema .transform() in packages/spec/src/ui/action.zod.ts, ahead of lowerRequiresFeature)", + "note": "Author-facing sugar, consumed AT PARSE TIME exactly as `requiresFeature` is: lowered into the (already-live) `visible` predicate over `current_user.positions` from the MEMBERSHIP_REACH table (one term per grade that reaches the named organization endpoint, in the names mapMembershipRole projects them to) and stripped from the output, so no downstream consumer ever sees the key. The table is pinned equal to the roles map plugin-auth hands better-auth by packages/plugins/plugin-auth/src/membership-reach-table.test.ts; packages/qa/dogfood/test/org-admin-affordance-reach.dogfood.test.ts boots the showcase and evaluates the served predicates against each grade's served session." + }, "requiredPermissions": { "status": "live", "verifiedAt": "2026-08-28", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 96423e673f4..06343af3cec 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 46 | 0 | 0 | 4 | 0 | 50 | +| `action` | 47 | 0 | 0 | 4 | 0 | 51 | diff --git a/packages/spec/src/identity/index.ts b/packages/spec/src/identity/index.ts index 16c4fbe6cb2..7f43ce43027 100644 --- a/packages/spec/src/identity/index.ts +++ b/packages/spec/src/identity/index.ts @@ -11,3 +11,7 @@ export * from './eval-user.zod'; // gatekeepers (better-auth's role registry and the `sys_invitation` / // `sys_member` role selects). Organization grade only; capability = position. export * from './membership-role'; +// ADR-0108 D1 — the fourth fact beside the three above: which membership grade +// reaches which better-auth organization endpoint, and the action sugar lowered +// from it. Reach, not authority; never merged into the name list. +export * from './membership-reach'; diff --git a/packages/spec/src/identity/membership-reach.test.ts b/packages/spec/src/identity/membership-reach.test.ts new file mode 100644 index 00000000000..8263a7d3485 --- /dev/null +++ b/packages/spec/src/identity/membership-reach.test.ts @@ -0,0 +1,192 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; +import { + MEMBERSHIP_REACH, + MEMBERSHIP_REACH_NAMES, + lowerRequiresMembershipReach, + membershipReachPredicate, +} from './membership-reach'; +import { BUILTIN_MEMBERSHIP_ROLES } from './membership-role'; + +// The table's own invariants. Its equality with what better-auth's door +// actually admits lives with the producer, in plugin-auth +// (`membership-reach-table.test.ts`) — this file checks the table and the +// lowering in isolation. +describe('MEMBERSHIP_REACH table', () => { + it('names every row in the sugar enum, and nothing else', () => { + expect([...MEMBERSHIP_REACH_NAMES].sort()).toEqual(Object.keys(MEMBERSHIP_REACH).sort()); + }); + + it('row names are snake_case machine names', () => { + for (const name of MEMBERSHIP_REACH_NAMES) expect(name).toMatch(/^[a-z][a-z0-9_]*$/); + }); + + it('every row lists only grades from the closed membership vocabulary, without repeats', () => { + const vocabulary: readonly string[] = BUILTIN_MEMBERSHIP_ROLES; + for (const [name, row] of Object.entries(MEMBERSHIP_REACH)) { + expect(row.grades.length, name).toBeGreaterThan(0); + expect(new Set(row.grades).size, name).toBe(row.grades.length); + for (const grade of row.grades) expect(vocabulary, `${name}: ${grade}`).toContain(grade); + } + }); + + it('a plain member reaches no row — the grade the gate exists to exclude', () => { + for (const [name, row] of Object.entries(MEMBERSHIP_REACH)) { + expect(row.grades as readonly string[], name).not.toContain('member'); + } + }); + + it('every row names a better-auth organization endpoint', () => { + for (const row of Object.values(MEMBERSHIP_REACH)) expect(row.endpoint).toMatch(/^\/organization\/[a-z-]+$/); + }); +}); + +describe('membershipReachPredicate', () => { + it('projects each grade through mapMembershipRole into one positions term', () => { + expect(membershipReachPredicate('invite_member')).toBe( + "'org_owner' in current_user.positions || 'org_admin' in current_user.positions" + + " || 'delegated_admin' in current_user.positions", + ); + expect(membershipReachPredicate('remove_member')).toBe( + "'org_owner' in current_user.positions || 'org_admin' in current_user.positions", + ); + expect(membershipReachPredicate('transfer_ownership')).toBe("'org_owner' in current_user.positions"); + }); + + it('never emits a raw better-auth grade name — positions carries the projected names', () => { + for (const name of MEMBERSHIP_REACH_NAMES) { + const source = membershipReachPredicate(name); + expect(source).not.toMatch(/'(owner|admin)' in/); + } + }); +}); + +describe('lowerRequiresMembershipReach', () => { + const collect = () => { + const issues: unknown[] = []; + return { + ctx: { addIssue: (i: unknown) => issues.push(i) } as never, + issues, + }; + }; + const ADMIN_GATE = "'org_owner' in current_user.positions || 'org_admin' in current_user.positions"; + const OWNER_GATE = "'org_owner' in current_user.positions"; + + it('passes through untouched when the sugar is absent', () => { + const { ctx, issues } = collect(); + const input = { name: 'x', visible: { dialect: 'cel', source: 'record.a == 1' } }; + expect(lowerRequiresMembershipReach(input, ctx)).toEqual(input); + expect(issues).toHaveLength(0); + }); + + it('emits the bare gate when no visible exists, and strips the sugar key', () => { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach({ name: 'x', requiresMembershipReach: 'remove_member' as const }, ctx); + expect(out).toEqual({ name: 'x', visible: { dialect: 'cel', source: ADMIN_GATE } }); + expect('requiresMembershipReach' in out).toBe(false); + expect(issues).toHaveLength(0); + }); + + it('treats `visible: true` as the explicit default — the gate alone', () => { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach( + { visible: true, requiresMembershipReach: 'remove_member' as const }, + ctx, + ); + expect(out.visible).toEqual({ dialect: 'cel', source: ADMIN_GATE }); + expect(issues).toHaveLength(0); + }); + + it('composes with an existing CEL visible — existing first, a multi-term gate parenthesised last', () => { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach( + { + requiresMembershipReach: 'remove_member' as const, + visible: { dialect: 'cel', source: "has(record.role) && record.role != 'owner'" }, + }, + ctx, + ); + expect(out.visible).toEqual({ + dialect: 'cel', + source: `(has(record.role) && record.role != 'owner') && (${ADMIN_GATE})`, + }); + expect(issues).toHaveLength(0); + }); + + it('leaves a single-term gate unparenthesised when composing', () => { + const { ctx } = collect(); + const out = lowerRequiresMembershipReach( + { + requiresMembershipReach: 'transfer_ownership' as const, + visible: { dialect: 'cel', source: "has(record.role) && record.role != 'owner'" }, + }, + ctx, + ); + expect(out.visible).toEqual({ + dialect: 'cel', + source: `(has(record.role) && record.role != 'owner') && ${OWNER_GATE}`, + }); + }); + + it('preserves envelope extras (meta) when composing', () => { + const { ctx } = collect(); + const out = lowerRequiresMembershipReach( + { + requiresMembershipReach: 'transfer_ownership' as const, + visible: { dialect: 'cel', source: 'a', meta: { rationale: 'r' } }, + }, + ctx, + ); + expect(out.visible).toEqual({ dialect: 'cel', source: `(a) && ${OWNER_GATE}`, meta: { rationale: 'r' } }); + }); + + it('refuses `visible: false` loudly — the gate could never take effect (ADR-0078)', () => { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach( + { visible: false, requiresMembershipReach: 'invite_member' as const }, + ctx, + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['requiresMembershipReach'] }); + expect(out.visible).toBe(false); + expect('requiresMembershipReach' in out).toBe(false); + }); + + it('refuses an AST-only or non-CEL visible loudly (ADR-0078)', () => { + for (const visible of [ + { dialect: 'cel', ast: { kind: 'literal' } }, + { dialect: 'js', source: 'true' }, + ]) { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach({ requiresMembershipReach: 'invite_member' as const, visible }, ctx); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['requiresMembershipReach'] }); + expect(out.visible).toEqual(visible); + } + }); + + it('refuses a CEL visible whose source is blank after trimming (ADR-0078)', () => { + for (const visible of [ + { dialect: 'cel', source: ' ' }, + { dialect: 'cel', source: '' }, + { dialect: 'cel', source: '\n\t' }, + ]) { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach({ requiresMembershipReach: 'invite_member' as const, visible }, ctx); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ code: 'custom', path: ['requiresMembershipReach'] }); + expect(out.visible).toEqual(visible); + } + }); + + it('still composes a source padded with whitespace around real text', () => { + const { ctx, issues } = collect(); + const out = lowerRequiresMembershipReach( + { requiresMembershipReach: 'transfer_ownership' as const, visible: { dialect: 'cel', source: ' a ' } }, + ctx, + ); + expect(issues).toHaveLength(0); + expect(out.visible).toEqual({ dialect: 'cel', source: `( a ) && ${OWNER_GATE}` }); + }); +}); diff --git a/packages/spec/src/identity/membership-reach.ts b/packages/spec/src/identity/membership-reach.ts new file mode 100644 index 00000000000..ca5952ca04f --- /dev/null +++ b/packages/spec/src/identity/membership-reach.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { z } from 'zod'; +import { + BUILTIN_MEMBERSHIP_ROLES, + MEMBERSHIP_ROLE_ADMIN, + MEMBERSHIP_ROLE_DELEGATED_ADMIN, + MEMBERSHIP_ROLE_OWNER, + type BuiltinMembershipRole, +} from './membership-role'; +import { mapMembershipRole } from './eval-user.zod'; + +/** + * # Membership reach — which grade reaches which organization endpoint + * + * `membership-role.ts` keeps three facts apart that look like one (what names + * exist · which names mean administrative authority · how a name projects into + * an identity). This module is a FOURTH, and it is not merged into any of them + * (ADR-0108 D4): + * + * 4. **which membership grades reach which better-auth organization + * endpoint** — the table below. + * + * Reach is not authority (ADR-0108 D1: a grade decides what a principal may + * REACH). `delegated_admin` reaches `/organization/invite-member` and is still + * not an administrative grade; what the endpoint then permits is decided by its + * own door — better-auth's statement check, plus plugin-auth's invitation role + * cap and the ADR-0105 D8 placement gate behind it. + * + * ## Where the rows come from + * + * They are read off better-auth's own access-control statements — the + * `defaultRoles` that `better-auth/plugins/organization/access` exports — plus + * the one role plugin-auth registers beside them, `delegated_admin` + * (`invitation: ['create']` on top of a plain member's statements). Each row + * names the statement the endpoint's door checks with `hasPermission`, and the + * grades whose statements carry it. Two rules on top of the statements: + * + * - `creatorRoleOnly` — better-auth refuses to SET the creator role (`owner`, + * its `creatorRole` default) on a member unless the caller holds it, whatever + * their statements say. Ownership transfer is that request shape. + * - Endpoints no grade gates are not rows. `/organization/add-member` is + * ObjectStack's mount over the vendor's server-only `addMember`, gated on + * platform-admin standing (ADR-0068), not on a membership grade. + * + * plugin-auth's `membership-reach-table.test.ts` pins every row equal to the + * roles map plugin-auth actually hands better-auth, and each row's statement + * equal to the check the installed vendor endpoint performs — so a vendor bump + * or a registration change that moves the door turns that test red instead of + * leaving this table quietly wrong. + * + * ## What reads it + * + * The `requiresMembershipReach` sugar on actions (`ui/action.zod.ts`): lowered + * at parse time by {@link lowerRequiresMembershipReach} into the canonical + * `visible` predicate over `current_user.positions`, using the names + * `mapMembershipRole` projects the grades to (`org_owner`, `org_admin`, + * `delegated_admin`). The capability channel (`current_user.can`) stays + * grade-free, as ADR-0108 rules. + * + * It is UI courtesy, not authorization: the button follows the door, and the + * door stays the authority (ADR-0066 D4 — for a `type: 'api'` action the server + * half is the endpoint's own check). + * + * This module carries constants and pure lowering helpers only (Prime + * Directive #2), and imports nothing beyond its two sibling identity modules, + * so schema modules can depend on it file-directly. + */ + +/** A better-auth organization access-control statement: one resource, one action. */ +export type MembershipReachStatement = { + /** The resource key in better-auth's organization `defaultStatements`. */ + resource: 'organization' | 'member' | 'invitation' | 'team' | 'ac'; + /** The action on that resource the endpoint's door checks. */ + action: string; +}; + +export type MembershipReachEntry = { + /** The better-auth organization endpoint, relative to the auth base path. */ + endpoint: `/organization/${string}`; + /** The statement the endpoint's door checks with `hasPermission`. */ + statement: MembershipReachStatement; + /** + * The request also passes better-auth's creator-role rule: setting the + * creator role on a member is refused unless the caller holds it. + */ + creatorRoleOnly?: true; + /** The grades that reach it, in {@link BUILTIN_MEMBERSHIP_ROLES} order. */ + grades: readonly BuiltinMembershipRole[]; +}; + +const OWNER_ADMIN = [MEMBERSHIP_ROLE_OWNER, MEMBERSHIP_ROLE_ADMIN] as const; + +/** + * The reach table. Keys are the names the `requiresMembershipReach` sugar + * accepts; each names one grade-gated organization request. + */ +export const MEMBERSHIP_REACH = { + invite_member: { + endpoint: '/organization/invite-member', + statement: { resource: 'invitation', action: 'create' }, + grades: [MEMBERSHIP_ROLE_OWNER, MEMBERSHIP_ROLE_ADMIN, MEMBERSHIP_ROLE_DELEGATED_ADMIN], + }, + cancel_invitation: { + endpoint: '/organization/cancel-invitation', + statement: { resource: 'invitation', action: 'cancel' }, + grades: OWNER_ADMIN, + }, + update_member_role: { + endpoint: '/organization/update-member-role', + statement: { resource: 'member', action: 'update' }, + grades: OWNER_ADMIN, + }, + transfer_ownership: { + endpoint: '/organization/update-member-role', + statement: { resource: 'member', action: 'update' }, + creatorRoleOnly: true, + grades: [MEMBERSHIP_ROLE_OWNER], + }, + remove_member: { + endpoint: '/organization/remove-member', + statement: { resource: 'member', action: 'delete' }, + grades: OWNER_ADMIN, + }, + create_team: { + endpoint: '/organization/create-team', + statement: { resource: 'team', action: 'create' }, + grades: OWNER_ADMIN, + }, + update_team: { + endpoint: '/organization/update-team', + statement: { resource: 'team', action: 'update' }, + grades: OWNER_ADMIN, + }, + remove_team: { + endpoint: '/organization/remove-team', + statement: { resource: 'team', action: 'delete' }, + grades: OWNER_ADMIN, + }, + add_team_member: { + endpoint: '/organization/add-team-member', + statement: { resource: 'member', action: 'update' }, + grades: OWNER_ADMIN, + }, + remove_team_member: { + endpoint: '/organization/remove-team-member', + statement: { resource: 'member', action: 'delete' }, + grades: OWNER_ADMIN, + }, +} as const satisfies Record; + +export type MembershipReachName = keyof typeof MEMBERSHIP_REACH; + +/** Tuple of table keys — feeds `z.enum(...)` for the `requiresMembershipReach` sugar. */ +export const MEMBERSHIP_REACH_NAMES = Object.keys(MEMBERSHIP_REACH) as [ + MembershipReachName, + ...MembershipReachName[], +]; + +/** + * The canonical CEL gate for a row: one `'' in current_user.positions` + * term per grade, joined with `||`, where `` is what `mapMembershipRole` + * projects the grade to. Grade order follows {@link BUILTIN_MEMBERSHIP_ROLES}, + * so the text is stable whatever order a row lists its grades in. + */ +export function membershipReachPredicate(name: MembershipReachName): string { + const grades: readonly string[] = MEMBERSHIP_REACH[name].grades; + return BUILTIN_MEMBERSHIP_ROLES + .filter((grade) => grades.includes(grade)) + .map((grade) => `'${mapMembershipRole(grade)}' in current_user.positions`) + .join(' || '); +} + +/** Object shape the lowering transform operates on (post field-level parse). */ +type WithRequiresMembershipReach = { + requiresMembershipReach?: MembershipReachName; + /** Normalized to the `{dialect, source}` envelope, or the boolean literal arm. */ + visible?: boolean | ({ dialect?: unknown; source?: unknown } & Record); +}; + +/** + * Lower the declarative `requiresMembershipReach: ''` sugar into the + * canonical `visible` CEL predicate and strip the sugar key from the output — + * the mechanism of `lowerRequiresFeature` (`kernel/public-auth-features.ts`), + * branch for branch, so persisted artifacts, lint, runtime and objectui only + * ever see the canonical envelope: + * + * - No existing `visible`, or `visible: true` → the gate alone. + * - Existing CEL `visible` with a non-blank `source` → `() && `, + * the gate parenthesised when it has more than one term. + * - Existing `visible: false` → loud parse error: `false && ` is `false` + * whatever the caller's grade, so the gate could never take effect (ADR-0078). + * - Existing `visible` that is non-CEL or AST-only → loud parse error. + * - Existing CEL `visible` whose `source` is blank after trimming → loud parse + * error: composing onto it would parenthesise nothing. + * + * Wired on `ActionSchema` AHEAD of the `requiresFeature` lowering, so a feature + * gate stays the last term of the composed predicate. + */ +export function lowerRequiresMembershipReach( + input: T, + ctx: z.core.$RefinementCtx, +): Omit { + const { requiresMembershipReach, ...rest } = input; + if (requiresMembershipReach === undefined) return rest as Omit; + + const gate = membershipReachPredicate(requiresMembershipReach); + // Annotated rather than inferred, for the reason `lowerRequiresFeature` gives: + // `rest.visible` is a deferred indexed access control flow cannot narrow. + const existing: WithRequiresMembershipReach['visible'] = rest.visible; + if (existing === undefined || existing === true) { + return { ...rest, visible: { dialect: 'cel', source: gate } } as Omit; + } + if (existing === false) { + ctx.addIssue({ + code: 'custom', + path: ['requiresMembershipReach'], + message: + '`requiresMembershipReach` cannot compose with `visible: false` — the literal already hides this ' + + 'unconditionally, so the membership gate can never take effect. Drop `requiresMembershipReach` to ' + + 'keep it hidden, or drop `visible: false` to let the caller\'s membership grade decide.', + }); + return rest as Omit; + } + if (existing.dialect !== 'cel' || typeof existing.source !== 'string') { + ctx.addIssue({ + code: 'custom', + path: ['requiresMembershipReach'], + message: + '`requiresMembershipReach` composes only with a CEL `visible` carrying a `source` string; ' + + 'this expression is AST-only or non-CEL — write the combined predicate by hand.', + }); + return rest as Omit; + } + if (existing.source.trim().length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['requiresMembershipReach'], + message: + '`requiresMembershipReach` composes only with a CEL `visible` carrying a NON-BLANK `source`; this ' + + '`source` is blank after trimming, so composing the membership gate onto it would parenthesise ' + + 'nothing, and no CEL parse accepts the result on any scope. The gate would fault at evaluation ' + + 'instead of gating, so the caller\'s grade would decide nothing. Drop the blank `visible` and ' + + '`requiresMembershipReach` emits the gate alone, or put the predicate the gate should compose ' + + 'with in `source`.', + }); + return rest as Omit; + } + const term = MEMBERSHIP_REACH[requiresMembershipReach].grades.length > 1 ? `(${gate})` : gate; + return { + ...rest, + visible: { ...existing, source: `(${existing.source}) && ${term}` }, + } as Omit; +} diff --git a/packages/spec/src/ui/action-requires-membership-reach.test.ts b/packages/spec/src/ui/action-requires-membership-reach.test.ts new file mode 100644 index 00000000000..8c7e0984bf6 --- /dev/null +++ b/packages/spec/src/ui/action-requires-membership-reach.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// The `requiresMembershipReach` sugar as `ActionSchema` wires it: lowered at +// parse time into `visible`, stripped from the output, and ordered ahead of +// the `requiresFeature` lowering so a feature gate stays the last term. The +// lowering's own branches are pinned beside the table +// (`identity/membership-reach.test.ts`); this file pins the wiring. + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { ActionParamSchema, ActionSchema } from './action.zod'; + +const INVITE_GATE = + "'org_owner' in current_user.positions || 'org_admin' in current_user.positions" + + " || 'delegated_admin' in current_user.positions"; +const OWNER_GATE = "'org_owner' in current_user.positions"; + +const invite = { + name: 'invite_user', + label: 'Invite', + type: 'api', + target: '/api/v1/auth/organization/invite-member', +} as const satisfies Partial>; + +describe('ActionSchema — requiresMembershipReach', () => { + it('lowers to the reach predicate and does not survive into the parsed action', () => { + const result = ActionSchema.parse({ ...invite, requiresMembershipReach: 'invite_member' }); + expect(result.visible).toEqual({ dialect: 'cel', source: INVITE_GATE }); + expect(result).not.toHaveProperty('requiresMembershipReach'); + }); + + it('composes ahead of requiresFeature — the feature gate stays the last term', () => { + const result = ActionSchema.parse({ + ...invite, + requiresMembershipReach: 'invite_member', + requiresFeature: 'organization', + }); + expect(result.visible).toEqual({ + dialect: 'cel', + source: `(${INVITE_GATE}) && features.organization != false`, + }); + expect(result).not.toHaveProperty('requiresMembershipReach'); + expect(result).not.toHaveProperty('requiresFeature'); + }); + + it('composes onto an authored record predicate, then the feature gate onto both', () => { + const result = ActionSchema.parse({ + name: 'transfer_ownership', + label: 'Transfer Ownership', + type: 'api', + target: '/api/v1/auth/organization/update-member-role', + visible: "has(record.role) && record.role != 'owner'", + requiresMembershipReach: 'transfer_ownership', + requiresFeature: 'organization', + }); + expect(result.visible).toEqual({ + dialect: 'cel', + source: `((has(record.role) && record.role != 'owner') && ${OWNER_GATE}) && features.organization != false`, + }); + }); + + it('refuses composition with `visible: false` at the sugar key', () => { + const result = ActionSchema.safeParse({ ...invite, visible: false, requiresMembershipReach: 'invite_member' }); + expect(result.success).toBe(false); + const issue = result.success + ? undefined + : result.error.issues.find((i) => i.path.join('.') === 'requiresMembershipReach'); + expect(issue).toMatchObject({ code: 'custom', path: ['requiresMembershipReach'] }); + }); + + it('refuses a name that is not a reach-table row, at the sugar key', () => { + const result = ActionSchema.safeParse({ ...invite, requiresMembershipReach: 'invite_members' }); + expect(result.success).toBe(false); + const issue = result.success + ? undefined + : result.error.issues.find((i) => i.path.join('.') === 'requiresMembershipReach'); + expect(issue).toMatchObject({ code: 'invalid_value', path: ['requiresMembershipReach'] }); + }); + + it('is an action-level key only — a param refuses it as unknown', () => { + const result = ActionParamSchema.safeParse({ name: 'email', requiresMembershipReach: 'invite_member' }); + expect(result.success).toBe(false); + expect(result.success ? [] : result.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); + }); + + it('leaves an action without the sugar untouched', () => { + const result = ActionSchema.parse({ ...invite, requiresFeature: 'organization' }); + expect(result.visible).toEqual({ dialect: 'cel', source: 'features.organization != false' }); + }); +}); diff --git a/packages/spec/src/ui/action.form.ts b/packages/spec/src/ui/action.form.ts index 190955c0a05..527c1a60ffb 100644 --- a/packages/spec/src/ui/action.form.ts +++ b/packages/spec/src/ui/action.form.ts @@ -141,6 +141,9 @@ export const actionForm = defineForm({ // names, so an inline list would only restate them and could drift from // the registry the parse step resolves against. { field: 'requiresFeature', helpText: 'Public auth feature flag gating this action. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key.' }, + // The grade twin of the row above, and its enum derives the same way: + // every member IS a row of the reach table the parse step lowers from. + { field: 'requiresMembershipReach', helpText: 'The organization endpoint this action calls, so it is offered only to members whose grade reaches that endpoint. It is lowered into the `visible` predicate at parse time and stripped from the output, so no downstream consumer ever sees the key.' }, // #19332 G1b — the RBAC gate, beside the feature gate. `string[]`, so the // `app.form.ts` `requiredPermissions` face (`string-tags`). { field: 'requiredPermissions', widget: 'string-tags', helpText: 'Capabilities (permission-set systemPermissions) a caller must hold — every one listed — to invoke this action (ADR-0066 D4). The platform action route refuses anyone else with 403 (script, flow and modal actions, and the MCP/AI path), and the button is hidden from them. A type api action calls its endpoint directly, so that endpoint must re-check them.' }, diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 38986cbb574..780d2b7a0de 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -28,6 +28,7 @@ import { HookBodySchema } from '../data/hook-body.zod'; // Imported file-directly (not via the kernel barrel): the module is // deliberately import-free, so this cannot introduce a cycle. import { PUBLIC_AUTH_FEATURE_NAMES, lowerRequiresFeature } from '../kernel/public-auth-features'; +import { MEMBERSHIP_REACH_NAMES, lowerRequiresMembershipReach } from '../identity/membership-reach'; import { strictUnknownKeyError } from '../shared/suggestions.zod'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; @@ -1481,6 +1482,16 @@ const actionObject = () => strictObject({ * `@objectstack/spec/kernel`. */ requiresFeature: z.enum(PUBLIC_AUTH_FEATURE_NAMES).optional().describe('Public auth feature flag gating this action; lowered into `visible` at parse time.'), + /** + * Declarative membership-grade gate (ADR-0108 D1) — the grade twin of + * `requiresFeature`. Names a row of `MEMBERSHIP_REACH` (`@objectstack/spec/identity`): + * the organization endpoint this action calls. Lowered at parse time into + * `visible` over `current_user.positions` — one term per grade that reaches + * the endpoint, in the names `mapMembershipRole` projects them to — + * AND-composed with an explicit `visible`, and stripped from the output. + * UI courtesy: the endpoint's own door stays the authority. + */ + requiresMembershipReach: z.enum(MEMBERSHIP_REACH_NAMES).optional().describe('Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time.'), /** * Whether the action is offered but refused. Same three arms as `visible` * ({@link ActionConditionInputSchema}) — a disabled action stays on screen @@ -2217,7 +2228,12 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { path: ['undoable'], }).superRefine(refuseDeclarativeUpdateContradictions) .superRefine(refuseInertOutcomeMessages) - .transform((data, ctx) => lowerRequiresFeature(data, ctx))); + // Grade gate first, feature gate last: `requiresFeature` composes onto + // whatever `visible` it finds, so this order keeps `features.*` the final + // term — the convention the platform-objects feature-gate guard reads. One + // transform, not two: a second pipe stage would move every refinement this + // chain carries one level deeper in the emitted schema graph. + .transform((data, ctx) => lowerRequiresFeature(lowerRequiresMembershipReach(data, ctx), ctx))); export type Action = z.input; /** Post-parse shape of {@link Action} — defaults applied, transforms run (ADR-0122). */ diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 6601f4589de..9f715ef01da 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -36,6 +36,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Exports: HookBodyCapability, ExpressionBodySchema, ScriptBodySchema, HookBodySchema - `node_modules/@objectstack/spec/src/data/query.zod.ts` — QueryAST — Abstract Syntax Tree for data queries. +- `node_modules/@objectstack/spec/src/identity/eval-user.zod.ts` — EvalUser — the one user-context contract (ADR-0068 D1). - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, diff --git a/skills/objectstack-ui/references/_index.md b/skills/objectstack-ui/references/_index.md index 9be8ae689a8..36bff69eab3 100644 --- a/skills/objectstack-ui/references/_index.md +++ b/skills/objectstack-ui/references/_index.md @@ -30,10 +30,15 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/hook-body.zod.ts` — Exports: HookBodyCapability, ExpressionBodySchema, ScriptBodySchema, HookBodySchema +- `node_modules/@objectstack/spec/src/data/object.zod.ts` — Exports: ApiMethod, ApiOperationSchema, ObjectCapabilities, IndexSchema, TenancyConfigSchema - `node_modules/@objectstack/spec/src/data/query.zod.ts` — QueryAST — Abstract Syntax Tree for data queries. +- `node_modules/@objectstack/spec/src/data/validation.zod.ts` — ObjectStack Validation Protocol +- `node_modules/@objectstack/spec/src/identity/eval-user.zod.ts` — EvalUser — the one user-context contract (ADR-0068 D1). - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Exports: ExecutionContextSchema - `node_modules/@objectstack/spec/src/kernel/metadata-protection.zod.ts` — Metadata Protection Model — Phase 1 (ADR-0010) - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, +- `node_modules/@objectstack/spec/src/security/permission.zod.ts` — Exports: ObjectAccessScopeSchema, ObjectPermissionSchema, EffectiveObjectPermissionSchema, AdminScopeSchema, FieldPermissionSchema +- `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol - `node_modules/@objectstack/spec/src/shared/enums.zod.ts` — Exports: SortDirectionEnum, SortItemSchema, MutationEventEnum, IsolationLevelEnum - `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas