Skip to content

[API][RLS] Remote-access launcher resolves no providers for org-scoped callers — partner read is invisible under org scope #3419

Description

@bdunncompany

Description

resolveRemoteAccessLauncherForDevice (apps/api/src/routes/devices/core.ts:1073-1090) reads partners.settings to find the tenant's configured remote-access providers. For an organization-scoped caller that read returns zero rows, so partnerSettings falls back to {}, no providers are found, and both the device-detail availability flag and POST /devices/:id/remote-access-launch degrade to "no provider configured" — for a tenant that has one configured.

This is the same partner-axis RLS gap already fixed once in getEnrollmentDefaultsForOrg (#2776 task 3.4), and the integration test written for that fix documents the chain exactly.

The chain

Each link verified against origin/main (f2d7fda83):

  1. The route admits org scope. POST /:id/remote-access-launch is requireScope('organization', 'partner', 'system') (core.ts:1102). The device-detail read path that computes the availability flag is reachable the same way (core.ts:988-1013).
  2. partners has exactly one SELECT policy, breeze_partner_isolation_select … USING (breeze_has_partner_access(id)) (migrations/2026-04-11-partners-rls.sql:81). I grepped every migration: there is no second permissive policy, and no own-partner branch via breeze_current_partner_id (that helper is used in the catalog and CIS-baseline policies, not here).
  3. breeze_has_partner_access is system → TRUE, NULL → FALSE, else id = ANY(breeze_accessible_partner_ids()) (migrations/2026-04-11-a-rls-function-bootstrap.sql).
  4. computeAccessiblePartnerIds returns [] for scope === 'organization' (middleware/auth.ts:382-389) — [partnerId] only for partner scope, null only for system.

So an org-scoped session cannot see the partner row, and the join yields nothing.

Why the withSystemDbAccessContext wrapper does not save it

The read is wrapped in withSystemDbAccessContext (core.ts:1077), which looks like it escalates. It does not, inside a request: withDbAccessContext early-returns when a context store already exists (db/index.ts:440), and authMiddleware has already opened one for the request. The nested call therefore retains the caller's ambient scope rather than switching to system.

That is the same behaviour confirmed on #3391, where the identical wrapper in readPreferredProviderId was inert ceremony and was removed. Here the consequence is worse than cosmetic, because this call actually depends on the escalation it appears to perform. A genuine escalation needs runOutsideDbContext first, as the billing bulk handlers do.

Why no test catches it

Quoting the precedent test, which says it better than I would:

A mocked-DB unit test cannot catch this: the mock always returns whatever row the test stages, with no RLS evaluation. Only a real Postgres connection running through the actual breeze_app role, under a real withDbAccessContext org-scoped RLS session, can prove the join is visible.

core.remoteAccessLaunch.test.ts mocks the db entirely, so it stages the partner settings it then asserts on.

What I have and have not established

Verified: every link above, by reading the code and grepping all migrations.

Not verified: a runtime reproduction. I have not stood up a real-Postgres org-scoped session against this resolver, so I am reporting a structural defect rather than an observed failure. It is possible that in practice the technicians who use remote access are partner-scoped, in which case the reachable path is simply untrodden — but the route explicitly admits organization scope, and the enrollment-cap precedent shows this class does reach real callers.

enrollmentDefaultsPartnerCap.integration.test.ts is close to a drop-in template — its orgContext() helper already mirrors computeAccessiblePartnerIds for org scope. Happy to write the equivalent repro and the fix together if you want it; I did not want to push a fix for a path I had only proven on paper.

Suggested fix

Wrap the partner read in runOutsideDbContext(() => withSystemDbAccessContext(...)) so the escalation is real. Worth a sweep for the same shape elsewhere: a bare withSystemDbAccessContext inside a request path is inert everywhere, so any other site that depends on it for cross-axis visibility has the same defect.

Found while building the profile-UI provider dropdown for #3391/#3404, where the same read has to work for exactly this org-scoped population.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions