Repository navigation
fix(identity): Invite User offers only the roles the caller may grant, read from the role cap's own table - #24033
Conversation
…the invite dialog (#23957) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Qdc3Ce9PyUEvBfvmLZSbW
…its pre-table answers Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Qdc3Ce9PyUEvBfvmLZSbW
… changeset Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Qdc3Ce9PyUEvBfvmLZSbW
…ty grant table Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Qdc3Ce9PyUEvBfvmLZSbW
…vite-role-options-per-caller
📓 Docs Drift CheckThis PR changes 3 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6d0e1f0c0458d268fe179eaa0423b680c9ca0a1e && git checkout 6d0e1f0c0458d268fe179eaa0423b680c9ca0a1e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e797a89764e396f75485f3b7b43d0f135aec52b 4ce40d551df9dc9f296a2f976e9a4b7e673b3fef && git checkout -B drift-repro 9e797a89764e396f75485f3b7b43d0f135aec52b && git merge --no-ff 4ce40d551df9dc9f296a2f976e9a4b7e673b3fef
node scripts/docs-audit/affected-docs.mjs --json 9e797a89764e396f75485f3b7b43d0f135aec52b
|
Contract reviewServed-tier: Inputs: card #23957 (body; triage ① Derived judgments
② Semver levelThe changeset declares ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT · PR #24033 @
|
Fixes #23957
Clause-②: yes
What changes
A
delegated_admin's Invite User dialog offered Owner, Admin and Delegated Admin, and the server's invitation role cap refuses all three for that issuer. Now the dialog offers only what the cap admits for the caller. Both read one table in@objectstack/spec/identity, so the offer cannot drift from the refusal.INVITATION_ROLE_GRANTORS)visibleWhenownerowner'org_owner' in current_user.positionsadminowner,admin'org_owner' in current_user.positions || 'org_admin' in current_user.positionsdelegated_adminowner,adminadminmemberINVITATION_ROLE_ANY_ISSUER)packages/spec/src/identity/invitation-role-grant.ts(new; constants and pure helpers only):INVITATION_ROLE_GRANTORS,INVITATION_ROLE_ANY_ISSUER,invitationRoleGrantors(role)(a role outside the closed vocabulary takes theowner,adminrow),invitationRoleGrantPredicate(role)(it builds the gate the waymembershipReachPredicatedoes: onemapMembershipRoleprojection per grade),INVITATION_ROLE_OPTIONS, and the typesInvitationRoleGrantorsandInvitationRoleOption. It is exported fromidentity/index.ts, and theapi-surfaceandexport-originsartifacts are regenerated with it.packages/plugins/plugin-auth/src/invitation-role-cap.ts:isPlainMemberInvitationand the admission decision now read the table. The grade ladder still resolves the issuer's grade and chooses which refusal text names the reason. Refusals and their texts are unchanged (pin below).sys-user/sys-member/sys-invitationinvite_user: theroleparam declaresoptions: [...INVITATION_ROLE_OPTIONS]instead of inheriting the field's list. Everything else about the param stays field-backed: type, label,objectOverride,required. Values, English labels and order are unchanged._actions.invite_user.params.role.options.VALUE, because objectui'sactionParamOptionLabelreads that path for every param option. The bundles are regenerated, and the three translated locales reuse the field's existing option translations. No source-hash residue is left (check:i18nOK).The resulting offer: a
delegated_adminsees Member only; an admin sees Admin, Delegated Admin, Member; an owner sees all four. The card's done-when says an admin should "still see all of them". The cap has always refusedownerfrom an admin, so the offer follows the cap instead (see Acceptance notes).Hypothesis 1, measured: an existing mechanism carries it, and objectui needs no change
At objectui
f8267df93a(.objectui-sha):packages/fields/src/resolveActionParams.ts:resolvedOptions = authoredOptions ?? normaliseOptions(field.options, …). Authored options win over inherited ones, andresolveOptionLabelsreturns them unchanged or rebuilds them by spread, sovisibleWhensurvives.packages/core/src/evaluator/optionRules.tsresolveVisibleOptionsfilters by each option'svisibleWhenagainst the record pluscurrent_user. The four option widgets reach it throughuseCascadingOptions.packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:285re-labels each option throughactionParamOptionLabel(object, action, param, value, label), which spreads the option and keepsvisibleWhen.So no new served field is needed, and no objectui half is filed. One correction to the dispatch's premise: at this pin
normaliseOptionsalso spreads inherited entries (fbc23e094), so an inherited option would keepvisibleWhentoo. The param still declares its own list, because avisibleWhenon the FIELD's options would narrow everysys_member.rolepicker, not only the invite dialog.Pins
plugin-auth/src/invitation-role-grant-offer.test.tsvisibleWhenwithcelEngineagainstcurrent_user.positions = [mapMembershipRole(grade)], and asserts the offered set equals{ role : invitationRoleCapFailure(role, grade) === null }. It also pins delegated_admin →['member'], admin → three roles, owner → all four.sales_managerincluded), arrays, case and space variants, and empty or non-string values. Every case gives the same answer with the same text. The test also asserts the grid holds both refusals (more than 100) and admissions.spec/src/identity/invitation-role-grant.test.ts: the table covers the vocabulary, the unlisted-role row, the predicate text, and that the options keep the vocabulary's values, labels and order.Ablation (one-shot; no permanent file):
scripts/ablation-replace.mjsdropped theowneroption'svisibleWhen(anchor 1 → 0, blobfe0aa9a9→8fcec5e6). After a spec rebuild,ablation-dist-preflightfound the marker indist/identity/index.{js,mjs}, andinvitation-role-grant-offer.test.tswent red: 10 failed (every non-owner issuer on all three mirrors, plus the explicit case). The restore was verified: blob equal to HEAD,git diff HEADempty, porcelain clean. After a rebuild the marker was absent from all 232 built files, and the test was green again with 14 passed. A static list with nooptionson the param fails the same file'sinvite_user declares its own role optionsassertion.Verification (local, head
4ce40d55, after mergingorigin/main)@objectstack/plugin-authtest: 151 files, 2956 passed, 10 skipped. typecheck exit 0.@objectstack/spectest: 691 files, 20939 passed. typecheck exit 0.check:generated: all 14 artifacts up to date.@objectstack/platform-objectstest: 73 files, 1136 passed. typecheck exit 0.node scripts/check-i18n-bundles.mjs: OK, 10 packages in sync.dispatch-gates --commandsderived 90 families on4ce40d55: 89 exit 0 (check:role-wordamong them, since this diff names roles).--ranreconciliation: 90 accounted, 89 run, 1 NOT MEASURED, 0 unrun.check-plugin-teardown-shape --self-testexited 3 (PREREQUISITE NOT MET: its fixture needs an unshallowed clone). NOT MEASURED locally; CI reads it.Acceptance notes
ownerfrom an admin, both before and after this PR, so the offer follows the cap. The admin dialog therefore no longer shows Owner. Offering it would keep exactly the offer-then-refuse defect this card closes.invitationRoleGrantors' administrative row, as before.resend_invitationonsys_invitationkeeps its field-backedroleparam withdefaultFromRow. It re-sends the row's own role and is not one of the threeinvite_usermirrors, so it is untouched.ui/action.zod.ts(the ActionParamoptionsdocblock and itsguidancecomment) says objectui'snormaliseOptionsrebuilds inherited entries as{ label, value }. At the current pin it spreads them (fbc23e094). That is a documentation drift with no runtime effect. Carrier: none.code— the sibling vendor refusal on the same endpoint carries one #23945 (the refusal's missingcode) is not addressed here.Generated by Claude Code