v0.17.0 ab8e178

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	c.waitFor("the owner's runs", func() bool { return len(c.runs()) >= runsBefore+4 })
243	for _, r := range c.runs() {
244		assert.NotEqual(t, "refs/heads/orphan", r.GetRef(), "no run for a job token's push")
245		assert.NotEqual(t, "refs/tags/v0", r.GetRef())
246		assert.NotEqual(t, "delete", r.GetEvent(), "no run for a job token's deletes")
247	}
248	assert.Len(t, c.runs(), runsBefore+4, "push and create of the owner's rworkflow and sentinel only")
249}
250
251func TestJobTokenCannotPushToAnotherRepository(t *testing.T) {
252	c := newCIHarness(t, nil)
253	c.seed("other", domain.VisibilityPublic, "a.txt")
254	runner := c.registerRunner("alpha", "ubuntu-latest")
255	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"}, "")
256	job := runner.await()
257	require.Equal(t, "write", levelsOf(job)["contents"])
258	before := c.bareGit("other", "rev-parse", "refs/heads/main")
259	wt := c.workTree("main", "b.txt")
260	out, err := c.git(wt, "-c", "http.extraheader="+checkoutHeader(job.GetGithubToken()), "push", "--force", c.url("/owner/other"), "main:refs/heads/main")
261	require.Error(t, err, out)
262	assert.Equal(t, before, c.bareGit("other", "rev-parse", "refs/heads/main"))
263	status, _, _ := c.get("/owner/other/info/refs?service=git-receive-pack", basic(job.GetGithubToken()))
264	assert.Equal(t, http.StatusForbidden, status)
265	_ = out
266}
267
268// D1: the run of a token limited to branches is not trusted: whatever the
269// workflow asks, its job token is read-only.
270func TestBranchLimitedActorsJobTokenIsReadOnly(t *testing.T) {
271	c := newCIHarness(t, nil)
272	runner := c.registerRunner("alpha", "ubuntu-latest")
273	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
274	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"}, "")
275	owner := runner.await()
276	assert.Equal(t, "write", levelsOf(owner)["contents"])
277	runner.runJob(owner, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
278
279	c.mustGit(c.wt, "checkout", "-q", "main")
280	c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
281	job := runner.await()
282	assert.False(t, job.GetTrusted())
283	lv := levelsOf(job)
284	assert.Equal(t, "read", lv["contents"], "write lowered to read")
285	assert.Equal(t, "read", lv["issues"])
286	assert.Equal(t, "read", lv["models"])
287	assert.NotContains(t, lv, "id-token", "a permission with no read level is none")
288	assert.NotContains(t, lv, "copilot-requests")
289	for k, v := range lv {
290		assert.NotEqual(t, "write", v, k)
291	}
292
293	c.commitIn(c.wt, "evil.txt", "x")
294	out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/agent/x/a")
295	require.Error(t, err, "its token cannot push: "+out)
296	out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
297	require.NoError(t, err, "but it reads: "+out)
298}
299
300// D1 / D3 on re-runs: the trust of a run is kept or lowered and the token of the
301// new attempt follows it, whoever runs it again.
302func TestRerunsNeverRaiseTheJobTokenPermissions(t *testing.T) {
303	c := newCIHarness(t, nil)
304	runner := c.registerRunner("alpha", "ubuntu-latest")
305	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
306	wf := "on: push\npermissions: write-all\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
307	rerun := func(api *clients, id int64) error {
308		_, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
309		return err
310	}
311	byRef := func(ref string) *pb.Run {
312		for _, r := range c.runs() {
313			if r.GetRef() == ref {
314				return r
315			}
316		}
317		return nil
318	}
319	done := func(j *pb.Job) {
320		runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
321	}
322	noPush := func(j *pb.Job, ref string) {
323		t.Helper()
324		assert.False(t, j.GetTrusted())
325		assert.Equal(t, "read", levelsOf(j)["contents"])
326		for k, v := range levelsOf(j) {
327			assert.NotEqual(t, "write", v, k)
328		}
329		c.mustGit(c.wt, "checkout", "-q", "-B", "tmp", "main")
330		c.commitIn(c.wt, "x-"+ref[len(ref)-1:]+".txt", "x")
331		out, err := c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/"+ref)
332		require.Error(t, err, "the token cannot push: "+out)
333	}
334
335	// The owner's run: write-all, trusted.
336	c.push("main", map[string]string{".github/workflows/p.yml": wf}, "")
337	owner := runner.await()
338	assert.True(t, owner.GetTrusted())
339	assert.Equal(t, "write", levelsOf(owner)["contents"])
340	done(owner)
341	c.waitFor("owner run done", func() bool {
342		r := byRef("refs/heads/main")
343		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
344	})
345	mainRun := byRef("refs/heads/main")
346
347	// The agent's run: untrusted, read-only, and the owner's re-run of it too.
348	c.mustGit(c.wt, "checkout", "-q", "main")
349	c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
350	first := runner.await()
351	noPush(first, "agent/x/a")
352	done(first)
353	c.waitFor("agent run done", func() bool {
354		r := byRef("refs/heads/agent/x/a")
355		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
356	})
357	agentRun := byRef("refs/heads/agent/x/a")
358	require.NoError(t, rerun(c.root, agentRun.GetId()))
359	again := runner.await()
360	assert.Equal(t, int32(2), again.GetAttempt())
361	noPush(again, "agent/x/a")
362	done(again)
363
364	// The agent runs the owner's run again: refused, as it is outside its
365	// branches, and the run stays trusted with a token that writes.
366	require.Error(t, rerun(c.api(agent), mainRun.GetId()))
367	assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
368
369	// A run of the owner on a branch the agent may touch: the agent's re-run of
370	// it is allowed, and its attempt is read-only.
371	c.mustGit(c.wt, "checkout", "-q", "-B", "agent/x/own", "main")
372	c.commitIn(c.wt, "own.txt", "own")
373	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x/own")
374	ownerOnBranch := runner.await()
375	assert.True(t, ownerOnBranch.GetTrusted())
376	assert.Equal(t, "write", levelsOf(ownerOnBranch)["contents"])
377	done(ownerOnBranch)
378	c.waitFor("owner branch run done", func() bool {
379		r := byRef("refs/heads/agent/x/own")
380		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
381	})
382	branchRun := byRef("refs/heads/agent/x/own")
383	require.NoError(t, rerun(c.api(agent), branchRun.GetId()))
384	lowered := runner.await()
385	noPush(lowered, "agent/x/own")
386	assert.False(t, c.getRun(branchRun.GetId()).GetRun().GetTrusted(), "the re-run is not trusted")
387	done(lowered)
388}

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 3

Tags 43

↑↓ moveenter openesc close