Repository navigation
fix(permissions): preserve absent-vs-empty systemPermissions through the capability gate - #4724
Conversation
…the capability gate MePermissionsProvider used to collapse an unreported systemPermissions (a backend predating ADR-0066) and a genuinely empty one into the same [], so hasCapabilities consumers could not gate strictly on a real empty answer without stripping UI from every user on a non-reporting deployment. hasCapabilities now fails open only when systemPermissions is undefined (unreported) and gates strictly on a reported [] (holds nothing). The HomePage.tsx call-site heuristic retires in favor of this centralized signal, and two more call sites that re-collapsed a loaded-but-undefined answer to [] before feeding the ADR-0066 D4 action gate (RecordDetailView's resolveActionUser, useConsoleActionRuntime) now forward it unchanged. Fixes #4656 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
…site change UnifiedSidebar/AppSidebar gate a bare requiredPermissions name with hasCapabilities([perm]) || can(perm, 'read'). Reproduce that exact expression against the real MePermissionsProvider to show it is fixed for free by the provider-level change alone. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnQd8iMMUwXQEV1crFmQiQ
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
|
Review verdict: ACCEPT (reviewer of record, PM session Verified against this PR, not the report: eleven changed files with the changeset covering exactly the two released packages touched (independently confirmed on the branch). Will flip ready + enable auto-merge once Lint, Type Check, and Test shards 1–4 conclude Generated by Claude Code |
Fixes #4656
Implements the graded scope verbatim:
MePermissionsProviderpreserves theabsent-vs-empty distinction on
systemPermissions, everyhasCapabilities(consumer's call-site fail-open heuristic retires in favor of the centralized
signal, and
HomePage.authoringCapabilityGate.test.tsx(the objectstack#8270pin) stays green.
Premise: verified, still valid
Read
MePermissionsProvider(packages/permissions/src/MePermissionsProvider.tsx)against
origin/mainatfec06835e: the collapse is real, atsystemPermissions: data?.systemPermissions ?? []and thehasCapabilitiesclosure built from the same default. Grepping
hasCapabilities(repo-wide(excluding tests/CHANGELOGs) turns up exactly three consumers —
HomePage.tsx,UnifiedSidebar.tsx,AppSidebar.tsx— and onlyHomePage.tsxcarries the named call-site heuristic (
useCanAuthorMetadata'sif (!Array.isArray(systemPermissions) || systemPermissions.length === 0) return true;).Shape chosen:
systemPermissions: string[] | undefined(carryundefinedthrough)The card offered two candidates: an added
capabilitiesReported: boolean, orcarrying the array as
string[] | undefined. I measured the second is not anew invention — it is already this repo's established doctrine for the
exact same distinction one layer down, in
@object-ui/react'suseCapabilityGate.ts(useHeldCapabilities(): string[] | undefined, whoseown doc comment states: "undefined when the host never supplied them (→
callers must fail open)... An EMPTY array is not unknown"). Matching that
shape instead of inventing a sibling
capabilitiesReportedflag:hasCapabilitiesabsorb the fail-open decision itself (mirroringuseCapabilityGate()'s ownif (!Array.isArray(held)) return true;), socall sites don't need to consult a second field at all —
useCanAuthorMetadatadrops to a one-line
return hasCapabilities([AUTHORING_CAPABILITY]);and theheuristic retires, not relocates;
systemPermissionsreader (below) the same undefined-means-unknown contract they'd already expect from the sibling mechanism.
The sweep — every
hasCapabilities(/systemPermissionsconsumer, namedDefinition sites (
packages/permissions/):MePermissionsProvider.tsx— the core fix:systemPermissionsforwardedas-is (no
?? []);hasCapabilitiesfails OPEN when it isundefined,gates strictly on a reported array (empty included).
PermissionProvider.tsx(role-based provider, never fetches/me/permissions) —systemPermissions: []→undefined: it has no realanswer to give, so it must not claim "reported, holds nothing" either.
hasCapabilities: () => trueunchanged (still fail-open by design).usePermissions.ts's no-context fallback (no provider mounted at all) —same
[]→undefinedcorrection, same reasoning.PermissionContext.ts— type + doctrine comment on both fields.Direct
hasCapabilities(consumers:HomePage.tsx'suseCanAuthorMetadata— local heuristic deleted; nowasks the centralized signal directly. Outward behavior for the three cases
the pin file already covered is unchanged (verified below); a fourth case
the pin file could not previously express — a REPORTED, genuinely empty
systemPermissions: []— now correctly gates closed instead of fallingopen (new test, "a REPORTED empty systemPermissions is not confused with
absent").
UnifiedSidebar.tsx/AppSidebar.tsx'scheckPerm— no code change.Their
hasCapabilities([perm]) || can(perm, 'read')is not anabsent-vs-empty heuristic (it ORs a capability check with an unrelated
object-read fallback for nav items authored as a plain object name), so
there is nothing to migrate — but it silently inherits a real fix: before
this PR, a bare-capability nav item on an unreported backend fell all the
way to
can(perm, 'read'), which reads the capability name as an objectname and denies for an authenticated session with no matching object entry
(feat(setup): the datasource list shows the real connect verdict, with the operator-facing reason (framework#3827) #2926 ④) — stripping the nav item on exactly the deployments this doctrine
protects. Reproduced against the real provider in the new test file
("the sidebar nav OR-fallback pattern needs no call-site change").
Two more call sites, found by tracing where
systemPermissionsitself(not
hasCapabilities) flows, not named in the card's own grep instructionbut the same defect class one layer down — both feed the ADR-0066 D4
action capability gate (
ActionEngine/useCapabilityGate, framework#3923),which already implements the fail-open-on-undefined doctrine correctly; the
bug was these two call sites re-collapsing the signal to
[]before handingit to that already-correct engine:
RecordDetailView.tsx'sresolveActionUser—systemPermissions ?? []→forwards as-is. Its own pin test (
RecordDetailView.actionUser.test.tsx)asserted the OLD, now-wrong premise ("a loaded-but-undefined set" collapses
to "holds nothing"); that premise held only because the OLD provider could
never actually produce
undefinedwhile loaded. Updated the test'sassertion and doc comment to the new premise (see "pin equivalence" below).
useConsoleActionRuntime.tsx(the shell-levelActionProvider, which mostaction surfaces mount, including
EnvironmentListToolbar'suseCapabilityGate()transitively) — same?? []atcurrentUserconstruction, same fix. Its own comment claimed "fail-open when no
PermissionProvider is mounted" while the code actually failed CLOSED (an
array, even empty, is never
undefinedto the engine'sArray.isArray(held)check) — a live, previously undiscovered instance ofthis exact bug, now fixed by the same one-line change.
I did not find any other
systemPermissionsreader (checked everyusePermissions()call site repo-wide);PermissionMatrixEditor.tsx/PermissionPreview.tsx/permission-slice*.tsread a differentdraft.systemPermissions— the metadata-admin's authoredPermissionSetDraft,unrelated to this context value.
Pin equivalence —
HomePage.authoringCapabilityGate.test.tsxAll four existing cases stay green, byte-for-byte the same assertions,
because the payloads they use only ever exercise the two ends of the truth
table this fix actually changes the boundary of:
systemPermissionsreportednon-empty either way —
hasCapabilitiestakes the same strict branchbefore and after (
Array.isArraywas already true).eePayload(undefined)omits thesystemPermissionskey entirely —data.systemPermissionsisundefinedin both the old (
?? []default kicks in downstream) and new provider;observably the CTAs stay enabled either way, only the mechanism moved
(was: call-site
.length === 0check after defaulting; now: centralizedArray.isArraycheck on the raw value).None of the four exercises a REPORTED-but-empty payload, which is exactly the
case the OLD heuristic got wrong (
.length === 0cannot tell "empty andreported" from "absent") — that gap is what the new
"a REPORTED empty systemPermissions is not confused with absent" describe
block fills, using
eePayload([])(truthy in JS, so the key IS present).RecordDetailView.actionUser.test.tsx's superseded case is notequivalent — it is corrected, because it pinned the old (now-wrong) premise
directly. Full old→new reasoning is in the test file's own comment; the short
version:
resolveActionUser(user, true, undefined)used to assertsystemPermissionscollapses to[]on the premise thatusePermissions().systemPermissionswas NEVER undefined while loaded. Thatpremise is gone — the provider now genuinely returns
undefinedwhileloaded, for the unreported case — so the test now asserts the value forwards
unchanged (
undefined), matching whatActionEngine's ownArray.isArray(held)check needs to fail open correctly.Truth table (new tests)
packages/permissions/src/__tests__/MePermissionsProvider.capabilitiesReported.test.tsxpins the full table against the REAL
MePermissionsProvider/PermissionProvider/ no-context fallback:systemPermissionssystemPermissionsin contexthasCapabilities(['x'])undefinedtrue(fail open)[](real, empty grant)[]false(gates strictly)[]+required: [][]true(vacuous subset)'x'true(positive control)'x'(the objectstack#8270 EE owner's exact set)false(negative control)PermissionProvider(no/me/permissionsfetch)undefinedtrueundefinedtrueReverse verification
Direction predicted before running: reverting only
MePermissionsProvider.tsxto its pre-fix
?? []collapse, with the rest of the fix (including thenow-simplified
HomePage.tsx) left in place, should redden the new"UNREPORTED" truth-table case AND the pre-existing HomePage "unknown
capabilities fail OPEN" case (since
useCanAuthorMetadatano longer has itsown fallback to paper over the reverted provider) — everything else stays
green. Observed exactly that:
2 failed | 14 passedacross the two files,both failures on the predicted tests, nothing else moved. Restored via
git checkout claude/issue-4656-capabilities-reported -- packages/permissions/src/MePermissionsProvider.tsxfrom the already-committed fix;
git diff HEADafter restore was empty(byte-identical) and the full suite below re-ran green from that state.
Verification — all at
1ff99a4da(the final commit)Gate re-derivation note: this repo has no
scripts/pm/dispatch-gates.mjs(thattool lives in the
objectstackrepo); the local families run above werechosen from AGENTS.md's own "housekeeping" / "check:*" list against the actual
changed paths — no action-schema, i18n, or spec-symbol surface is touched, so
check:action-forward-parity/check:i18n-*/check:spec-symbolsare notimplicated.
Changeset
@object-ui/permissionsand@object-ui/app-shell, bothpatch— see.changeset/permissions-capabilities-reported.md.Generated by Claude Code
Generated by Claude Code