From d613d1e55792a72fd22bd554d948f4e22bc07a33 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 29 Jun 2026 16:43:53 -0400 Subject: [PATCH 1/2] test: verify built-in token can resolve collaborator permission Throwaway workflow to confirm the Actions GITHUB_TOKEN can call the collaborator-permission endpoint (and see private-team-derived access) under the external-contributor-alerts permission scope. To be deleted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test-collaborator-token-check.yml | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/test-collaborator-token-check.yml diff --git a/.github/workflows/test-collaborator-token-check.yml b/.github/workflows/test-collaborator-token-check.yml new file mode 100644 index 00000000..c55269ec --- /dev/null +++ b/.github/workflows/test-collaborator-token-check.yml @@ -0,0 +1,52 @@ +name: TEST token collaborator check + +# Throwaway workflow to verify the built-in GITHUB_TOKEN can resolve a user's +# repo permission (including access granted via private teams) under the same +# permissions the external-contributor-alerts workflow runs with. +# Delete this file once the result is confirmed. + +on: + push: + branches: [test/collaborator-token-check] + workflow_dispatch: + inputs: + username: + description: "Login to check (overrides the default list)" + required: false + +# Mirror external-contributor-alerts.yml so the token is identical. +permissions: + issues: write + pull-requests: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v9 + env: + TARGET_USER: ${{ inputs.username }} + with: + script: | + const { owner, repo } = context.repo; + const input = (process.env.TARGET_USER || '').trim(); + // teeohhem: private ClickHouse member with write access (the bug case) -> expect INTERNAL + // octocat: no access -> expect EXTERNAL (control) + const users = input ? [input] : ['teeohhem', 'octocat']; + for (const username of users) { + try { + const { data, status } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username, + }); + const verdict = data.permission !== 'none' ? 'INTERNAL' : 'EXTERNAL'; + core.info(`[${username}] HTTP ${status} permission='${data.permission}' role='${data.role_name}' => ${verdict}`); + } catch (e) { + if (e.status === 404) { + core.info(`[${username}] HTTP 404 not a collaborator => EXTERNAL`); + } else if (e.status === 403) { + core.setFailed(`[${username}] HTTP 403 => built-in GITHUB_TOKEN CANNOT call this endpoint (would need a PAT/App token).`); + } else { + core.setFailed(`[${username}] HTTP ${e.status}: ${e.message}`); + } + } + } From 040654a56f8a04bbe18472d5abe9b5ce23a074c7 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 29 Jun 2026 16:45:14 -0400 Subject: [PATCH 2/2] test: threshold on granted access, not implicit public read Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/test-collaborator-token-check.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-collaborator-token-check.yml b/.github/workflows/test-collaborator-token-check.yml index c55269ec..4a80e740 100644 --- a/.github/workflows/test-collaborator-token-check.yml +++ b/.github/workflows/test-collaborator-token-check.yml @@ -31,15 +31,19 @@ jobs: const { owner, repo } = context.repo; const input = (process.env.TARGET_USER || '').trim(); // teeohhem: private ClickHouse member with write access (the bug case) -> expect INTERNAL - // octocat: no access -> expect EXTERNAL (control) + // octocat: no granted access (public repo gives implicit read) -> expect EXTERNAL (control) const users = input ? [input] : ['teeohhem', 'octocat']; + // Public repos grant everyone implicit 'read', so test for actual + // granted access (triage and up), not merely permission != 'none'. + const isInternal = (p) => !!(p && (p.admin || p.maintain || p.push || p.triage)); for (const username of users) { try { const { data, status } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username, }); - const verdict = data.permission !== 'none' ? 'INTERNAL' : 'EXTERNAL'; - core.info(`[${username}] HTTP ${status} permission='${data.permission}' role='${data.role_name}' => ${verdict}`); + const perms = data.user?.permissions ?? {}; + const verdict = isInternal(perms) ? 'INTERNAL' : 'EXTERNAL'; + core.info(`[${username}] HTTP ${status} permission='${data.permission}' role='${data.role_name}' perms=${JSON.stringify(perms)} => ${verdict}`); } catch (e) { if (e.status === 404) { core.info(`[${username}] HTTP 404 not a collaborator => EXTERNAL`);