Skip to content

fix(api,cli): gate first-claim of a GitHub repo binding on entitlement (issue #297)#356

Merged
Zach Dunn (zachdunn) merged 2 commits into
mainfrom
claude/297-cross-tenant-authz
Jul 21, 2026
Merged

fix(api,cli): gate first-claim of a GitHub repo binding on entitlement (issue #297)#356
Zach Dunn (zachdunn) merged 2 commits into
mainfrom
claude/297-cross-tenant-authz

Conversation

@zachdunn

@zachdunn Zach Dunn (zachdunn) commented Jul 21, 2026

Copy link
Copy Markdown
Member

In plain terms

Any workspace could become the "owner" of any other org's GitHub repo, just by being the first to call the bot-comment/promote endpoints (or uploads github link) against it — the App is installed org-wide, so uploads.sh holds a write token for every customer org, and nothing checked that the calling workspace actually had a real relationship to the repo it was claiming. Once claimed, that workspace could post — or overwrite — the uploads-sh[bot] comment on someone else's PR.

This closes that gap: a workspace can only make the first claim on a repo when its linked GitHub account actually has push access there, checked live against GitHub itself.

The gap (post-#327/#346)

PR #327 added the baseline binding gate (github_repo_links): once a repo is bound, a different workspace's comment call declines. PR #346 let the communal default workspace claim repos like any other workspace. Neither addressed the actual claim step — installationForRepo resolves any org's installation for any workspace's token, so the very first call from any workspace against an unbound repo silently became that repo's permanent binding (recordRepoLink, first-claim-wins). Three routes could create that first binding with zero entitlement check: /github/comment, /github/promote, and the explicit POST /github/link behind uploads github link.

Exploit: org A uploads its own image under a crafted key gh/orgB/repo/pull/N/x.png, then calls its own workspace's /github/comment with {repo: "orgB/repo", num: N}. The server resolves org B's installation, posts as uploads-sh[bot] into org B's PR, and records org A's workspace as orgB/repo's owner going forward — blocking org B's own legitimate calls from then on.

Design

New module apps/api/src/github-claim-authz.ts (isEntitledToClaimRepo) gates every first-claim call site. WorkspaceRecord has no GitHub org/owner field, and the workspace↔org mapping is a Better Auth organization slug — it says nothing about GitHub repo access — so the strongest signal actually available is: the calling token's minting user's linked GitHub login (issue #340/#344 attribution) must have push/maintain/admin permission on the target repo, verified via GET /repos/:repo/collaborators/:login/permission using the App's own installation token. This is GitHub's own authorization data, not anything self-reported by the workspace.

Degrade-safe by construction: no minting user, no linked account, App not installed/configured, or a failed lookup all resolve to "not entitled" — never a 5xx. Legacy/enrollment/shared tokens (including default's) can never claim a new repo, though they keep working normally on repos already bound to them.

Grandfathering: existing github_repo_links rows are never re-validated — the gate only runs when a repo is unbound. Already-bound legitimate repos are unaffected.

Webhook path: github-webhook.ts's auto-promotion only reads existing bindings (findRepoLink) and never creates one, so no change was needed there.

Every decline is a soft { posted: false, reason: "not_authorized" } / { claimed: false, reason: "not_authorized" }, same shape as the existing "bound to someone else" decline — never a server error, and the CLI (uploads github link / the bot-comment fallback logic) never falls back to posting via local gh for it.

What it does / what it is not

  • Gates the implicit claim in /github/comment and /github/promote, and the explicit claim in POST /github/link (uploads github link).
  • Does not touch already-bound repos, the webhook auto-promotion path, or any other authorization already in place.
  • The actor-on-PR enhancement from the issue (author/assignee/reviewer/collaborator check) is deferred — it wasn't a natural drop-out of this signal (collaborator-permission already implies push access, a strictly stronger check), and adding a separate PR-actor lookup would be new scope. Worth a follow-up if a legitimate in-org member spamming a colleague's unrelated PR becomes a real complaint.

How to try it

uploads github link --repo some-org/some-repo

If your linked GitHub account doesn't have push access to that repo, you'll get:

note: some-org/some-repo isn't linked to any workspace yet, and this workspace couldn't be
verified as entitled to claim it. Link a GitHub account with push access to
some-org/some-repo, or ask an operator to bind it explicitly.

Technical notes

  • New: apps/api/src/github-claim-authz.ts, apps/api/src/github-claim-authz.test.ts.
  • Changed: apps/api/src/github-app.ts (new collaboratorPermission), apps/api/src/uploader-identity.ts (extracted resolveUploaderLogin), apps/api/src/routes/github-comment.ts, apps/api/src/routes/github-promote.ts, apps/api/src/routes/github-link.ts.
  • CLI: packages/uploads/src/client.ts (new reason field on GithubLinkClaimResult), packages/uploads/src/commands.ts (new decline message).
  • Docs: apps/web/src/pages/docs/github-app.astro (Repo bindings section), skills/uploads-cli/SKILL.md.
  • Changeset: patch (security fix, no new CLI surface — just a new decline reason and message).

Test plan

  • pnpm test — 2087 tests pass, incl. new github-claim-authz.test.ts (entitled/not-entitled matrix) and updated route tests for /github/comment, /github/promote, /github/link covering: cross-tenant claim refused, legitimate same-identity claim allowed, grandfathered already-bound repos unaffected, soft-decline shape, and the CLI's new decline message.
  • pnpm typecheck
  • pnpm check (oxlint + oxfmt)

Summary by CodeRabbit

  • Security

    • Added GitHub access verification before claiming unbound repositories, preventing unauthorized cross-workspace claims and comment posting.
  • User Experience

    • Unauthorized claims now return a clear not_authorized result instead of an error.
    • CLI guidance explains how to link an account with push access or request an explicit binding.
    • Existing repository bindings continue working unchanged.
  • Documentation

    • Updated repository-binding guidance and troubleshooting instructions to reflect the new authorization requirements.

…t (issue #297)

Closes a live cross-tenant gap: with the App installed org-wide, any
workspace could implicitly bind (via /github/comment or /github/promote) or
explicitly claim (via `uploads github link`) an unbound repo belonging to a
different org, then post or deface the uploads-sh[bot] comment there. There
was no check that the calling workspace had any real relationship to the
repo it was about to own.

A workspace may now make the FIRST claim on a repo only when its linked
GitHub account (issue #340/#344 attribution) has push/maintain/admin access
to that repo, verified live against GitHub via the App's own installation
token (github-claim-authz.ts). Legacy/enrollment/shared tokens (including
the communal `default` workspace's) have no linked identity and can never
claim a new repo, though they keep working on repos already bound to them.
Already-bound repos are grandfathered and never re-checked. Every decline is
a soft `{ posted: false / claimed: false, reason: "not_authorized" }`, never
a 5xx, and the CLI never falls back to `gh` for it.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5432425a-464a-4a68-98cb-155e1f7027df

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Unbound GitHub repository claims now require live verification that the caller’s linked GitHub account has push-level or higher access. Unauthorized claims return soft not_authorized responses, while existing bindings remain supported across comment, link, and promote flows.

Changes

Repository claim authorization

Layer / File(s) Summary
Permission resolution and entitlement checks
apps/api/src/github-app.ts, apps/api/src/github-claim-authz.ts, apps/api/src/uploader-identity.ts, apps/api/src/github-claim-authz.test.ts
Collaborator permissions and minting-user GitHub logins are resolved safely; admin, write, and maintain permissions authorize new claims.
Comment and link claim gates
apps/api/src/routes/github-comment.ts, apps/api/src/routes/github-link.ts, apps/api/src/routes/*-route.test.ts
Unbound repositories require entitlement before implicit or explicit binding, while existing bindings retain their prior behavior and unauthorized requests return not_authorized.
Promotion claim side effect
apps/api/src/routes/github-promote.ts, apps/api/src/routes/github-promote-route.test.ts
Promotion records a new repository link only for verified callers and leaves already-bound repositories as no-op re-records.
Client contracts and guidance
packages/uploads/src/client.ts, packages/uploads/src/commands.ts, packages/uploads/test/commands-github-link.test.ts, apps/web/src/pages/docs/github-app.astro, skills/uploads-cli/SKILL.md, .changeset/*
The client exposes reason: "not_authorized", the CLI prints entitlement guidance without gh fallback, and documentation describes the new claim rules.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GithubCommentRoute
  participant isEntitledToClaimRepo
  participant collaboratorPermission
  participant GitHubAPI
  Client->>GithubCommentRoute: post comment for repository
  GithubCommentRoute->>isEntitledToClaimRepo: verify unbound repository claim
  isEntitledToClaimRepo->>collaboratorPermission: check linked login permission
  collaboratorPermission->>GitHubAPI: request collaborator permission
  GitHubAPI-->>collaboratorPermission: permission response
  collaboratorPermission-->>isEntitledToClaimRepo: entitlement result
  isEntitledToClaimRepo-->>GithubCommentRoute: allow or not_authorized
  GithubCommentRoute-->>Client: comment result
Loading

Possibly related issues

Possibly related PRs

Suggested labels: coderabbit:review

Poem

A rabbit checks the repo gate,
With GitHub paws to validate.
No linked login? Claims decline,
Old bindings hop along just fine.
Soft replies replace a fall—
“Push access first,” they tell us all.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: entitlement-gated first claim of GitHub repo bindings for api and cli.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/297-cross-tenant-authz

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-api aa35f16 Commit Preview URL

Branch Preview URL
Jul 21 2026, 08:54 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-auth c08cfa8 Commit Preview URL

Branch Preview URL
Jul 21 2026, 08:31 PM

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
uploads-web c08cfa8 Commit Preview URL

Branch Preview URL
Jul 21 2026, 08:31 PM

@zachdunn Zach Dunn (zachdunn) added the coderabbit:review Trigger CodeRabbit review for the PR. label Jul 21, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
apps/api/src/github-app.ts (1)

172-195: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add defense-in-depth validation for repo here too.

login is guarded by LOGIN_RE before interpolation, but repo is interpolated into the same GitHub API path (Line 186) with no shape check in this function itself — it relies entirely on every caller (github-link.ts, github-promote.ts, github-comment.ts) pre-validating with REPO_RE/DOTS_ONLY_RE. That's safe today, but this is an exported, security-critical helper (the sole GitHub-side signal for cross-tenant claim authorization) — a future caller that skips that validation could let a crafted repo value (e.g. containing .. segments) get dot-segment-normalized by the URL/fetch layer into an unintended api.github.com path using the installation token.

🛡️ Proposed defensive check
+const REPO_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
+
 export async function collaboratorPermission(
   env: Env,
   cfg: GithubAppConfig,
   installationId: number,
   repo: string,
   login: string,
   fetchImpl: typeof fetch = fetch,
 ): Promise<string | null> {
-  if (!LOGIN_RE.test(login)) return null;
+  if (!LOGIN_RE.test(login) || !REPO_RE.test(repo)) return null;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/github-app.ts` around lines 172 - 195, Add defense-in-depth
validation for the repo parameter at the start of collaboratorPermission, using
the existing repository-shape validation symbol (REPO_RE or the established
equivalent), and return null before obtaining an installation token or
constructing the GitHub URL when validation fails. Preserve the existing
LOGIN_RE check and valid-repository behavior.
apps/api/src/routes/github-comment-route.test.ts (1)

11-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the mintingUserId D1-stub pattern into one shared test helper.

The same "fake an auth_tokens row with minting_user_id" stub is copy-pasted across three test files. A shared helper (e.g. alongside RepoLinksTable/FakeKv in apps/api/test/helpers/) would remove the drift risk if the auth_tokens shape ever changes.

  • apps/api/src/routes/github-comment-route.test.ts#L11-L54: extract claimTestDb's minting-user stub logic into the shared helper and have this file call it.
  • apps/api/src/routes/github-link-route.test.ts#L26-L93: replace the inline db.prepare override in seededEnv with a call to the same shared helper.
  • apps/api/src/routes/github-promote-route.test.ts#L42-L161: replace the inline db.prepare override in seededEnv with a call to the same shared helper (keeping it distinct from the adjacent scopedToken override).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/github-comment-route.test.ts` around lines 11 - 54,
Extract the shared auth_tokens/minting_user_id D1 stub into a helper alongside
RepoLinksTable/FakeKv, preserving the existing token-row shape and lookup
behavior. In apps/api/src/routes/github-comment-route.test.ts#11-54, update
claimTestDb to use the helper; in
apps/api/src/routes/github-link-route.test.ts#26-93 and
apps/api/src/routes/github-promote-route.test.ts#42-161, replace the inline
seededEnv prepare overrides with the same helper, keeping promote’s adjacent
scopedToken override separate.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/github-app.ts`:
- Around line 172-195: Add defense-in-depth validation for the repo parameter at
the start of collaboratorPermission, using the existing repository-shape
validation symbol (REPO_RE or the established equivalent), and return null
before obtaining an installation token or constructing the GitHub URL when
validation fails. Preserve the existing LOGIN_RE check and valid-repository
behavior.

In `@apps/api/src/routes/github-comment-route.test.ts`:
- Around line 11-54: Extract the shared auth_tokens/minting_user_id D1 stub into
a helper alongside RepoLinksTable/FakeKv, preserving the existing token-row
shape and lookup behavior. In
apps/api/src/routes/github-comment-route.test.ts#11-54, update claimTestDb to
use the helper; in apps/api/src/routes/github-link-route.test.ts#26-93 and
apps/api/src/routes/github-promote-route.test.ts#42-161, replace the inline
seededEnv prepare overrides with the same helper, keeping promote’s adjacent
scopedToken override separate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f80081c2-b11b-499f-bbbf-593ad54b286c

📥 Commits

Reviewing files that changed from the base of the PR and between c2468e8 and c08cfa8.

📒 Files selected for processing (16)
  • .changeset/cross-tenant-repo-claim-authz.md
  • apps/api/src/github-app.ts
  • apps/api/src/github-claim-authz.test.ts
  • apps/api/src/github-claim-authz.ts
  • apps/api/src/routes/github-comment-route.test.ts
  • apps/api/src/routes/github-comment.ts
  • apps/api/src/routes/github-link-route.test.ts
  • apps/api/src/routes/github-link.ts
  • apps/api/src/routes/github-promote-route.test.ts
  • apps/api/src/routes/github-promote.ts
  • apps/api/src/uploader-identity.ts
  • apps/web/src/pages/docs/github-app.astro
  • packages/uploads/src/client.ts
  • packages/uploads/src/commands.ts
  • packages/uploads/test/commands-github-link.test.ts
  • skills/uploads-cli/SKILL.md

- collaboratorPermission now validates repo shape (REPO_RE) up front,
  before minting an installation token or building the GitHub URL, matching
  the existing LOGIN_RE defense-in-depth guard.
- Dedupe the auth_tokens/minting_user_id D1 stub used across
  github-comment-route.test.ts, github-link-route.test.ts, and
  github-promote-route.test.ts into a shared
  test/helpers/fake-minting-user-token.ts helper; promote's adjacent
  scopedToken override is untouched.
@zachdunn
Zach Dunn (zachdunn) merged commit 81b220a into main Jul 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

coderabbit:review Trigger CodeRabbit review for the PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant