fix(zapier): request base|read_all, and ask stale connections to reconnect - #3
Merged
Conversation
The Base dropdown is powered by GET /api/base/access/all, which the
backend guards with `base|read_all`. We only ever requested `base|read`.
This worked until 2026-07-21. Before then the backend unconditionally
concatenated `base|read_all` onto every OAuth client's scopes:
scopes: scopes.concat('base|read_all'),
teable cce429cb4 removed that implicit grant as a security fix for
GHSA-c57x (OAuth scope escalation — third-party apps were getting broader
read access than the user approved). Correct fix on their side; it just
exposed that we were relying on the escalation without knowing it.
Since then GET /api/base/access/all returns
403 {"message":"Forbidden resource","code":"restricted_resource"}
so the Base dropdown renders empty and no new Zap can be configured.
Already-configured Zaps keep running — they only touch table/record
endpoints, whose scopes we request explicitly — which is why this
surfaced as user reports rather than as broken Zaps.
The connection itself still tests fine (authentication.test hits
GET /api/auth/user, which only needs `user|email_read`), so the
integration looks healthy while being unusable, and reconnecting never
helps because it re-requests the same insufficient scope.
Adds a unit test pinning every scope to the endpoint that needs it. All
other endpoints were audited against their controller `@Permissions` and
are correctly covered — the removed implicit grant covered `base|read_all`
only, so the blast radius is exactly this one dropdown.
Requires a matching change on the Teable side BEFORE release: the OAuth
App (client id cltmh2wegs4wq0xoq4j on app.teable.ai) must be granted
`base|read_all`, otherwise authorize rejects the request with
"Invalid scopes" (oauth-server.service.ts:137). Every existing user must
reconnect once, since scopes are fixed at grant time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
caoxing9
force-pushed
the
fix/zapier-base-read-all-scope
branch
from
August 14, 2026 17:07
0688fe1 to
7e8048e
Compare
Adding `base|read_all` fixes new authorizations, but it cannot repair the
connections that already exist: a token's scopes are fixed at the moment
the user grants them, so every connection created before this ships stays
broken until its owner reconnects.
Zapier will not prompt them on its own. authentication.test calls
GET /api/auth/user, which only needs `user|email_read` and keeps
returning 200, so the connection looks healthy and no reconnect flow is
ever offered. Meanwhile the Base dropdown just renders empty, which reads
as "this account has no bases" — the reporter of the original bug spent
days with Zapier support chasing that before it reached us.
So translate the 403 where it happens: bases.perform now throws
ExpiredAuthError, which is Zapier's signal to mark the connection as
needing attention and walk the user through reconnecting. Each affected
user gets told, at the moment they hit the problem, by the product rather
than by an email from us — and the same applies to any future scope
change, which is what makes this scale past a handful of users.
Deliberately scoped to the Base dropdown. Throwing from
authentication.test or from a polling trigger would flag every
not-yet-reconnected connection as broken and stop Zaps that are running
perfectly well — they only touch table/record endpoints, whose scopes we
have always requested explicitly. The dropdown is the one place the
missing scope actually bites, and it is only reached while someone is
editing a Zap.
statusOf() reads the status back out of z.errors.Error, which
JSON-stringifies { message, code, status } into the Error's message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CI deploy guard refuses to overwrite a version that is promoted and
has Zap users, and 1.0.0 is both (3 users). Without a bump, merging this
to main fails the deploy outright.
Minor rather than patch: this changes the OAuth scopes the integration
requests, so users see a new item on the consent screen and every
existing connection has to be re-authorized. That is more than a fix.
Also documents the release sequence in the README, because a version bump
turns a one-command deploy into four, and the two extra steps both fail
silently:
- Env is per-version and does NOT follow a bump. A same-version push
preserves it; a new version starts empty. Promoting without CLIENT_ID
breaks OAuth for everyone — worse than the bug being fixed.
- `promote` only decides what new users install. Without `migrate`, the
existing users stay on the old version and never get the fix.
Co-Authored-By: Claude Opus 5 (1M context) <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.
Problem
Reported by a user (
ryan@garrisonre.com): "Zapier not seeing my Bases… everything appears connected correctly. I have deleted connections, cleared cache, tried again — nothing changes."The Base dropdown is powered by
GET /api/base/access/all, which the backend guards withbase|read_all. We only ever requestedbase|read.This is a regression from a Teable-side security fix (2026-07-21)
It used to work. The backend unconditionally handed
base|read_allto every OAuth client, regardless of consented scopes. teablecce429cb4removed that:// apps/nestjs-backend/src/features/auth/permission.service.ts if (clientId && clientId.startsWith(IdPrefix.OAuthClient)) { + // Only expose base|read_all when the user actually consented to it. + // Previously it was concatenated unconditionally, granting third-party + // OAuth apps broader read access than the scopes they were approved for. return { - scopes: scopes.concat('base|read_all'), + scopes,That's the fix for GHSA-c57x (OAuth scope escalation) — correct on their side. It just exposed that this integration had been silently relying on the escalation.
Note the route's own
@Permissions('base|read_all')is unchanged since 2024-12-19, 18 months before this integration existed. What changed is the token→permission resolution, not the endpoint's requirement.cce429cb4removes the implicit `baseEvidence from production logs (
zapier logs --user=ryan@garrisonre.com --detailed)GET /api/auth/userGET /api/base/access/allGET /api/base/access/allGET /api/base/access/all{"message":"Forbidden resource","status":403,"code":"restricted_resource"}Granted token scopes on that connection — note what's missing:
Fix, part 1 — request the scope
Add
base|read_alltoSCOPES.base|readis kept for the per-base endpoints.There is no narrower alternative: every other base route is a single-resource
:baseIdendpoint, and the route that listed bases byspaceIdno longer exists./base/access/allis the only way to enumerate bases, sobase|read_allis unavoidable.Also adds a unit test pinning each scope to the endpoint that needs it. A missing scope fails silently, and this is the second time it's bitten us (see d0e7c56,
view|read). Every other endpoint was audited against its controller@Permissionsand is correctly covered — the removed implicit grant coveredbase|read_allonly, so the blast radius is exactly this one dropdown.Fix, part 2 — tell existing connections to reconnect
Part 1 only fixes new authorizations. A token's scopes are fixed when it is granted, so every connection created before this ships stays broken until its owner reconnects — and Zapier will not prompt them:
authentication.testcallsGET /api/auth/user, which needs onlyuser|email_readand keeps returning 200. The connection looks healthy, so no reconnect flow is ever offered.So
bases.performnow translates the 403 intoExpiredAuthError, Zapier's signal to flag the connection and walk the user through reconnecting. Each affected user is told by the product, at the moment they hit the problem — and the same holds for any future scope change, which is what makes this scale past a handful of users.Deliberately scoped to the Base dropdown. Throwing from
authentication.testor from a polling trigger would flag every not-yet-reconnected connection as broken and stop Zaps that are running perfectly well — they only touch table/record endpoints, whose scopes we have always requested explicitly. The dropdown is the one place the missing scope actually bites, and it is only reached while someone is editing a Zap.Release
Version bumped 1.0.0 → 1.1.0. The CI guard refuses to overwrite a promoted version that has Zap users, and 1.0.0 is both — without the bump, merging this fails the deploy. Minor rather than patch because the requested OAuth scopes change: users see a new item on the consent screen and every existing connection must be re-authorized.
The OAuth App (client id
cltmh2wegs4wq0xoq4jonapp.teable.ai) already whitelistsbase|read_all("查看所有数据库" is checked), sodifference(queryScopes, scopes)stays empty and no Teable-side config change is needed. Whitelisted ≠ granted — the scope still has to be requested, which is what part 1 does.Both extra steps fail silently if forgotten:
CLIENT_IDbreaks OAuth for everyone — far worse than the bug being fixed.promotedoes not move existing users. Withoutmigrate, the 3 current users stay on 1.0.0 and never receive this fix.migrateswaps their code but not their token, so the re-authorization still has to come from the users themselves — which is what part 2 prompts them to do. The README now documents this sequence.Test plan
npx tsc --noEmitcleannpx jest test/unit.test.ts— 38/38 pass🤖 Generated with Claude Code