v0.35.1 2aca1df

ci_permissions_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"net/http"
7	"strings"
8	"testing"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	pb "bnema.dev/ermite-api/gen/ermite"
15	"bnema.dev/ermite/internal/domain"
16)
17
18// CI-3 D3 / AC3: the permissions of the job token, the push guard on workflow
19// files and the rule that a job token's push starts no run.
20
21const permsWorkflow = `on: push
22permissions:
23  contents: write
24jobs:
25  wf:
26    runs-on: ubuntu-latest
27    steps:
28      - run: echo wf
29  readonly:
30    runs-on: ubuntu-latest
31    permissions:
32      contents: read
33      pull-requests: write
34    steps:
35      - run: echo ro
36  none:
37    runs-on: ubuntu-latest
38    permissions: {}
39    steps:
40      - run: echo none
41  all:
42    runs-on: ubuntu-latest
43    permissions: write-all
44    steps:
45      - run: echo all
46`
47
48const defaultPermsWorkflow = "on: push\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
49
50// tokenPush pushes refspec to the repository with the job token, as a job that
51// runs git push with the GITHUB_TOKEN does.
52func (c *ciHarness) tokenPush(dir, token string, refspecs ...string) (string, error) {
53	args := []string{"-c", "http.extraheader=" + checkoutHeader(token), "push", c.url("/owner/proj")}
54	return c.git(dir, append(args, refspecs...)...)
55}
56
57// commitIn commits a file in dir on its current branch.
58func (c *ciHarness) commitIn(dir, path, content string) {
59	c.t.Helper()
60	c.writeFile(dir, path, []byte(content))
61}
62
63// jobsOf collects the jobs of the one run by key.
64func startedJobs(runner *fakeRunner, n int) map[string]*pb.Job {
65	return runner.jobsByKey(n)
66}
67
68func levelsOf(j *pb.Job) map[string]string { return j.GetPermissions().GetLevels() }
69
70func TestJobPermissionsAreResolvedStoredAndSent(t *testing.T) {
71	c := newCIHarness(t, nil)
72	runner := c.registerRunner("alpha", "ubuntu-latest")
73	c.push("main", map[string]string{".github/workflows/p.yml": permsWorkflow}, "")
74	jobs := startedJobs(runner, 4)
75
76	wf := levelsOf(jobs["wf"])
77	assert.Equal(t, map[string]string{"metadata": "read", "contents": "write"}, wf, "the workflow's permissions")
78	assert.True(t, jobs["wf"].GetTrusted())
79	assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "pull-requests": "write"}, levelsOf(jobs["readonly"]), "the job's replace the workflow's")
80	assert.Equal(t, map[string]string{"metadata": "read"}, levelsOf(jobs["none"]), "permissions: {} is all none")
81	assert.Equal(t, "write", levelsOf(jobs["all"])["issues"])
82	assert.Equal(t, "write", levelsOf(jobs["all"])["contents"])
83	assert.Equal(t, "read", levelsOf(jobs["all"])["models"], "write-all keeps models at read")
84
85	// The record keeps what the workflow asked for.
86	stored, err := c.forge.store.CI().ListJobs(c.ctx, c.runs()[0].GetId(), 1)
87	require.NoError(t, err)
88	byKey := map[string]domain.CIJob{}
89	for _, j := range stored {
90		byKey[j.Key] = j
91	}
92	assert.Equal(t, "write", byKey["wf"].Permissions["contents"])
93	assert.Equal(t, map[string]string{"metadata": "read"}, byKey["none"].Permissions)
94
95	// What each token may do on the repository.
96	push := func(j *pb.Job, branch string) (string, error) {
97		c.mustGit(c.wt, "checkout", "-q", "main")
98		c.commitIn(c.wt, "n-"+branch+".txt", branch)
99		return c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/main")
100	}
101	_, err = push(jobs["readonly"], "r")
102	require.Error(t, err, "contents: read cannot push")
103	_, err = push(jobs["none"], "n")
104	require.Error(t, err, "contents: none cannot push")
105	c.mustGit(c.wt, "reset", "-q", "--hard", "HEAD~2")
106	out, err := c.clone(jobs["none"].GetGithubToken(), c.url("/owner/proj"))
107	require.Error(t, err, "contents: none cannot even clone a private repository: "+out)
108	out, err = c.clone(jobs["readonly"].GetGithubToken(), c.url("/owner/proj"))
109	require.NoError(t, err, out)
110	out, err = push(jobs["wf"], "w")
111	require.NoError(t, err, out)
112	out, err = push(jobs["all"], "a")
113	require.NoError(t, err, out)
114}
115
116func TestDefaultTokenCannotPush(t *testing.T) {
117	c := newCIHarness(t, nil)
118	runner := c.registerRunner("alpha", "ubuntu-latest")
119	c.push("main", map[string]string{".github/workflows/p.yml": defaultPermsWorkflow}, "")
120	job := runner.await()
121	assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "packages": "read"}, levelsOf(job), "the restricted default")
122
123	c.commitIn(c.wt, "x.txt", "x")
124	before := c.bareGit("proj", "rev-parse", "refs/heads/main")
125	out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/main")
126	require.Error(t, err)
127	assert.Contains(t, out, "403")
128	assert.Equal(t, before, c.bareGit("proj", "rev-parse", "refs/heads/main"))
129	out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
130	require.NoError(t, err, "it reads: "+out)
131}
132
133func TestJobTokenWithContentsWritePushesButNotWorkflows(t *testing.T) {
134	c := newCIHarness(t, nil)
135	runner := c.registerRunner("alpha", "ubuntu-latest")
136	wfYAML := "on:\n  push:\n  create:\n  delete:\npermissions:\n  contents: write\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
137	c.push("main", map[string]string{".github/workflows/p.yml": wfYAML}, "")
138	c.push("feat", map[string]string{"feat.txt": "f"}, "")
139	job := runner.await()
140	tok := job.GetGithubToken()
141	require.Equal(t, "write", levelsOf(job)["contents"])
142	// push and create for main, then for feat.
143	c.waitFor("four runs", func() bool { return len(c.runs()) == 4 })
144	runsBefore := len(c.runs())
145	ref := func(r string) string { return c.bareGit("proj", "rev-parse", "--verify", "-q", r) }
146	refs := func() string { return c.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
147
148	// A commit on an existing branch is accepted.
149	c.mustGit(c.wt, "checkout", "-q", "main")
150	c.commitIn(c.wt, "notes.txt", "generated")
151	out, err := c.tokenPush(c.wt, tok, "HEAD:refs/heads/main")
152	require.NoError(t, err, out)
153	assert.Equal(t, strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")), ref("refs/heads/main"))
154
155	// A new branch and a new tag are accepted when their commits carry no
156	// workflow (a root commit with a README here).
157	orphan := c.workTree("orphan", "README")
158	out, err = c.tokenPush(orphan, tok, "orphan:refs/heads/orphan")
159	require.NoError(t, err, out)
160	require.NotEmpty(t, ref("refs/heads/orphan"))
161	out, err = c.tokenPush(orphan, tok, "orphan:refs/tags/v0")
162	require.NoError(t, err, out)
163	require.NotEmpty(t, ref("refs/tags/v0"))
164	// And the branch they made can be deleted, as can one that holds workflows.
165	out, err = c.tokenPush(orphan, tok, ":refs/heads/orphan")
166	require.NoError(t, err, out)
167	out, err = c.tokenPush(c.wt, tok, ":refs/heads/feat")
168	require.NoError(t, err, "deleting a branch changes no workflow: "+out)
169
170	// Refused: the whole push, whatever the ref, with GitHub's message.
171	refusal := func(path string) string {
172		return "refusing to allow a GitHub App to create or update workflow `" + path + "` without `workflows` permission"
173	}
174	before := refs()
175	c.mustGit(c.wt, "checkout", "-q", "-B", "w", "main")
176	c.commitIn(c.wt, ".github/workflows/new.yml", wfYAML) // create a file
177	out, err = c.tokenPush(c.wt, tok, "w:refs/heads/w")
178	require.Error(t, err)
179	assert.Contains(t, out, refusal(".github/workflows/new.yml"))
180	assert.Equal(t, before, refs())
181
182	c.mustGit(c.wt, "checkout", "-q", "-B", "m", "main")
183	c.commitIn(c.wt, ".github/workflows/p.yml", wfYAML+"# changed\n") // modify one
184	out, err = c.tokenPush(c.wt, tok, "m:refs/heads/main")
185	require.Error(t, err, "an update of main that touches a workflow")
186	assert.Contains(t, out, refusal(".github/workflows/p.yml"))
187	out, err = c.tokenPush(c.wt, tok, "+m:refs/heads/main")
188	require.Error(t, err, "forced too")
189	assert.Contains(t, out, refusal(".github/workflows/p.yml"))
190	assert.Equal(t, before, refs())
191
192	c.mustGit(c.wt, "checkout", "-q", "-B", "r", "main")
193	c.writeFile(c.wt, "moved.yml", []byte(wfYAML))
194	c.mustGit(c.wt, "mv", "moved.yml", ".github/workflows/moved.yml") // rename into
195	c.mustGit(c.wt, "commit", "-q", "-m", "mv")
196	out, err = c.tokenPush(c.wt, tok, "r:refs/heads/main")
197	require.Error(t, err, "a rename into .github/workflows")
198	assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
199
200	// A ref cut from the head of main carries main's workflows and changes none:
201	// a tag, an annotated tag and a branch there are allowed, as are new branches
202	// with commits that touch no workflow (staging run 855).
203	out, err = c.tokenPush(c.wt, tok, "main:refs/tags/v1")
204	require.NoError(t, err, "a tag on main's head: "+out)
205	c.mustGit(c.wt, "tag", "-a", "-m", "release", "va", "main")
206	out, err = c.tokenPush(c.wt, tok, "refs/tags/va:refs/tags/va")
207	require.NoError(t, err, "an annotated tag on main's head: "+out)
208	out, err = c.tokenPush(c.wt, tok, "main:refs/heads/copy")
209	require.NoError(t, err, "a branch at main's head: "+out)
210	c.mustGit(c.wt, "checkout", "-q", "-B", "smoke/perms-ok", "main")
211	c.commitIn(c.wt, "smoke.txt", "ok")
212	out, err = c.tokenPush(c.wt, tok, "smoke/perms-ok:refs/heads/smoke/perms-ok")
213	require.NoError(t, err, "a new branch with a commit that touches no workflow: "+out)
214	c.mustGit(c.wt, "checkout", "-q", "-B", "smoke/wf", "main")
215	c.commitIn(c.wt, ".github/workflows/smoke.yml", wfYAML)
216	out, err = c.tokenPush(c.wt, tok, "smoke/wf:refs/heads/smoke/wf")
217	require.Error(t, err, "a new branch with a commit that adds a workflow: "+out)
218	assert.Contains(t, out, refusal(".github/workflows/smoke.yml"))
219
220	// One ref that touches a workflow refuses a push of several.
221	c.mustGit(c.wt, "checkout", "-q", "-B", "ok", "main")
222	c.commitIn(c.wt, "fine.txt", "fine")
223	before = refs()
224	out, err = c.tokenPush(c.wt, tok, "ok:refs/heads/main", "r:refs/heads/other")
225	require.Error(t, err)
226	assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
227	assert.Equal(t, before, refs(), "nothing moved, not even the fine ref")
228
229	// The owner's token is not held to it.
230	out, err = c.git(c.wt, "push", c.urlAuth(adminToken, "/proj.git"), "r:refs/heads/rworkflow")
231	require.NoError(t, err, out)
232
233	// A job token cannot put a ref on that existing commit, whose workflows
234	// differ from the default branch's, nor move main back onto it.
235	out, err = c.tokenPush(c.wt, tok, "r:refs/heads/cut")
236	require.Error(t, err, "a new branch at an existing commit with other workflows: "+out)
237	assert.Contains(t, out, "without `workflows` permission")
238
239	// None of the pushes of the job token started a run; a push of the owner
240	// does, and queues after them.
241	c.push("sentinel", map[string]string{"s.txt": "s"}, "")
242	// rworkflow carries both p.yml and moved.yml, each on push and create.
243	// sentinel branches from ok (p.yml only), so adds two more runs. Wait for
244	// its create run: planning is queued, and a total alone can be reached
245	// before the owner's pushes have finished being planned.
246	c.waitFor("the sentinel's create run", func() bool {
247		for _, r := range c.runs() {
248			if r.GetRef() == "refs/heads/sentinel" && r.GetEvent() == "create" {
249				return true
250			}
251		}
252		return false
253	})
254	var ownerRuns []string
255	for _, r := range c.runs() {
256		if r.GetRef() == "refs/heads/rworkflow" || r.GetRef() == "refs/heads/sentinel" {
257			ownerRuns = append(ownerRuns, r.GetRef()+" "+r.GetWorkflowPath()+" "+r.GetEvent())
258		}
259		assert.NotEqual(t, "refs/heads/orphan", r.GetRef(), "no run for a job token's push")
260		assert.NotEqual(t, "refs/tags/v0", r.GetRef())
261		assert.NotEqual(t, "delete", r.GetEvent(), "no run for a job token's deletes")
262	}
263	assert.ElementsMatch(t, []string{
264		"refs/heads/rworkflow .github/workflows/p.yml push",
265		"refs/heads/rworkflow .github/workflows/p.yml create",
266		"refs/heads/rworkflow .github/workflows/moved.yml push",
267		"refs/heads/rworkflow .github/workflows/moved.yml create",
268		"refs/heads/sentinel .github/workflows/p.yml push",
269		"refs/heads/sentinel .github/workflows/p.yml create",
270	}, ownerRuns)
271	assert.Len(t, c.runs(), runsBefore+6, "only the owner's two workflows on rworkflow and one on sentinel, each on push and create")
272}
273
274func TestJobTokenCannotPushToAnotherRepository(t *testing.T) {
275	c := newCIHarness(t, nil)
276	c.seed("other", domain.VisibilityPublic, "a.txt")
277	runner := c.registerRunner("alpha", "ubuntu-latest")
278	c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions:\n  contents: write\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}, "")
279	job := runner.await()
280	require.Equal(t, "write", levelsOf(job)["contents"])
281	before := c.bareGit("other", "rev-parse", "refs/heads/main")
282	wt := c.workTree("main", "b.txt")
283	out, err := c.git(wt, "-c", "http.extraheader="+checkoutHeader(job.GetGithubToken()), "push", "--force", c.url("/owner/other"), "main:refs/heads/main")
284	require.Error(t, err, out)
285	assert.Equal(t, before, c.bareGit("other", "rev-parse", "refs/heads/main"))
286	status, _, _ := c.get("/owner/other/info/refs?service=git-receive-pack", basic(job.GetGithubToken()))
287	assert.Equal(t, http.StatusForbidden, status)
288	_ = out
289}
290
291// D1: the run of a token limited to branches is not trusted: whatever the
292// workflow asks, its job token is read-only.
293func TestBranchLimitedActorsJobTokenIsReadOnly(t *testing.T) {
294	c := newCIHarness(t, nil)
295	runner := c.registerRunner("alpha", "ubuntu-latest")
296	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
297	c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions: write-all\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"}, "")
298	owner := runner.await()
299	assert.Equal(t, "write", levelsOf(owner)["contents"])
300	runner.runJob(owner, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
301
302	c.mustGit(c.wt, "checkout", "-q", "main")
303	c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
304	job := runner.await()
305	assert.False(t, job.GetTrusted())
306	lv := levelsOf(job)
307	assert.Equal(t, "read", lv["contents"], "write lowered to read")
308	assert.Equal(t, "read", lv["issues"])
309	assert.Equal(t, "read", lv["models"])
310	assert.NotContains(t, lv, "id-token", "a permission with no read level is none")
311	assert.NotContains(t, lv, "copilot-requests")
312	for k, v := range lv {
313		assert.NotEqual(t, "write", v, k)
314	}
315
316	c.commitIn(c.wt, "evil.txt", "x")
317	out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/agent/x/a")
318	require.Error(t, err, "its token cannot push: "+out)
319	out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
320	require.NoError(t, err, "but it reads: "+out)
321}
322
323// D1 / D3 on re-runs: the trust of a run is kept or lowered and the token of the
324// new attempt follows it, whoever runs it again.
325func TestRerunsNeverRaiseTheJobTokenPermissions(t *testing.T) {
326	c := newCIHarness(t, nil)
327	runner := c.registerRunner("alpha", "ubuntu-latest")
328	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
329	wf := "on: push\npermissions: write-all\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
330	rerun := func(api *clients, id int64) error {
331		_, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
332		return err
333	}
334	byRef := func(ref string) *pb.Run {
335		for _, r := range c.runs() {
336			if r.GetRef() == ref {
337				return r
338			}
339		}
340		return nil
341	}
342	done := func(j *pb.Job) {
343		runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
344	}
345	noPush := func(j *pb.Job, ref string) {
346		t.Helper()
347		assert.False(t, j.GetTrusted())
348		assert.Equal(t, "read", levelsOf(j)["contents"])
349		for k, v := range levelsOf(j) {
350			assert.NotEqual(t, "write", v, k)
351		}
352		c.mustGit(c.wt, "checkout", "-q", "-B", "tmp", "main")
353		c.commitIn(c.wt, "x-"+ref[len(ref)-1:]+".txt", "x")
354		out, err := c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/"+ref)
355		require.Error(t, err, "the token cannot push: "+out)
356	}
357
358	// The owner's run: write-all, trusted.
359	c.push("main", map[string]string{".github/workflows/p.yml": wf}, "")
360	owner := runner.await()
361	assert.True(t, owner.GetTrusted())
362	assert.Equal(t, "write", levelsOf(owner)["contents"])
363	done(owner)
364	c.waitFor("owner run done", func() bool {
365		r := byRef("refs/heads/main")
366		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
367	})
368	mainRun := byRef("refs/heads/main")
369
370	// The agent's run: untrusted, read-only, and the owner's re-run of it too.
371	c.mustGit(c.wt, "checkout", "-q", "main")
372	c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
373	first := runner.await()
374	noPush(first, "agent/x/a")
375	done(first)
376	c.waitFor("agent run done", func() bool {
377		r := byRef("refs/heads/agent/x/a")
378		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
379	})
380	agentRun := byRef("refs/heads/agent/x/a")
381	require.NoError(t, rerun(c.root, agentRun.GetId()))
382	again := runner.await()
383	assert.Equal(t, int32(2), again.GetAttempt())
384	noPush(again, "agent/x/a")
385	done(again)
386
387	// The agent runs the owner's run again: refused, as it is outside its
388	// branches, and the run stays trusted with a token that writes.
389	require.Error(t, rerun(c.api(agent), mainRun.GetId()))
390	assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
391
392	// A run of the owner on a branch the agent may touch: the agent's re-run of
393	// it is allowed, and its attempt is read-only.
394	c.mustGit(c.wt, "checkout", "-q", "-B", "agent/x/own", "main")
395	c.commitIn(c.wt, "own.txt", "own")
396	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x/own")
397	ownerOnBranch := runner.await()
398	assert.True(t, ownerOnBranch.GetTrusted())
399	assert.Equal(t, "write", levelsOf(ownerOnBranch)["contents"])
400	done(ownerOnBranch)
401	c.waitFor("owner branch run done", func() bool {
402		r := byRef("refs/heads/agent/x/own")
403		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
404	})
405	branchRun := byRef("refs/heads/agent/x/own")
406	require.NoError(t, rerun(c.api(agent), branchRun.GetId()))
407	lowered := runner.await()
408	noPush(lowered, "agent/x/own")
409	assert.False(t, c.getRun(branchRun.GetId()).GetRun().GetTrusted(), "the re-run is not trusted")
410	done(lowered)
411}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close