v0.12.0 dccd128

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:\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	c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
143	runsBefore := len(c.runs())
144	ref := func(r string) string { return c.bareGit("proj", "rev-parse", "--verify", "-q", r) }
145	refs := func() string { return c.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
146
147	// A commit on an existing branch is accepted.
148	c.mustGit(c.wt, "checkout", "-q", "main")
149	c.commitIn(c.wt, "notes.txt", "generated")
150	out, err := c.tokenPush(c.wt, tok, "HEAD:refs/heads/main")
151	require.NoError(t, err, out)
152	assert.Equal(t, strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")), ref("refs/heads/main"))
153
154	// A new branch and a new tag are accepted when their commits carry no
155	// workflow (a root commit with a README here).
156	orphan := c.workTree("orphan", "README")
157	out, err = c.tokenPush(orphan, tok, "orphan:refs/heads/orphan")
158	require.NoError(t, err, out)
159	require.NotEmpty(t, ref("refs/heads/orphan"))
160	out, err = c.tokenPush(orphan, tok, "orphan:refs/tags/v0")
161	require.NoError(t, err, out)
162	require.NotEmpty(t, ref("refs/tags/v0"))
163	// And the branch they made can be deleted, as can one that holds workflows.
164	out, err = c.tokenPush(orphan, tok, ":refs/heads/orphan")
165	require.NoError(t, err, out)
166	out, err = c.tokenPush(c.wt, tok, ":refs/heads/feat")
167	require.NoError(t, err, "deleting a branch changes no workflow: "+out)
168
169	// Refused: the whole push, whatever the ref, with GitHub's message.
170	refusal := func(path string) string {
171		return "refusing to allow a GitHub App to create or update workflow `" + path + "` without `workflows` permission"
172	}
173	before := refs()
174	c.mustGit(c.wt, "checkout", "-q", "-B", "w", "main")
175	c.commitIn(c.wt, ".github/workflows/new.yml", wfYAML) // create a file
176	out, err = c.tokenPush(c.wt, tok, "w:refs/heads/w")
177	require.Error(t, err)
178	assert.Contains(t, out, refusal(".github/workflows/new.yml"))
179	assert.Equal(t, before, refs())
180
181	c.mustGit(c.wt, "checkout", "-q", "-B", "m", "main")
182	c.commitIn(c.wt, ".github/workflows/p.yml", wfYAML+"# changed\n") // modify one
183	out, err = c.tokenPush(c.wt, tok, "m:refs/heads/main")
184	require.Error(t, err, "an update of main that touches a workflow")
185	assert.Contains(t, out, refusal(".github/workflows/p.yml"))
186	out, err = c.tokenPush(c.wt, tok, "+m:refs/heads/main")
187	require.Error(t, err, "forced too")
188	assert.Contains(t, out, refusal(".github/workflows/p.yml"))
189	assert.Equal(t, before, refs())
190
191	c.mustGit(c.wt, "checkout", "-q", "-B", "r", "main")
192	c.writeFile(c.wt, "moved.yml", []byte(wfYAML))
193	c.mustGit(c.wt, "mv", "moved.yml", ".github/workflows/moved.yml") // rename into
194	c.mustGit(c.wt, "commit", "-q", "-m", "mv")
195	out, err = c.tokenPush(c.wt, tok, "r:refs/heads/main")
196	require.Error(t, err, "a rename into .github/workflows")
197	assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
198
199	// A tag that points to a commit holding workflows (even an existing one).
200	out, err = c.tokenPush(c.wt, tok, "main:refs/tags/v1")
201	require.Error(t, err, "a tag on a commit with workflows: "+out)
202	assert.Contains(t, out, "without `workflows` permission")
203	// An annotated tag is judged by the commit it peels to.
204	c.mustGit(c.wt, "tag", "-a", "-m", "release", "va", "main")
205	out, err = c.tokenPush(c.wt, tok, "refs/tags/va:refs/tags/va")
206	require.Error(t, err, "an annotated tag on a commit with workflows: "+out)
207	assert.Contains(t, out, "without `workflows` permission")
208	// A new branch off main carries the workflows too.
209	out, err = c.tokenPush(c.wt, tok, "main:refs/heads/copy")
210	require.Error(t, err)
211	assert.Contains(t, out, "without `workflows` permission")
212
213	// One ref that touches a workflow refuses a push of several.
214	c.mustGit(c.wt, "checkout", "-q", "-B", "ok", "main")
215	c.commitIn(c.wt, "fine.txt", "fine")
216	before = refs()
217	out, err = c.tokenPush(c.wt, tok, "ok:refs/heads/main", "r:refs/heads/other")
218	require.Error(t, err)
219	assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
220	assert.Equal(t, before, refs(), "nothing moved, not even the fine ref")
221
222	// The owner's token is not held to it.
223	out, err = c.git(c.wt, "push", c.urlAuth(adminToken, "/proj.git"), "r:refs/heads/rworkflow")
224	require.NoError(t, err, out)
225
226	// None of the pushes of the job token started a run; a push of the owner
227	// does, and queues after them.
228	c.push("sentinel", map[string]string{"s.txt": "s"}, "")
229	c.waitFor("the owner's runs", func() bool { return len(c.runs()) >= runsBefore+2 })
230	for _, r := range c.runs() {
231		assert.NotEqual(t, "refs/heads/orphan", r.GetRef(), "no run for a job token's push")
232		assert.NotEqual(t, "refs/tags/v0", r.GetRef())
233	}
234	assert.Len(t, c.runs(), runsBefore+2, "the owner's rworkflow and sentinel pushes only")
235}
236
237func TestJobTokenCannotPushToAnotherRepository(t *testing.T) {
238	c := newCIHarness(t, nil)
239	c.seed("other", domain.VisibilityPublic, "a.txt")
240	runner := c.registerRunner("alpha", "ubuntu-latest")
241	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"}, "")
242	job := runner.await()
243	require.Equal(t, "write", levelsOf(job)["contents"])
244	before := c.bareGit("other", "rev-parse", "refs/heads/main")
245	wt := c.workTree("main", "b.txt")
246	out, err := c.git(wt, "-c", "http.extraheader="+checkoutHeader(job.GetGithubToken()), "push", "--force", c.url("/owner/other"), "main:refs/heads/main")
247	require.Error(t, err, out)
248	assert.Equal(t, before, c.bareGit("other", "rev-parse", "refs/heads/main"))
249	status, _, _ := c.get("/owner/other/info/refs?service=git-receive-pack", basic(job.GetGithubToken()))
250	assert.Equal(t, http.StatusForbidden, status)
251	_ = out
252}
253
254// D1: the run of a token limited to branches is not trusted: whatever the
255// workflow asks, its job token is read-only.
256func TestBranchLimitedActorsJobTokenIsReadOnly(t *testing.T) {
257	c := newCIHarness(t, nil)
258	runner := c.registerRunner("alpha", "ubuntu-latest")
259	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
260	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"}, "")
261	owner := runner.await()
262	assert.Equal(t, "write", levelsOf(owner)["contents"])
263	runner.runJob(owner, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
264
265	c.mustGit(c.wt, "checkout", "-q", "main")
266	c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
267	job := runner.await()
268	assert.False(t, job.GetTrusted())
269	lv := levelsOf(job)
270	assert.Equal(t, "read", lv["contents"], "write lowered to read")
271	assert.Equal(t, "read", lv["issues"])
272	assert.Equal(t, "read", lv["models"])
273	assert.NotContains(t, lv, "id-token", "a permission with no read level is none")
274	assert.NotContains(t, lv, "copilot-requests")
275	for k, v := range lv {
276		assert.NotEqual(t, "write", v, k)
277	}
278
279	c.commitIn(c.wt, "evil.txt", "x")
280	out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/agent/x/a")
281	require.Error(t, err, "its token cannot push: "+out)
282	out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
283	require.NoError(t, err, "but it reads: "+out)
284}
285
286// D1 / D3 on re-runs: the trust of a run is kept or lowered and the token of the
287// new attempt follows it, whoever runs it again.
288func TestRerunsNeverRaiseTheJobTokenPermissions(t *testing.T) {
289	c := newCIHarness(t, nil)
290	runner := c.registerRunner("alpha", "ubuntu-latest")
291	agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
292	wf := "on: push\npermissions: write-all\njobs:\n  j:\n    runs-on: ubuntu-latest\n    steps:\n      - run: echo hi\n"
293	rerun := func(api *clients, id int64) error {
294		_, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
295		return err
296	}
297	byRef := func(ref string) *pb.Run {
298		for _, r := range c.runs() {
299			if r.GetRef() == ref {
300				return r
301			}
302		}
303		return nil
304	}
305	done := func(j *pb.Job) {
306		runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
307	}
308	noPush := func(j *pb.Job, ref string) {
309		t.Helper()
310		assert.False(t, j.GetTrusted())
311		assert.Equal(t, "read", levelsOf(j)["contents"])
312		for k, v := range levelsOf(j) {
313			assert.NotEqual(t, "write", v, k)
314		}
315		c.mustGit(c.wt, "checkout", "-q", "-B", "tmp", "main")
316		c.commitIn(c.wt, "x-"+ref[len(ref)-1:]+".txt", "x")
317		out, err := c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/"+ref)
318		require.Error(t, err, "the token cannot push: "+out)
319	}
320
321	// The owner's run: write-all, trusted.
322	c.push("main", map[string]string{".github/workflows/p.yml": wf}, "")
323	owner := runner.await()
324	assert.True(t, owner.GetTrusted())
325	assert.Equal(t, "write", levelsOf(owner)["contents"])
326	done(owner)
327	c.waitFor("owner run done", func() bool {
328		r := byRef("refs/heads/main")
329		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
330	})
331	mainRun := byRef("refs/heads/main")
332
333	// The agent's run: untrusted, read-only, and the owner's re-run of it too.
334	c.mustGit(c.wt, "checkout", "-q", "main")
335	c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
336	first := runner.await()
337	noPush(first, "agent/x/a")
338	done(first)
339	c.waitFor("agent run done", func() bool {
340		r := byRef("refs/heads/agent/x/a")
341		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
342	})
343	agentRun := byRef("refs/heads/agent/x/a")
344	require.NoError(t, rerun(c.root, agentRun.GetId()))
345	again := runner.await()
346	assert.Equal(t, int32(2), again.GetAttempt())
347	noPush(again, "agent/x/a")
348	done(again)
349
350	// The agent runs the owner's run again: refused, as it is outside its
351	// branches, and the run stays trusted with a token that writes.
352	require.Error(t, rerun(c.api(agent), mainRun.GetId()))
353	assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
354
355	// A run of the owner on a branch the agent may touch: the agent's re-run of
356	// it is allowed, and its attempt is read-only.
357	c.mustGit(c.wt, "checkout", "-q", "-B", "agent/x/own", "main")
358	c.commitIn(c.wt, "own.txt", "own")
359	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x/own")
360	ownerOnBranch := runner.await()
361	assert.True(t, ownerOnBranch.GetTrusted())
362	assert.Equal(t, "write", levelsOf(ownerOnBranch)["contents"])
363	done(ownerOnBranch)
364	c.waitFor("owner branch run done", func() bool {
365		r := byRef("refs/heads/agent/x/own")
366		return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
367	})
368	branchRun := byRef("refs/heads/agent/x/own")
369	require.NoError(t, rerun(c.api(agent), branchRun.GetId()))
370	lowered := runner.await()
371	noPush(lowered, "agent/x/own")
372	assert.False(t, c.getRun(branchRun.GetId()).GetRun().GetTrusted(), "the re-run is not trusted")
373	done(lowered)
374}

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 2

Tags 45

↑↓ moveenter openesc close