Skip to content

fix(zapier): request base|read_all, and ask stale connections to reconnect - #3

Merged
caoxing9 merged 3 commits into
mainfrom
fix/zapier-base-read-all-scope
Aug 17, 2026
Merged

fix(zapier): request base|read_all, and ask stale connections to reconnect#3
caoxing9 merged 3 commits into
mainfrom
fix/zapier-base-read-all-scope

Conversation

@caoxing9

@caoxing9 caoxing9 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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 with base|read_all. We only ever requested base|read.

This is a regression from a Teable-side security fix (2026-07-21)

It used to work. The backend unconditionally handed base|read_all to every OAuth client, regardless of consented scopes. teable cce429cb4 removed 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.

Date Event
2026-06-24 Integration ships requesting `base
2026-07-21 cce429cb4 removes the implicit `base
2026-08-13 User reports the Base dropdown is empty

Evidence from production logs (zapier logs --user=ryan@garrisonre.com --detailed)

Time (CDT) Endpoint Status
17:53:21 GET /api/auth/user 200
17:53:27 GET /api/base/access/all 403
17:54:53 GET /api/base/access/all 403
17:56:56 GET /api/base/access/all 403
{"message":"Forbidden resource","status":403,"code":"restricted_resource"}

Granted token scopes on that connection — note what's missing:

["base|read","table|read","field|read","view|read",
 "record|read","record|create","record|update","record|delete","user|email_read"]

Fix, part 1 — request the scope

Add base|read_all to SCOPES. base|read is kept for the per-base endpoints.

There is no narrower alternative: every other base route is a single-resource :baseId endpoint, and the route that listed bases by spaceId no longer exists. /base/access/all is the only way to enumerate bases, so base|read_all is 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 @Permissions and is correctly covered — the removed implicit grant covered base|read_all only, 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.test calls GET /api/auth/user, which needs only user|email_read and keeps returning 200. The connection looks healthy, so no reconnect flow is ever offered.
  • The Base dropdown just renders empty, which reads as "this account has no bases". That is what sent the reporter to Zapier support for days, chasing a problem neither they nor Zapier could fix.

So bases.perform now translates the 403 into ExpiredAuthError, 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.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.

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 cltmh2wegs4wq0xoq4j on app.teable.ai) already whitelists base|read_all ("查看所有数据库" is checked), so difference(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.

# merge -> CI pushes 1.1.0
npx zapier-platform env:get 1.1.0            # <- do not skip
npx zapier-platform promote 1.1.0
npx zapier-platform migrate 1.0.0 1.1.0 100%

Both extra steps fail silently if forgotten:

  • Env is per-version and does not follow a bump. A same-version push preserves it; a new version starts from whatever is (not) set. Promoting 1.1.0 without CLIENT_ID breaks OAuth for everyone — far worse than the bug being fixed.
  • promote does not move existing users. Without migrate, the 3 current users stay on 1.0.0 and never receive this fix.

migrate swaps 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 --noEmit clean
  • npx jest test/unit.test.ts — 38/38 pass
  • After release: reconnect an account and confirm the Base dropdown populates
  • With a pre-fix connection, confirm the dropdown surfaces a reconnect prompt rather than an empty list

🤖 Generated with Claude Code

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
caoxing9 force-pushed the fix/zapier-base-read-all-scope branch from 0688fe1 to 7e8048e Compare August 14, 2026 17:07
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>
@caoxing9 caoxing9 changed the title fix(zapier): request base|read_all so the Base dropdown loads fix(zapier): request base|read_all, and ask stale connections to reconnect Aug 17, 2026
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>
@caoxing9
caoxing9 merged commit eab121c into main Aug 17, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant