Skip to content

fix(api): gate partner-wide script writes on canManagePartnerWidePolicies (#3262) - #3263

Merged
ToddHebebrand merged 2 commits into
LanternOps:mainfrom
bdunncompany:fix/3262-scripts-partner-wide-gate
Aug 8, 2026
Merged

fix(api): gate partner-wide script writes on canManagePartnerWidePolicies (#3262)#3263
ToddHebebrand merged 2 commits into
LanternOps:mainfrom
bdunncompany:fix/3262-scripts-partner-wide-gate

Conversation

@bdunncompany

Copy link
Copy Markdown
Collaborator

Fixes #3262.

Partner scope was standing in for the partner-wide capability. canManagePartnerWidePolicies appeared nowhere in scripts.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:

# Vector Prior guard Gap
1 Create with availability: 'partner' auth.scope === 'partner' the reported vector
2 Widen an org script to partner-wide on edit resolveRescopeTarget checked scope only a second creation vector for the same privilege
3 Edit an existing partner-wide script blocked only auth.scope === 'organization' a partner selected user could rewrite code already running as SYSTEM everywhere
4 Delete an existing partner-wide script same guard, same gap removes automation from every org under the partner

Sites 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' on PUT /:id reaches partner-wide state without ever touching the create handler, so gating create alone would have left the privilege fully reachable.

Implementation

Follows peripheralControl.ts:508 verbatim — PARTNER_WIDE_WRITE_DENIED_MESSAGE with 403 — matching the nine other routes you listed as doing it correctly.

RescopeAuth gains partnerOrgAccess (the capability is deliberately not derivable from accessibleOrgIds, so it has to be carried) and its scope narrows from string to AuthContext['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 canManagePartnerWidePolicies itself to return true gives the honest result:

× create as 'selected'   FAIL
× widen  as 'selected'   FAIL
× edit   as 'selected'   FAIL
× delete as 'selected'   FAIL
✓ widen  as 'all'        PASS   (control still passes)

Gate

tsc --noEmit exit 0 · vitest run 1285 files / 20525 tests / 0 failures · eslint clean on both changed files.

Left out deliberately

Your point 3 — auditing existing orgId IS NULL AND partner_id IS NOT NULL scripts for any created by a selected-access user — is a query against your deployment rather than a code change, so it isn't in this diff.

bdunncompany and others added 2 commits August 7, 2026 23:06
…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>
@ToddHebebrand
ToddHebebrand merged commit 42a1b9b into LanternOps:main Aug 8, 2026
54 checks passed
@ToddHebebrand

Copy link
Copy Markdown
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:

  • Same-partner ownership check in the app layer. The new PUT/DELETE guards checked the capability but relied on RLS row-invisibility alone for cross-partner containment. There's no exploit today, but any future refactor that loads scripts under a system DB context (the [api] Mass agent reconnect can poison the Postgres pool (idle-in-transaction timeouts on config_policy_assignments) → heartbeat 500s + login outage #1105 pattern) would have turned that into cross-tenant write/delete. The guard now returns 404 (not a leaky 403) when script.partnerId !== auth.partnerId — one pre-existing rescope test updated accordingly since the ownership guard now fires first.
  • RescopeAuth.partnerOrgAccess is now a required key so future callers can't omit it and silently compile into fail-closed 403s for genuine full-partner admins.
  • Both route guards now share one partnerWideScriptWriteError helper so PUT and DELETE can't drift.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants