Skip to content

fix(plugin-auth): a role change that grants an org-admin permission set names the set - #28086

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-rvombf
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-rvombf

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #27862

Clause-②: no
Metadata-protocol: none
Skills-docs: none — grep -rn "update-member-role\|permission_set_granted\|grantedPermissionSet\|organization_admin_no_bypass" skills content/docs/guides content/docs/protocol finds no sentence about what a role change answers (one hit in skills/objectstack-platform/references/operations.md:201 is about the first-admin admin_full_access bootstrap, a different grant).

维护者速读

  • 改了什么:把成员角色改成管理员或所有者时,接口的返回里现在会说明这次顺带授予了哪个权限集(名称和显示名),控制台的提示也会写出来,比如「成员角色已更新。新角色还授予了权限集 Organization Administrator (no record bypass)。」
  • 为什么改:改角色会自动给这个人加上组织管理员权限集,但提示只写「成员角色已更新」,管理员事后在用户的权限集页签上看到一个没人手动分配的权限集,不知道从哪来的。
  • 风险与回滚:只加读不加写,授予什么完全不变(授予仍由 plugin-security 的协调器单独完成);读失败时就不显示这句,不会显示错的。回滚即 revert 本 PR。
  • 先看:packages/plugins/plugin-auth/src/role-change-granted-set.ts(前后两次读授予、比出新增的那个);auth-manager-before-hook.ts / auth-manager-after-hook.ts 的两处接入;sys-member.object.ts 的两条 outcomeMessages。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/role-change-granted-set.test.ts -t 27862,预期 9 个用例通过。
  • 需要你做的:无,席位落地。

What changes

POST /api/v1/auth/organization/update-member-role (the sys_member actions Change Role and Make Owner) now says which permission set the role change granted.

  • plugin-auth, new role-change-granted-set.ts. The global before hook reads the target member's org-admin grants (sys_user_permission_set rows naming organization_admin or organization_admin_no_bypass, by name) and stashes them on the request's endpoint context, the __osPwChangeUserId shape. The global after hook reads them again on a successful answer; a set held after and not before is named on the answer: outcome: 'permission_set_granted' and grantedPermissionSet: { name, label }, the label read from the security catalog (the name when the definition carries none). This is the shape invite-member answers outcome by mail delivery and sys_invitation gains a server copy_invitation_link action that answers the accept URL (ruling E on #27797) #27843 gave invite-member's outcome.
  • platform-objects. update_member_role and transfer_ownership declare outcomeMessages.permission_set_granted, naming ${result.grantedPermissionSet.label}; the generated bundles carry the en leaf and hand-translated zh-CN, ja-JP and es-ES leaves (re-extracted, so no source-hash entry claims a copied leaf).
  • Purely additive. The member row's own columns are untouched; a role change that grants nothing (admin to owner keeps the set already held, a demotion revokes it) and every refusal answer exactly what they answered before. No new error code, no spec key.
  • What is granted is unchanged. plugin-security is not in the diff. The new code calls only find (the deps object has no write member); the grant is still written by reconcileOrgAdminGrant alone.
  • Fails toward silence. A read that throws, a member id that resolves to no row, or a stash that did not reach the after hook leaves the answer as the vendor wrote it: a missing line, never a wrong one.

Tests

packages/plugins/plugin-auth/src/role-change-granted-set.test.ts: a real AuthManager over the installed better-auth, the role changed through the vendor's own route by the organization's owner. The grant is written by plugin-security's own reconcileOrgAdminGrant, fired after each sys_member update as the security plugin's middleware fires it.

  • (a) member → admin and member → owner answer outcome: 'permission_set_granted' and grantedPermissionSet: { name: 'organization_admin_no_bypass', label: 'Organization Administrator (no record bypass)' }, the member row's role and userId intact, and the grant table holds exactly that one set.
  • (b) admin → owner (set already held) and admin → member (revoked) answer no outcome / grantedPermissionSet; a plain member's attempt is refused 403 with neither, and nothing is granted.
  • Fail-toward-silence units: a before-read that throws, an after-read that throws, a set with no catalog label (named by its name), another route.

Acceptance notes

  • The label is the catalog definition's label as written (English for the platform sets); the surrounding sentence is translated per locale.
  • Make Owner's successMessage and Change Role's stay as they were for the no-grant case.

Verification

Ablation (two, both reverted and verified by blob == HEAD)
  1. auth-manager-after-hook.ts: the stampRoleChangeGrantedSet call replaced by void roleChangeDeps; → Tests 3 failed | 6 passed: both (a) cases and the admin → owner case (its first step asserts the grant line) went red. Restored: git diff HEAD empty.
  2. role-change-granted-set.ts: after.find((name) => !before.names.includes(name)) → after.find(() => true) (ignore the before-snapshot) → Tests 1 failed | 8 passed: admin → owner went red. Restored: ok restored: blob == HEAD (ba71c8e10d19).
Local gates (head a574295)
  • dispatch-gates --commands: 65 families derived; all 65 run with exit codes recorded; --ran reconciliation: "65 run, 0 NOT-MEASURED (a DERIVED zero — all 65 recorded an exit code and none of them is 3)".
  • Three families first answered exit 3 (PREREQUISITE NOT MET: check:i18n and check:dual-build-cjs-loads need built output; check-plugin-teardown-shape --self-test needs a commit the shallow clone lacked). Re-run after building the closure / fetching the commit: all exit 0. check:i18n: "OK (10 package(s) — all bundles in sync, no undeclared authoring keys)".
  • After the second commit (a test-file edit) the families that read test files were re-run on head a574295a: check:type-check-coverage, check:type-check-debt, check:test-source-alias, check:undeclared-dep-imports, check:cross-package-test-inputs, check:engine-double-contract, check:tier-file-adoption, check-issue-citations, check:i18n: all exit 0. The derived list is identical at both heads.
  • pnpm --filter @objectstack/plugin-auth test: Test Files 205 passed (205), Tests 3904 passed / 10 skipped. typecheck: exit 0.
  • pnpm --filter @objectstack/platform-objects test at head: Test Files 100 passed (100), Tests 1432 passed. typecheck: exit 0.
  • pnpm lint narrowed (declared): eslint --no-inline-config over the 9 changed .ts files, exit 0; the repo-wide run is CI's.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4czy9koaxQiZdkGdPYY


Generated by Claude Code

…et names the set

update-member-role now answers `outcome: 'permission_set_granted'` and
`grantedPermissionSet: { name, label }` beside the member row when the
change left the member holding an org-admin set they did not hold
before it. The before hook reads the pair's org-admin grants, the after
hook reads them again; both only read, so what is granted is unchanged.
The sys_member Change Role and Make Owner actions carry the outcome copy.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czy9koaxQiZdkGdPYY
…e beside its other copy

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4czy9koaxQiZdkGdPYY
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, touching 34 documentable anchor(s).

40 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 5448585d87246c0d57fdbbf422ece35c4c025241.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 5448585d87246c0d57fdbbf422ece35c4c025241 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4f2e978b91c68134bb8d69bccec67cd0ae0b43f6 — the merge of head fca3704b30db0f368af03995d4c790a1cd05e007 into base 5448585d87246c0d57fdbbf422ece35c4c025241, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4f2e978b91c68134bb8d69bccec67cd0ae0b43f6 && git checkout 4f2e978b91c68134bb8d69bccec67cd0ae0b43f6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5448585d87246c0d57fdbbf422ece35c4c025241 fca3704b30db0f368af03995d4c790a1cd05e007 && git checkout -B drift-repro 5448585d87246c0d57fdbbf422ece35c4c025241 && git merge --no-ff fca3704b30db0f368af03995d4c790a1cd05e007

node scripts/docs-audit/affected-docs.mjs --json 5448585d87246c0d57fdbbf422ece35c4c025241

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 5448585d87246c0d57fdbbf422ece35c4c025241 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Contract-review-claim: session_016WV4czuTWNgKR22fKv7jia · head fca3704b30db0f368af03995d4c790a1cd05e007 · 2026-10-30T12:09Z

The lane seat writes the TIER_DEFAULT review record this PR owes (Clause-②: yes (widening), declaration face only; no packages/spec/src/** or governed path in the diff).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Contract review

Served-tier: TIER_DEFAULT
Head-sha: fca3704b30db0f368af03995d4c790a1cd05e007
Local-runs: none
Scope: packages/plugins/plugin-auth/src/role-change-granted-set.ts, packages/plugins/plugin-auth/src/role-change-granted-set.test.ts, packages/plugins/plugin-auth/src/auth-manager-before-hook.ts, packages/plugins/plugin-auth/src/auth-manager-after-hook.ts, packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/platform-objects/src/identity/sys-member.object.ts, packages/platform-objects/src/apps/translations/{en,zh-CN,ja-JP,es-ES}.objects.generated.ts, packages/platform-objects/src/identity/transfer-ownership-action-copy.test.ts + packages/plugins/plugin-auth/src/invitation-delivery-outcome.ts (the #27843 precedent, PR #27902 f552fa8), packages/plugins/plugin-auth/src/organization-delete.ts (ORGANIZATION_ADMIN_SET_NAMES), packages/spec/src/ui/action.zod.ts (outcomeMessages / successMessage fallback) (.changeset/27862-role-change-names-granted-set.md is a prose face: the lane seat's ACCEPT)

Owed on the declaration face alone; the diff touches no packages/spec/src/** and no governed path, so the tier is TIER_DEFAULT, written by the lane seat.

① Derived judgments

  • Declaration corrected. The dev declared Clause-②: no at a574295a, reading invite-member answers outcome by mail delivery and sys_invitation gains a server copy_invitation_link action that answers the accept URL (ruling E on #27797) #27843 as precedent. That precedent (PR feat(plugin-auth): an invite answers its mail-delivery outcome, and sys_invitation gains copy_invitation_link #27902, f552fa81) declared Clause-②: yes (widening) for the same shape (a success answer gains outcome plus copy keys). This PR widens the same way: POST /organization/update-member-role's success answer gains outcome: 'permission_set_granted' and grantedPermissionSet: { name, label }, and the two sys_member actions gain outcomeMessages. Head fca3704b changes only the changeset line to yes (widening: …); the claim (6520476174) is amended to match. The PR body still reads Clause-②: no: the relay cannot edit a PR body, so this record is the correction — read yes (widening) for this PR.
  • Accept set unchanged. Nothing a caller sends is accepted or refused differently: the before hook only reads; the after hook only adds members to a success answer. What is granted is unchanged: plugin-security's reconciler stays the one writer, and plugin-security is not in the diff.
  • When the members appear. Only when (a) the path is update-member-role, (b) the before hook stashed the pair's org-admin grants, (c) the answer is an object and not an APIError, and (d) a set among ORGANIZATION_ADMIN_SET_NAMES is held after and was not before. Admin → owner (already held), demotions, member → external and refusals answer as before. Pinned case by case.
  • No disclosure beyond the caller's reach. The before hook reads the target by memberId under the system read context before better-auth authorizes, but it only stashes; a refused request gets no stamp and the stash is deleted in the after hook. A successful caller is already authorized for that member, and learns only the name and catalog label of an org-admin set it just caused to be granted.
  • Fails toward silence. Every read is in try/catch; a failed read, a missing member or a lost stash leaves the vendor's answer byte-for-byte (pinned: throwing before-read, throwing after-read).
  • Console copy. outcomeMessages is an existing ActionSchema member; the action picks the entry named by the answer's outcome and falls back to successMessage otherwise (action.zod.ts). Both actions call the same route, so both carry the entry. The generated translations carry the new key in all four locales.
  • Wrong: none found beyond the declaration, corrected above.

② Semver level

③ Boundary flags

  • PR body vs changeset. Body no, changeset and claim yes (widening). The queue gate reads the claim; the record above carries the correction. If a PR-body guard reds on the mismatch, that is a real finding for the lane seat, not to be worked around.
  • Cost. Two system reads before and one or two after, on this one route only. Proportionate.
  • Check-runs at this head: not complete when read. Landing waits for all green.

④ Design judgment

Implemented-by: claude/project-thread-rvombf
Reviewed-by: session_016WV4czuTWNgKR22fKv7jia (lane seat)

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Seat ACCEPT · PR #28086 · head fca3704b30db0f368af03995d4c790a1cd05e007 · 2026-10-30T12:29Z

维护者速读

  • 改了什么: 把成员升为管理员或所有者时,角色变更接口会顺带说明自动授予了哪个权限集(outcome: 'permission_set_granted' + grantedPermissionSet: { name, label }),控制台的「修改角色」「设为所有者」提示随之显示:「成员角色已更新。新角色还授予了权限集 ×××」(Setup: changing a member's role silently auto-grants a permission set; the result never says a set was granted (#27521 row 14) #27862)。
  • 为什么改: 以前只提示「Member role updated」,管理员事后在用户的权限集页看到一个没人显式分配的集合。
  • 风险与回滚: 只读、不改授予逻辑(授予仍由 plugin-security 的协调器唯一写入);任何读取失败都静默回落为原样回答。回滚即还原 plugin-auth 的两处钩子与新模块、platform-objects 的文案。
  • 先看: packages/plugins/plugin-auth/src/role-change-granted-set.ts。
  • 两分钟验证: 在控制台把一个普通成员改为 admin,看成功提示是否点名权限集;再从 admin 改为 owner,应只显示原提示。
  • 需要你做的: 无(Tier S;契约复核 PASS,按本期落地授权由席位落地)。

席位核对

  • Shape: draft → main, Fixes #27862. 13 files (+520/−10): plugin-auth (new role-change-granted-set.ts + test, before/after hook wiring, AuthManager.roleChangeGrantDeps), platform-objects (sys-member.object.ts outcome copy, four generated translation bundles, one copy pin), one changeset. check-governed-merges --pr 28086: NOT governed.
  • Premise: re-measured by the thread on main: the route answered the member row from the adapter; the org-admin grant is written by plugin-security's sys_member middleware inside the same request; the action toasted fixed copy.
  • File surface: amended in the claim before the push (plugin-auth after-hook + platform-objects copy; plugin-security untouched — confirmed, it is not in the diff).
  • Contract review: Clause-②: yes (widening) on the changeset and (amended) the claim. Correction to the PR body: its Clause-②: no line is wrong and cannot be edited through the relay; read yes (widening: update-member-role's success answer gains outcome 'permission_set_granted' and grantedPermissionSet; the two sys_member actions gain outcomeMessages), as the invite-member answers outcome by mail delivery and sys_invitation gains a server copy_invitation_link action that answers the accept URL (ruling E on #27797) #27843 / PR feat(plugin-auth): an invite answers its mail-delivery outcome, and sys_invitation gains copy_invitation_link #27902 precedent declared. TIER_DEFAULT record by the lane seat, PASS, comment 6522494596, at this head.
  • Declarations: Metadata-protocol: none; both packages patch. Skills-docs: per the PR body.
  • Gate check-runs on this head: 48 total, 35 success, 13 skipped by the path filter, 0 failed (PR-body guards green). mergeable_state: clean.
  • Landing: Tier S, under the maintainer's landing grant for this term (2026-10-29 22:05Z, seat thread). Ready + auto-merge through the relay.

ACCEPT

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 12:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 5954705 Oct 30, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-rvombf branch October 30, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Setup: changing a member's role silently auto-grants a permission set; the result never says a set was granted (#27521 row 14)

1 participant