1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "testing"
7
8 "connectrpc.com/connect"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 pb "bnema.dev/ermite-api/gen/ermite"
13 "bnema.dev/ermite/internal/domain"
14)
15
16const everyBranchWorkflow = `name: all
17on:
18 push:
19 branches: ['**']
20 tags: ['v*']
21jobs:
22 j:
23 runs-on: ubuntu-latest
24 steps:
25 - run: echo hi
26`
27
28// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29// logs is not a write and stays open to whoever can read the repository.
30// Cancelling and re-running a run is a write on the ref of the run (it stops or
31// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32// branch under one, refused on any other branch and on a tag. Runners serve
33// every repository and branch, so a limited token never manages them, even with
34// runner:register.
35func TestCIBranchLimitedToken(t *testing.T) {
36 c := newCIHarness(t, nil)
37 runner := c.registerRunner("alpha", "ubuntu-latest")
38 wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39
40 c.push("main", wf, "")
41 c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43 c.push("agent/claude/x", nil, "")
44 c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45 runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46 c.mustGit(c.wt, "tag", "v1")
47 c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48 c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49 c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50
51 var mainRun, agentRun, tagRun *pb.Run
52 for _, r := range c.runs() {
53 switch r.GetRef() {
54 case "refs/heads/main":
55 mainRun = r
56 case "refs/heads/agent/claude/x":
57 agentRun = r
58 case "refs/tags/v1":
59 tagRun = r
60 }
61 }
62 require.NotNil(t, mainRun)
63 require.NotNil(t, agentRun)
64 require.NotNil(t, tagRun)
65
66 all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67 secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68 limited := c.api(secret)
69 // The same token without the limit, to prove the refusals come from it.
70 unlimited := c.api(c.token(all, "proj"))
71 cancel := func(api *clients, r *pb.Run) error {
72 _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73 return err
74 }
75 rerun := func(api *clients, r *pb.Run) error {
76 _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77 return err
78 }
79
80 t.Run("reads", func(t *testing.T) {
81 list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82 require.NoError(t, err)
83 assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84 got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85 require.NoError(t, err)
86 require.NotEmpty(t, got.Msg.GetJobs())
87 log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88 require.NoError(t, err)
89 var text string
90 for log.Receive() {
91 for _, l := range log.Msg().GetLines() {
92 text += l.GetText()
93 }
94 }
95 require.NoError(t, log.Err())
96 assert.Contains(t, text, "log of main")
97 _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98 require.NoError(t, err)
99 })
100
101 t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102 for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105 }
106 // Still there: the refused rerun started no new attempt.
107 assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108 // Without the limit the same token is allowed (rerun) or reaches the
109 // state rule of the run (cancel of a completed run).
110 assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111 require.NoError(t, rerun(unlimited, mainRun))
112 })
113
114 t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115 require.NoError(t, rerun(limited, agentRun))
116 assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117 require.NoError(t, cancel(limited, agentRun))
118 })
119
120 t.Run("runners are out of reach", func(t *testing.T) {
121 // Not restricted to a repository, so only the branch limit can refuse it.
122 secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123 limited := c.api(secret)
124 _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125 Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126 }))
127 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128 _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130 _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131 assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132
133 // The same scopes without the limit manage runners (not restricted to a
134 // repository, which the runner rules already demand).
135 open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136 list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137 require.NoError(t, err)
138 assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139 })
140}