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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/27862-role-change-names-granted-set.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/platform-objects': patch
---

fix(plugin-auth): a role change that grants an org-admin permission set says which set it granted (#27862)

Clause-②: yes (widening: update-member-role's success answer gains outcome 'permission_set_granted' and grantedPermissionSet; the two sys_member actions gain outcomeMessages)
Metadata-protocol: none

Raising a member to `admin` or `owner` grants an org-admin permission set as a side effect (`organization_admin`, or `organization_admin_no_bypass` on a wall-less posture). The role change used to answer the member row alone, so the console toasted "Member role updated" and the administrator later found a set on the user that nobody visibly assigned.

- **Now.** When the role change left the member holding an org-admin set they did not hold before it, `POST /api/v1/auth/organization/update-member-role` answers two more members beside the member row: `outcome: 'permission_set_granted'` and `grantedPermissionSet: { name, label }`, the label read from the security catalog. The `sys_member` actions Change Role and Make Owner show it: "Member role updated. The new role also granted the permission set Organization Administrator (no record bypass)."
- **Unchanged.** A role change that grants nothing (admin to owner keeps the set already held, a demotion revokes it) and a refusal answer exactly as before. The member row's own columns are untouched. What is granted does not change: the grant is still written by `plugin-security`'s reconciler alone, and the new hooks only read.
- **Fails toward silence.** A grant read that fails leaves the answer as the route wrote it, without the line.
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
update_member_role: {
label: "Change Role",
successMessage: "Member role updated"
successMessage: "Member role updated",
outcomeMessages: {
permission_set_granted: "Member role updated. The new role also granted the permission set ${result.grantedPermissionSet.label}."
}
},
remove_member: {
label: "Remove Member",
Expand All @@ -704,7 +707,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
transfer_ownership: {
label: "Make Owner",
confirmText: "Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.",
successMessage: "Member is now an owner"
successMessage: "Member is now an owner",
outcomeMessages: {
permission_set_granted: "Member is now an owner. The new role also granted the permission set ${result.grantedPermissionSet.label}."
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
update_member_role: {
label: "Cambiar rol",
successMessage: "Rol del miembro actualizado"
successMessage: "Rol del miembro actualizado",
outcomeMessages: {
permission_set_granted: "Rol del miembro actualizado. El nuevo rol también concedió el conjunto de permisos ${result.grantedPermissionSet.label}."
}
},
remove_member: {
label: "Eliminar miembro",
Expand All @@ -704,7 +707,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
transfer_ownership: {
label: "Hacer propietario",
confirmText: "¿Hacer propietario de esta organización al miembro seleccionado? Obtendrá todos los privilegios de propietario, incluido eliminar la organización. Tú sigues siendo propietario; para dejar de serlo, usa «Cambiar rol» en tu propia membresía.",
successMessage: "El miembro ahora es propietario"
successMessage: "El miembro ahora es propietario",
outcomeMessages: {
permission_set_granted: "El miembro ahora es propietario. El nuevo rol también concedió el conjunto de permisos ${result.grantedPermissionSet.label}."
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
update_member_role: {
label: "ロールを変更",
successMessage: "メンバーロールを更新しました"
successMessage: "メンバーロールを更新しました",
outcomeMessages: {
permission_set_granted: "メンバーのロールを更新しました。新しいロールにより権限セット ${result.grantedPermissionSet.label} も付与されました。"
}
},
remove_member: {
label: "メンバーを削除",
Expand All @@ -704,7 +707,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
transfer_ownership: {
label: "所有者にする",
confirmText: "選択したメンバーをこの組織の所有者にしますか?このメンバーには、組織の削除を含む所有者のすべての権限が付与されます。あなたも引き続き所有者です。退く場合は、自分のメンバーシップで「ロールを変更」を使用してください。",
successMessage: "メンバーが所有者になりました"
successMessage: "メンバーが所有者になりました",
outcomeMessages: {
permission_set_granted: "このメンバーはオーナーになりました。新しいロールにより権限セット ${result.grantedPermissionSet.label} も付与されました。"
}
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
update_member_role: {
label: "更改角色",
successMessage: "成员角色已更新"
successMessage: "成员角色已更新",
outcomeMessages: {
permission_set_granted: "成员角色已更新。新角色还授予了权限集 ${result.grantedPermissionSet.label}。"
}
},
remove_member: {
label: "移除成员",
Expand All @@ -704,7 +707,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
transfer_ownership: {
label: "设为所有者",
confirmText: "确定要将所选成员设为该组织的所有者吗?对方将获得所有者的全部权限,包括删除该组织。您仍是所有者;如需卸任,请在您自己的成员记录上使用“更改角色”。",
successMessage: "该成员现已成为所有者"
successMessage: "该成员现已成为所有者",
outcomeMessages: {
permission_set_granted: "该成员现已成为所有者。新角色还授予了权限集 ${result.grantedPermissionSet.label}。"
}
}
}
},
Expand Down
13 changes: 13 additions & 0 deletions packages/platform-objects/src/identity/sys-member.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ export const SysMember = ObjectSchema.create({
requiresFeature: 'organization',
requiresMembershipReach: 'update_member_role',
successMessage: 'Member role updated',
// [#27862] A role change that grants an org-admin permission set says
// which one: update-member-role then answers
// `outcome: 'permission_set_granted'` and `grantedPermissionSet` beside
// the member row (plugin-auth `role-change-granted-set.ts`).
outcomeMessages: {
permission_set_granted:
'Member role updated. The new role also granted the permission set ${result.grantedPermissionSet.label}.',
},
refreshAfter: true,
params: [
{ field: 'role', required: true, defaultFromRow: true },
Expand Down Expand Up @@ -271,6 +279,11 @@ export const SysMember = ObjectSchema.create({
requiresMembershipReach: 'transfer_ownership',
confirmText: 'Make the selected member an owner of this organization? They get every owner privilege, including deleting the organization. You stay an owner too; to step down, use Change Role on your own membership.',
successMessage: 'Member is now an owner',
// [#27862] The same answer as `update_member_role` (the same route).
outcomeMessages: {
permission_set_granted:
'Member is now an owner. The new role also granted the permission set ${result.grantedPermissionSet.label}.',
},
refreshAfter: true,
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface CopyAction {
label?: string;
confirmText?: string;
successMessage?: string;
outcomeMessages?: Record<string, string>;
target?: string;
bodyExtra?: Record<string, unknown>;
recordIdParam?: string;
Expand Down Expand Up @@ -71,7 +72,7 @@ describe('sys_member.transfer_ownership — declared copy', () => {
});

it('promises neither a demotion nor a transfer', () => {
for (const text of [action.label, action.confirmText, action.successMessage]) {
for (const text of [action.label, action.confirmText, action.successMessage, ...Object.values(action.outcomeMessages ?? {})]) {
expect(text).not.toMatch(/demot|transfer/i);
}
expect(action.confirmText).toContain('You stay an owner');
Expand All @@ -93,6 +94,11 @@ describe('sys_member.transfer_ownership — every locale bundle', () => {
it('en is the declared copy', () => {
const copy = bundled(enObjects, 'transfer_ownership');
const action = declared('transfer_ownership');
expect(copy).toEqual({ label: action.label, confirmText: action.confirmText, successMessage: action.successMessage });
expect(copy).toEqual({
label: action.label,
confirmText: action.confirmText,
successMessage: action.successMessage,
outcomeMessages: action.outcomeMessages,
});
});
});
10 changes: 10 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-after-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { rotateCallerBearerOnImpersonation } from './impersonation-bearer-rotati
import { stampInvitationDeliveryOutcome } from './invitation-delivery-outcome.js';
import { codeInviteRoleNotFound } from './invite-role-not-found-code.js';
import { readRequestClientAddress } from './request-client-address.js';
import { stampRoleChangeGrantedSet } from './role-change-granted-set.js';
import { attachSessionToCredentialResponse } from './session-envelope-completion.js';
import { resetVerifiedOnTwoFactorReenrollment } from './two-factor-reenrollment-verified-reset.js';
import { echoInstalledSessionToken } from './two-factor-rotated-token-echo.js';
Expand All @@ -64,6 +65,7 @@ export interface AfterHookHost {
isEmailDeliveryLive: AuthManager['isEmailDeliveryLive'];
recordPasswordHistory: AuthManager['recordPasswordHistory'];
recordSignInOutcome: AuthManager['recordSignInOutcome'];
roleChangeGrantDeps: AuthManager['roleChangeGrantDeps'];
sendChangeEmailNotice: AuthManager['sendChangeEmailNotice'];
stampLastLogin: AuthManager['stampLastLogin'];
stampPasswordChangedAt: AuthManager['stampPasswordChangedAt'];
Expand Down Expand Up @@ -232,6 +234,14 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
// `invitation-delivery-outcome.ts`). A refusal passes untouched.
await stampInvitationDeliveryOutcome(ctx, () => this.isEmailDeliveryLive());

// ── #27862: a role change names the permission set it granted ──
// The before hook read the member's org-admin grants; a success that
// left a new one held answers `outcome: 'permission_set_granted'` and
// `grantedPermissionSet: { name, label }` beside the member row (see
// `role-change-granted-set.ts`). A refusal passes untouched.
const roleChangeDeps = this.roleChangeGrantDeps();
if (roleChangeDeps) await stampRoleChangeGrantedSet(ctx, roleChangeDeps);

// ── #24750: invite-member's unknown-role refusal carries its code ──
// better-auth throws it with only a message; the spec promises
// `ROLE_NOT_FOUND` (see `invite-role-not-found-code.ts`). It throws
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export interface AuthInstanceHost {
resolveAuthSecret: AuthManager['resolveAuthSecret'];
resolvePasswordChangeUserId: AuthManager['resolvePasswordChangeUserId'];
resolvePasswordHasher: AuthManager['resolvePasswordHasher'];
roleChangeGrantDeps: AuthManager['roleChangeGrantDeps'];
sendChangeEmailNotice: AuthManager['sendChangeEmailNotice'];
sendExistingAccountSignUpNotice: AuthManager['sendExistingAccountSignUpNotice'];
signUpForcedOff: AuthManager['signUpForcedOff'];
Expand Down Expand Up @@ -706,6 +707,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
isPlatformAdminUserId: (...a) => this.isPlatformAdminUserId(...a),
resolveActor: (...a) => this.resolveActor(...a),
resolvePasswordChangeUserId: (...a) => this.resolvePasswordChangeUserId(...a),
roleChangeGrantDeps: (...a) => this.roleChangeGrantDeps(...a),
validateAudienceAdmission: (...a) => this.validateAudienceAdmission(...a),
};
return globalBeforeHook.call(host, ctx, setShouldSkipSessionRefresh);
Expand All @@ -720,6 +722,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a),
recordPasswordHistory: (...a) => this.recordPasswordHistory(...a),
recordSignInOutcome: (...a) => this.recordSignInOutcome(...a),
roleChangeGrantDeps: (...a) => this.roleChangeGrantDeps(...a),
sendChangeEmailNotice: (...a) => this.sendChangeEmailNotice(...a),
stampLastLogin: (...a) => this.stampLastLogin(...a),
stampPasswordChangedAt: (...a) => this.stampPasswordChangedAt(...a),
Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-before-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
} from './last-local-credential.js';
import { ORGANIZATION_DELETE_PATH } from './organization-delete.js';
import { carriesPrincipalSession } from './principal-session-mint.js';
import { snapshotRoleChangeGrants, UPDATE_MEMBER_ROLE_PATH } from './role-change-granted-set.js';

/**
* The `AuthManager` members the function below reads — every one of them, and
Expand Down Expand Up @@ -98,6 +99,7 @@ export interface BeforeHookHost {
isPlatformAdminUserId: AuthManager['isPlatformAdminUserId'];
resolveActor: AuthManager['resolveActor'];
resolvePasswordChangeUserId: AuthManager['resolvePasswordChangeUserId'];
roleChangeGrantDeps: AuthManager['roleChangeGrantDeps'];
validateAudienceAdmission: AuthManager['validateAudienceAdmission'];
}

Expand Down Expand Up @@ -660,6 +662,15 @@ export async function globalBeforeHook(
});
}

// ── #27862: read the member's org-admin grants before a role change ──
// The after hook compares them with what the change left held and names
// the set it granted (see `role-change-granted-set.ts`). A read only: it
// gates nothing, and a read that fails stashes nothing.
if (ctx?.path === UPDATE_MEMBER_ROLE_PATH) {
const roleChangeDeps = this.roleChangeGrantDeps();
if (roleChangeDeps) await snapshotRoleChangeGrants(ctx, roleChangeDeps);
}

// ── ADR-0069 D2: account lockout (gate) ─────────────────────
// Reject a sign-in for a locked identity BEFORE better-auth checks
// the password — a lock must hold even against the correct password.
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
import type { RegisteredErrorCode, StandardErrorCode } from '@objectstack/spec/api';
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
import { type ExternalDirectoryDoorDeps } from './external-directory-door.js';
import { roleChangeGrantDepsFor, type RoleChangeGrantDeps } from './role-change-granted-set.js';
import { isExternalMembershipRole } from './external-membership.js';
import { permissionSetInEffect } from './catalog-set-in-effect.js';
import { recoverInternalFieldsForSystemRead } from './internal-field-readback.js';
Expand Down Expand Up @@ -1701,6 +1702,7 @@ export class AuthManager {
resolveAuthSecret: (...a) => this.resolveAuthSecret(...a),
resolvePasswordChangeUserId: (...a) => this.resolvePasswordChangeUserId(...a),
resolvePasswordHasher: (...a) => this.resolvePasswordHasher(...a),
roleChangeGrantDeps: (...a) => this.roleChangeGrantDeps(...a),
sendChangeEmailNotice: (...a) => this.sendChangeEmailNotice(...a),
sendExistingAccountSignUpNotice: (...a) => this.sendExistingAccountSignUpNotice(...a),
signUpForcedOff: (...a) => this.signUpForcedOff(...a),
Expand Down Expand Up @@ -4784,6 +4786,16 @@ export class AuthManager {
};
}

/**
* [#27862] The reads the role-change hooks make to name the set a role change
* granted, bound to this manager's engine under the system read context. With
* no engine there is nothing to read, so no answer names a set.
*/
private roleChangeGrantDeps(): RoleChangeGrantDeps | undefined {
const engine = this.config.dataEngine;
return engine ? roleChangeGrantDepsFor(engine, withSystemReadContext(engine) as any) : undefined;
}

private resolveActor(
ctx: any,
options?: { failedReadThrows?: boolean },
Expand Down
Loading
Loading