-
Notifications
You must be signed in to change notification settings - Fork 0
310 lines (282 loc) · 16 KB
/
Copy pathcheck-token-permissions.yml
File metadata and controls
310 lines (282 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
name: Check Token Permissions
# Probes a GitHub token for every permission the Dependabot automation needs
# (see DESIGN-dependabot-automation.md) and reports which features it can and
# cannot support. Purely diagnostic - it reads, and the one mutation it issues
# is deliberately given invalid node IDs so it can never change anything.
#
# Run this after rotating GH_ACTIONS_REPO_TOKEN, or before building a feature
# that needs a scope the token may not have (notably Projects v2).
#
# See README-check-token-permissions.md for details.
on:
workflow_dispatch:
inputs:
token:
description: 'Token to probe. Falls back to GH_ACTIONS_REPO_TOKEN.'
required: false
type: string
default: ''
org:
description: 'Organization that owns the Projects v2 boards.'
required: false
type: string
default: 'spring-cloud'
oss_repo:
description: 'An OSS repo to probe read/write access against.'
required: false
type: string
default: 'spring-cloud/spring-cloud-build'
commercial_repo:
description: 'A commercial repo to probe read/write access against.'
required: false
type: string
default: 'spring-cloud/spring-cloud-build-commercial'
project_title:
description: 'Optional Projects v2 board title to look for (e.g. 2025.1.3). Empty just lists what is visible.'
required: false
type: string
default: ''
permissions:
contents: read
jobs:
probe:
name: Probe token
runs-on: ubuntu-latest
steps:
- name: Run permission probes
env:
GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }}
ORG: ${{ inputs.org }}
OSS_REPO: ${{ inputs.oss_repo }}
COMMERCIAL_REPO: ${{ inputs.commercial_repo }}
PROJECT_TITLE: ${{ inputs.project_title }}
run: |
node - << 'JSEOF'
const fs = require('fs');
const { execFileSync } = require('child_process');
const ORG = process.env.ORG;
const OSS = process.env.OSS_REPO;
const COMM = process.env.COMMERCIAL_REPO;
const WANT_PROJECT = (process.env.PROJECT_TITLE || '').trim();
// gh exits non-zero on API and GraphQL errors alike. Both still print the
// response body on stdout, which is where the useful detail lives, so
// failures are captured rather than thrown.
const gh = args => {
try {
return { ok: true, out: execFileSync('gh', args,
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1 << 26 }) };
} catch (err) {
return {
ok: false,
out: err.stdout || '',
err: (err.stderr || err.message || '').split('\n')[0].trim(),
};
}
};
// A GraphQL call can exit zero and still carry an "errors" array, so the
// body is inspected regardless of exit status.
const graphql = (query, ...fields) => {
const r = gh(['api', 'graphql', '-f', `query=${query}`, ...fields]);
let body = null;
try { body = JSON.parse(r.out); } catch (_) { /* non-JSON error output */ }
const errors = body?.errors || [];
return { ...r, body, errors, ok: r.ok && errors.length === 0 };
};
const results = [];
const add = (check, status, detail) => {
results.push({ check, status, detail });
const icon = { ok: '✅', warn: '⚠️', fail: '❌' }[status];
console.log(`${icon} ${check}: ${detail}`);
};
// ── Identity and declared scopes ────────────────────────────────────────────
// Classic PATs return their scopes in a response header. Fine-grained tokens
// and GitHub App installation tokens send it empty, which is not a problem -
// it just means the functional probes below are the only real evidence.
const who = gh(['api', 'user', '--jq', '.login']);
add('Token identity', who.ok ? 'ok' : 'warn',
who.ok ? `authenticated as ${who.out.trim()}`
: `could not read /user (${who.err}) - normal for an App installation token`);
const headers = gh(['api', '-i', '/rate_limit']);
const scopeLine = (headers.out.match(/^x-oauth-scopes:(.*)$/im) || [])[1];
const scopes = (scopeLine || '').trim();
let declared = null;
if (scopes) {
declared = scopes.split(',').map(s => s.trim()).filter(Boolean);
add('Declared scopes', 'ok', `\`${declared.join('`, `')}\``);
} else {
add('Declared scopes', 'warn',
'none reported - fine-grained or App token; rely on the functional probes below');
}
// ── Repository read + write ─────────────────────────────────────────────────
// .permissions.push is the honest signal for "can this token write issues,
// milestones and comments here" without actually mutating anything.
for (const [label, repo] of [['OSS', OSS], ['Commercial', COMM]]) {
const r = gh(['api', `repos/${repo}`, '--jq', '.permissions']);
if (!r.ok) {
add(`${label} repo access (\`${repo}\`)`, 'fail', `cannot read: ${r.err}`);
continue;
}
let perms = {};
try { perms = JSON.parse(r.out); } catch (_) { /* ignore */ }
const canWrite = perms.push === true;
add(`${label} repo access (\`${repo}\`)`, canWrite ? 'ok' : 'warn',
canWrite ? 'read + write (push)' : 'read only - cannot set milestones or comment');
}
// ── Actions read: the Dependabot update-job feed (feature 1) ────────────────
const runs = gh(['api',
`repos/${OSS}/actions/runs?actor=dependabot%5Bbot%5D&event=dynamic&per_page=1`,
'--jq', '.total_count']);
add('Dependabot update runs readable', runs.ok ? 'ok' : 'fail',
runs.ok ? `${runs.out.trim()} run(s) visible on \`${OSS}\``
: `cannot list workflow runs: ${runs.err}`);
// ── Pull requests and milestones (features 2, 3, 4) ─────────────────────────
const prs = gh(['api', `repos/${OSS}/pulls?state=open&per_page=1`, '--jq', 'length']);
add('Pull requests readable', prs.ok ? 'ok' : 'fail',
prs.ok ? 'ok' : `cannot list pull requests: ${prs.err}`);
const miles = gh(['api', `repos/${OSS}/milestones?per_page=1`, '--jq', 'length']);
add('Milestones readable', miles.ok ? 'ok' : 'fail',
miles.ok ? 'ok' : `cannot list milestones: ${miles.err}`);
// ── The releaser config branch that resolves a PR's project (feature 2) ─────
// The file names are also where the board titles come from: a snapshot properties
// file 2025_1_3-snapshot.properties corresponds to the board "2025.1.3". Deriving
// them here means the write probe below can target the board actually in use
// without anyone having to name it.
const releaser = gh(['api',
`repos/${ORG}/spring-cloud-release/contents/?ref=jenkins-releaser-config`,
'--jq', '[.[] | select(.name | endswith("-snapshot.properties")) | .name] | join(",")']);
let trains = [];
if (releaser.ok) {
trains = releaser.out.trim().split(',').filter(Boolean)
.map(n => n.replace('-snapshot.properties', '').replace(/_/g, '.'));
}
const cmpVer = (a, b) => {
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
}
return 0;
};
trains.sort(cmpVer);
const newestTrain = trains[trains.length - 1] || '';
add('`jenkins-releaser-config` readable', releaser.ok ? 'ok' : 'fail',
releaser.ok ? `${trains.length} snapshot properties file(s): ${trains.join(', ') || 'none'}`
: `cannot read the branch: ${releaser.err}`);
// ── Projects v2 read ───────────────────────────────────────────────────────
const projQuery = `{ organization(login: "${ORG}") { projectsV2(first: 100) { nodes { id number title closed } } } }`;
const projRead = graphql(projQuery);
const insufficient = r => r.errors.some(e => e.type === 'INSUFFICIENT_SCOPES');
let projectsVisible = null;
if (projRead.ok) {
const nodes = projRead.body?.data?.organization?.projectsV2?.nodes || [];
projectsVisible = nodes;
const open = nodes.filter(n => !n.closed);
add('Projects v2 readable', 'ok',
`${nodes.length} board(s) visible, ${open.length} open`);
} else if (insufficient(projRead)) {
add('Projects v2 readable', 'fail',
'INSUFFICIENT_SCOPES - token needs `read:project` (or `project`)');
} else {
add('Projects v2 readable', 'fail',
projRead.errors[0]?.message || projRead.err || 'unknown GraphQL error');
}
// ── Which board should the write probe target? ──────────────────────────────
// Defaults to the newest release train, which is the board triage actually writes
// to, so a plain run checks the thing that matters.
const targetTitle = WANT_PROJECT || newestTrain;
let targetBoard = null;
if (targetTitle) {
if (projectsVisible === null) {
add(`Board \`${targetTitle}\` present`, 'warn',
'could not check - Projects v2 is not readable with this token');
} else {
targetBoard = projectsVisible.find(n => n.title === targetTitle) || null;
add(`Board \`${targetTitle}\` present`, targetBoard ? 'ok' : 'fail',
targetBoard ? `found (#${targetBoard.number}${targetBoard.closed ? ', closed' : ''})`
: `no board titled \`${targetTitle}\` in \`${ORG}\``);
}
}
// ── Projects v2 write ──────────────────────────────────────────────────────
// Two distinct things can stop a write, and they need separate probes:
//
// 1. the token lacks the `project` scope -> INSUFFICIENT_SCOPES
// 2. the account lacks write access to *that board* -> "does not have the
// correct permissions to execute `AddProjectV2ItemById`"
//
// The scope probe below uses invalid node IDs, so GitHub never resolves a board
// and case 2 cannot surface - which is exactly how an earlier version of this
// workflow reported a green write check while triage failed on every PR. The
// board probe therefore uses the *real* project ID with an invalid content ID,
// so the per-board authorization check actually runs. Nothing can be added
// either way: no PR has the content ID being passed.
const scopeProbe = graphql(
'mutation { addProjectV2ItemById(input: {projectId: "PVT_probe_invalid", ' +
'contentId: "PVTI_probe_invalid"}) { item { id } } }');
const hasScope = !insufficient(scopeProbe);
add('Projects v2 `project` scope', hasScope ? 'ok' : 'fail',
hasScope ? 'granted (scope only - board access is checked separately below)'
: 'INSUFFICIENT_SCOPES - token needs the `project` scope');
const forbidden = r => r.errors.some(e =>
/does not have the correct permissions|FORBIDDEN|Resource not accessible/i
.test(e.message || '') || e.type === 'FORBIDDEN');
let canWriteBoard = null;
if (!hasScope) {
add('Projects v2 writable on the board', 'fail',
'cannot check without the `project` scope');
canWriteBoard = false;
} else if (!targetBoard) {
add('Projects v2 writable on the board', 'warn',
'no board resolved to probe - pass project_title to check a specific board');
} else {
const boardProbe = graphql(
'mutation($p:ID!){ addProjectV2ItemById(input: {projectId: $p, ' +
'contentId: "PVTI_probe_invalid"}) { item { id } } }', '-f', `p=${targetBoard.id}`);
if (forbidden(boardProbe)) {
canWriteBoard = false;
add(`Projects v2 writable on \`${targetTitle}\``, 'fail',
`${who.ok ? who.out.trim() : 'this account'} has the scope but no write access ` +
`to board #${targetBoard.number} - grant it under the project's Manage access`);
} else {
canWriteBoard = true;
add(`Projects v2 writable on \`${targetTitle}\``, 'ok',
`authorized (probe rejected on the invalid content ID, as intended)`);
}
}
// ── Report ─────────────────────────────────────────────────────────────────
const icon = s => ({ ok: '✅', warn: '⚠️', fail: '❌' })[s];
const by = name => results.find(r => r.check.startsWith(name));
const passing = name => by(name)?.status === 'ok';
const lines = ['## Token Permission Probe', ''];
lines.push(`Org \`${ORG}\` · OSS \`${OSS}\` · commercial \`${COMM}\``, '');
lines.push('| | Check | Detail |', '|---|---|---|');
for (const r of results) {
lines.push(`| ${icon(r.status)} | ${r.check} | ${r.detail} |`);
}
// Translate the raw probes into "can each designed feature actually run?",
// which is the question this workflow exists to answer.
const canWriteOss = by('OSS repo access')?.status === 'ok';
const features = [
['1 — Alert on failing Dependabot workflows',
passing('Dependabot update runs readable')],
['2a — Set milestones on Dependabot PRs',
passing('Milestones readable') && canWriteOss],
['2b — Add OSS PRs to the correct project',
passing('Projects v2 readable') && passing('Projects v2 `project` scope')
&& canWriteBoard === true
&& passing('`jenkins-releaser-config` readable')],
['3 — Comment `@dependabot rebase` on conflicts', canWriteOss],
['4 — Daily Dependabot PR report',
passing('Pull requests readable') && passing('Milestones readable')],
];
lines.push('', '### Feature readiness', '', '| | Feature |', '|---|---|');
for (const [name, ready] of features) {
lines.push(`| ${ready ? '✅' : '❌'} | ${name} |`);
}
const blocked = features.filter(([, ready]) => !ready);
lines.push('');
lines.push(blocked.length
? `**${blocked.length} of ${features.length} features ${blocked.length === 1 ? 'is' : 'are'} blocked** by missing permissions.`
: `**All ${features.length} features are supported** by this token.`);
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join('\n') + '\n');
console.log('\n' + lines.join('\n'));
// Always exits 0 - this reports on a token, it does not gate anything.
JSEOF