fix(api): gate partner-wide script writes on canManagePartnerWidePolicies (#3262) - #3263
Merged
ToddHebebrand merged 2 commits intoAug 8, 2026
Conversation
…cies (LanternOps#3262) Partner SCOPE was standing in for the partner-wide CAPABILITY. A partner user with `partner_users.org_access = 'selected'` — scoped to, say, three of eighty customers — could create a script that runs as SYSTEM across all eighty, including orgs they hold no grant for and orgs onboarded later. `canManagePartnerWidePolicies` exists precisely to prevent this substitution; its doc comment says so. It appeared nowhere in scripts.ts. Four sites, not one. The issue names the create path; the other three are the same privilege reached by a different verb: 1. CREATE (`availability: 'partner'`) — the reported vector. 2. WIDEN on edit — `resolveRescopeTarget`'s `availability === 'partner'` branch checked scope only, so an existing org script could be promoted partner-wide. This is a second creation vector for the same privilege. 3. EDIT an existing partner-wide script — the prior guard blocked only `auth.scope === 'organization'`, so a partner 'selected' user could rewrite the body of a script already running as SYSTEM everywhere. 4. DELETE an existing partner-wide script — same guard, same gap; deleting removes automation from every org under the partner. `RescopeAuth` gains `partnerOrgAccess` because the capability is deliberately not derivable from `accessibleOrgIds`, and its `scope` narrows from `string` to `AuthContext['scope']` so the capability check typechecks. Follows `peripheralControl.ts:508` — `PARTNER_WIDE_WRITE_DENIED_MESSAGE` with 403 — matching the nine other routes that already do this correctly. Tests: four denials (create / widen / edit / delete as a 'selected' partner user) plus a positive control proving a full-partner admin can still widen. The two existing partner-auth helpers now default to `partnerOrgAccess: 'all'`, which is the user those positive cases always meant to describe. All five verified against the un-fixed code: with the capability neutered to `return true`, exactly the four denials fail and the positive control still passes. Note the first control attempt only neutered two of the four guards — the edit/delete guards use a different expression shape — so it under-reported; the numbers above are from neutering the capability itself. Gate: tsc exit 0; vitest 1285 files / 20525 tests / 0 failures; eslint clean.
…capability Review follow-ups on the server-side gate: - /users/me now surfaces canManagePartnerWide (UX only; the server still gates every partner-wide write), and the auth store merges it on the same ride-along path as isPlatformAdmin/permissions. - ScriptForm no longer defaults new scripts to an availability the save would 403: selected-access partner users get the org option by default, a disabled "All my organizations" radio with a hint, and a read-only notice when editing an existing partner-wide script. - Positive coverage for the gate's allow branches: a selected-access user still creates org-scoped scripts, and a full-partner admin still deletes a partner-wide script — so an over-broad gate can't ship green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collaborator
|
Thanks @bdunncompany — solid work catching the fourth vector (widen-via-rescope) that the issue didn't name. CI was green and a high-effort review confirmed the gating logic is correct across all four paths. I pushed one maintainer commit (1cfe0e8) on top with two defense-in-depth hardenings the review flagged, so the whole fix lands atomically:
Will merge once CI is green on the new commit. |
ToddHebebrand
added a commit
that referenced
this pull request
Aug 8, 2026
Docs staleness sweep for the v0.102.0..v0.104.0 range (tracked in `scripts/docs-review/last-reviewed.json`). ## Page updates - **features/ai.mdx** — rewrote the Tier 3 approval workflow for the supervised/four-eyes split (#3175): supervised actions are approved by the requesting technician in chat (gated on their own permissions); a fixed high-stakes list (financial, tenant shape, M365/Google identity, restores/rollbacks, computer control, containment release) requires a second approver, now with a 60-minute window and content-change pinning. Sole-operator and `approvals:decide` sections rescoped to four-eyes; disabled/invited users noted as ineligible approvers. - **features/devices.mdx** — WAN IP / LAN IP opt-in device-list columns (#2996). - **features/quotes.mdx** — new "Ordering What You Sold" section: the To-be-ordered procurement breakdown on won quotes, Mark ordered / Mark received tracking, CSV export, vendor cost snapshot, Pax8 badges (#3111). - **features/scripts.mdx** — partner-wide ("All my organizations") scripts, the Available-to picker, and the full-partner-access requirement for partner-wide writes (#3262/#3263); fixed the API section's org-only claim. - **features/edr-integrations.mdx** — chunked resumable package uploads with progress (#3113). - **features/snmp.mdx** — poll due-check runs off attempts; failing devices back off exponentially (scheduler description + troubleshooting entry) (#3223). - **agents/enrollment-keys.mdx** — installer capacity figure and key status derived from live bootstrap tokens (#2993/#3045/#3196). Verified as already self-documented in range (no action): Quick Support (#3153), third-party ring auto-approve (#3150), VSS writer health + partial status (#3005/#3030), EVENT_LOOP_MONITOR_*/DB_POOL_HEALTH_* env vars (#3024/#3224), strict env validation (#2979), custom alert-condition retirement (#2995), enrollment idempotency (#3063), extensions install scoping (#3032). ## Housekeeping - `scripts/docs-review/mapping.json` — added `quickSupport*`, `eventLoop*`, `dbPoolHealth*` patterns; `approvals.ts` now also maps to features/ai.mdx; `partnerWideAccess.ts` now also maps to features/scripts.mdx. - `packages/shared/src/utils/docsMapping.ts` — `/remote/quick-support` → Quick Support section anchor (+ test). `@breeze/shared` tests green (1661 passed). - `apps/api/src/data/docsIndex.json` regenerated (147 docs indexed). - `docs/release-notes/next-release-draft.md` cleared; last release set to v0.104.0 (2026-08-08). - `scripts/docs-review/last-reviewed.json` bumped to v0.104.0. Docs build verified: `astro build` — 150 pages, no errors. **Follow-up (not in this PR):** `docsMapping.ts` maps `/ai-risk` → `/features/user-risk/`, but the AI Risk Engine (approval history, tiers) is documented in `features/ai.mdx` — one of the two should be reconciled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
ToddHebebrand
added a commit
that referenced
this pull request
Aug 8, 2026
#3262 follow-up) (#3272) Follow-up to #3263, which was merged just before this reviewed hardening commit reached the PR branch (it landed on the fork moments after the squash-merge — see #3263 (comment)). Same diff as fork commit `1cfe0e8`, cherry-picked onto main: - **App-layer same-partner ownership check** on partner-wide script PUT/DELETE: `script.partnerId !== auth.partnerId` now returns 404 (no existence leak) instead of relying solely on RLS row-invisibility. Defense-in-depth — no exploit today, but protects against future refactors that load scripts under a system DB context (the #1105 pattern). - **`RescopeAuth.partnerOrgAccess` is now a required key**, so future callers of `resolveRescopeTarget` can't omit it and silently compile into fail-closed 403s for genuine full-partner admins. - Both route guards collapse into one `partnerWideScriptWriteError` helper so PUT and DELETE can't drift. One pre-existing test updated: cross-partner rescope now expects 404 rather than the old 403 "not owned by your partner" (that message was the existence leak). Verified: `scripts.test.ts` 47 passed | 2 skipped (two new cross-partner denial tests); `tsc --noEmit` clean on apps/api. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3262.
Partner scope was standing in for the partner-wide capability.
canManagePartnerWidePoliciesappeared nowhere inscripts.ts, and its doc comment names this exact substitution as the thing it exists to prevent.Four sites, not one
Your issue names the create path and asks for update/delete too. Building it surfaced a fourth that isn't in the issue — and it's the one I'd most expect to be missed:
availability: 'partner'auth.scope === 'partner'resolveRescopeTargetchecked scope onlyauth.scope === 'organization'selecteduser could rewrite code already running as SYSTEM everywhereSites 3 and 4 already had a partner-wide guard — it just tested the wrong thing. It kept org-scoped users out and let every partner-scoped user through regardless of
org_access.Site 2 is the one worth a second look on review:
availability: 'partner'onPUT /:idreaches partner-wide state without ever touching the create handler, so gating create alone would have left the privilege fully reachable.Implementation
Follows
peripheralControl.ts:508verbatim —PARTNER_WIDE_WRITE_DENIED_MESSAGEwith 403 — matching the nine other routes you listed as doing it correctly.RescopeAuthgainspartnerOrgAccess(the capability is deliberately not derivable fromaccessibleOrgIds, so it has to be carried) and itsscopenarrows fromstringtoAuthContext['scope']so the capability check typechecks.Tests, and the control that mattered
Five: four denials — create, widen, edit, delete as a partner user with
partnerOrgAccess: 'selected'— plus a positive control proving a full-partner admin can still widen, so the denials can't pass on a handler that simply rejects every partner write.The two existing partner-auth helpers now default to
partnerOrgAccess: 'all'. That is the user those positive cases always meant to describe; without it the gate would have failed pre-existing tests, which is itself a small signal the fixture was under-specified.All five were run against the un-fixed code. My first control attempt was wrong and worth flagging: I neutered the guards with a regex that matched only two of the four call sites — the edit/delete guards use a different expression shape — so only two denials failed and I would have shipped two unproven tests. Neutering
canManagePartnerWidePoliciesitself toreturn truegives the honest result:Gate
tsc --noEmitexit 0 ·vitest run1285 files / 20525 tests / 0 failures ·eslintclean on both changed files.Left out deliberately
Your point 3 — auditing existing
orgId IS NULL AND partner_id IS NOT NULLscripts for any created by aselected-access user — is a query against your deployment rather than a code change, so it isn't in this diff.