feat(ci): limit run writes and runner management of branch-limited tokens
Cancel and re-run follow the branch prefixes of the token, on the ref of the run. A branch-limited token never manages runners.
- commit
- f9ee4b91659bf99d21a26b138d57b211ea738de0
- author
- brice · · 2026-10-12 20:09 UTC
- parent
- 0651976
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +179 -5
M README.md +1 -0
| @@ -100,6 +100,7 @@ For an AI agent that should propose changes, not publish them: `--branch-prefix | |||
| 100 | 100 | - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref. | |
| 101 | 101 | - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own. | |
| 102 | 102 | - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request. | |
| 103 | + | - CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository. | |
| 103 | 104 | - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`. | |
| 104 | 105 | - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade. | |
| 105 | 106 | ||
| @@ -100,6 +100,7 @@ For an AI agent that should propose changes, not publish them: `--branch-prefix | |||||
| 100 | - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref. | 100 | - It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref. | ||
| 101 | - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own. | 101 | - It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own. | ||
| 102 | - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request. | 102 | - It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request. | ||
| 103 | + | - CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository. | |||
| 103 | - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`. | 104 | - `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`. | ||
| 104 | - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade. | 105 | - This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade. | ||
| 105 | 106 | ||||
A internal/app/ci_branch_token_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package app | |
| 4 | + | ||
| 5 | + | import ( | |
| 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 | + | ||
| 16 | + | const everyBranchWorkflow = `name: all | |
| 17 | + | on: | |
| 18 | + | push: | |
| 19 | + | branches: ['**'] | |
| 20 | + | tags: ['v*'] | |
| 21 | + | jobs: | |
| 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. | |
| 35 | + | func 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 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package app | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 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 | + | ||||
| 16 | + | const everyBranchWorkflow = `name: all | |||
| 17 | + | on: | |||
| 18 | + | push: | |||
| 19 | + | branches: ['**'] | |||
| 20 | + | tags: ['v*'] | |||
| 21 | + | jobs: | |||
| 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. | |||
| 35 | + | func 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 | + | } | |||
M internal/usecase/ci.go +26 -1
| @@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R | |||
| 462 | 462 | return repo, nil | |
| 463 | 463 | } | |
| 464 | 464 | ||
| 465 | + | // runWriteAccess is the branch rule of the write actions on a run, cancel and | |
| 466 | + | // re-run, which come after the ci:write check. A run is tied to the ref that | |
| 467 | + | // made it, and re-running it executes the workflow of that ref again while | |
| 468 | + | // cancelling it stops the checks of that ref. So, like a push, a merge or a | |
| 469 | + | // branch delete, these actions fall under the branch prefixes of a limited | |
| 470 | + | // token (ADR 0027): it may act only on the runs of branches under its prefixes, | |
| 471 | + | // and never on a run of a tag or of any other ref. Without prefixes, every run. | |
| 472 | + | // | |
| 473 | + | // Reading runs, jobs, logs and checks is not a write: it needs only the access | |
| 474 | + | // to read the repository, as for the code, whatever the prefixes. The caller | |
| 475 | + | // has seen the run by then, so the refusal is forbidden, not not found. | |
| 476 | + | func runWriteAccess(p domain.Principal, run domain.CIRun) error { | |
| 477 | + | if !p.AllowsRef(run.Ref) { | |
| 478 | + | return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref) | |
| 479 | + | } | |
| 480 | + | return nil | |
| 481 | + | } | |
| 482 | + | ||
| 465 | 483 | // canManageRunners is the access rule of runners: runner:register on a token | |
| 466 | 484 | // that is not restricted to repositories (a runner serves every repository), | |
| 467 | - | // or root. | |
| 485 | + | // or root. A token limited to branch prefixes never manages runners, whatever | |
| 486 | + | // scopes it holds: a runner runs the workflows of every branch of every | |
| 487 | + | // repository, with their code and secrets, so registering or removing one | |
| 488 | + | // reaches far beyond the prefixes (a replaced runner could also be a way to | |
| 489 | + | // take over the jobs of main). Like repository administration and owner | |
| 490 | + | // rights, it is out of reach of a limited token. | |
| 468 | 491 | func canManageRunners(p domain.Principal) error { | |
| 469 | 492 | switch { | |
| 470 | 493 | case p.IsAnonymous(): | |
| @@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error { | |||
| 473 | 496 | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | |
| 474 | 497 | case len(p.Repos()) > 0: | |
| 475 | 498 | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | |
| 499 | + | case p.IsBranchLimited(): | |
| 500 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden) | |
| 476 | 501 | } | |
| 477 | 502 | return nil | |
| 478 | 503 | } | |
| @@ -462,9 +462,32 @@ func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.R | |||||
| 462 | return repo, nil | 462 | return repo, nil | ||
| 463 | } | 463 | } | ||
| 464 | 464 | ||||
| 465 | + | // runWriteAccess is the branch rule of the write actions on a run, cancel and | |||
| 466 | + | // re-run, which come after the ci:write check. A run is tied to the ref that | |||
| 467 | + | // made it, and re-running it executes the workflow of that ref again while | |||
| 468 | + | // cancelling it stops the checks of that ref. So, like a push, a merge or a | |||
| 469 | + | // branch delete, these actions fall under the branch prefixes of a limited | |||
| 470 | + | // token (ADR 0027): it may act only on the runs of branches under its prefixes, | |||
| 471 | + | // and never on a run of a tag or of any other ref. Without prefixes, every run. | |||
| 472 | + | // | |||
| 473 | + | // Reading runs, jobs, logs and checks is not a write: it needs only the access | |||
| 474 | + | // to read the repository, as for the code, whatever the prefixes. The caller | |||
| 475 | + | // has seen the run by then, so the refusal is forbidden, not not found. | |||
| 476 | + | func runWriteAccess(p domain.Principal, run domain.CIRun) error { | |||
| 477 | + | if !p.AllowsRef(run.Ref) { | |||
| 478 | + | return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref) | |||
| 479 | + | } | |||
| 480 | + | return nil | |||
| 481 | + | } | |||
| 482 | + | ||||
| 465 | // canManageRunners is the access rule of runners: runner:register on a token | 483 | // canManageRunners is the access rule of runners: runner:register on a token | ||
| 466 | // that is not restricted to repositories (a runner serves every repository), | 484 | // that is not restricted to repositories (a runner serves every repository), | ||
| 467 | - | // or root. | 485 | + | // or root. A token limited to branch prefixes never manages runners, whatever |
| 486 | + | // scopes it holds: a runner runs the workflows of every branch of every | |||
| 487 | + | // repository, with their code and secrets, so registering or removing one | |||
| 488 | + | // reaches far beyond the prefixes (a replaced runner could also be a way to | |||
| 489 | + | // take over the jobs of main). Like repository administration and owner | |||
| 490 | + | // rights, it is out of reach of a limited token. | |||
| 468 | func canManageRunners(p domain.Principal) error { | 491 | func canManageRunners(p domain.Principal) error { | ||
| 469 | switch { | 492 | switch { | ||
| 470 | case p.IsAnonymous(): | 493 | case p.IsAnonymous(): | ||
| @@ -473,6 +496,8 @@ func canManageRunners(p domain.Principal) error { | |||||
| 473 | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | 496 | return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister) | ||
| 474 | case len(p.Repos()) > 0: | 497 | case len(p.Repos()) > 0: | ||
| 475 | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | 498 | return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden) | ||
| 499 | + | case p.IsBranchLimited(): | |||
| 500 | + | return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden) | |||
| 476 | } | 501 | } | ||
| 477 | return nil | 502 | return nil | ||
| 478 | } | 503 | } | ||
M internal/usecase/ci_runs.go +12 -4
| @@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 318 | 318 | if _, err := c.writableRepo(ctx, p, name); err != nil { | |
| 319 | 319 | return domain.CIRun{}, err | |
| 320 | 320 | } | |
| 321 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | |
| 321 | + | run, err := c.runOf(ctx, name, runID) | |
| 322 | + | if err != nil { | |
| 323 | + | return domain.CIRun{}, err | |
| 324 | + | } | |
| 325 | + | if err := runWriteAccess(p, run); err != nil { | |
| 322 | 326 | return domain.CIRun{}, err | |
| 323 | 327 | } | |
| 324 | 328 | var out domain.CIRun | |
| 325 | - | err := c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 329 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| 326 | 330 | if rs.run.Status == domain.CICompleted { | |
| 327 | 331 | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | |
| 328 | 332 | } | |
| @@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 351 | 355 | if err != nil { | |
| 352 | 356 | return domain.CIRun{}, err | |
| 353 | 357 | } | |
| 354 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | |
| 358 | + | run, err := c.runOf(ctx, name, runID) | |
| 359 | + | if err != nil { | |
| 360 | + | return domain.CIRun{}, err | |
| 361 | + | } | |
| 362 | + | if err := runWriteAccess(p, run); err != nil { | |
| 355 | 363 | return domain.CIRun{}, err | |
| 356 | 364 | } | |
| 357 | 365 | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | |
| @@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||
| 416 | 424 | if err := c.advanceRun(ctx, runID); err != nil { | |
| 417 | 425 | return domain.CIRun{}, err | |
| 418 | 426 | } | |
| 419 | - | run, err := c.runOf(ctx, name, runID) | |
| 427 | + | run, err = c.runOf(ctx, name, runID) | |
| 420 | 428 | if err != nil { | |
| 421 | 429 | return domain.CIRun{}, err | |
| 422 | 430 | } | |
| @@ -318,11 +318,15 @@ func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 318 | if _, err := c.writableRepo(ctx, p, name); err != nil { | 318 | if _, err := c.writableRepo(ctx, p, name); err != nil { | ||
| 319 | return domain.CIRun{}, err | 319 | return domain.CIRun{}, err | ||
| 320 | } | 320 | } | ||
| 321 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | 321 | + | run, err := c.runOf(ctx, name, runID) |
| 322 | + | if err != nil { | |||
| 323 | + | return domain.CIRun{}, err | |||
| 324 | + | } | |||
| 325 | + | if err := runWriteAccess(p, run); err != nil { | |||
| 322 | return domain.CIRun{}, err | 326 | return domain.CIRun{}, err | ||
| 323 | } | 327 | } | ||
| 324 | var out domain.CIRun | 328 | var out domain.CIRun | ||
| 325 | - | err := c.withRun(ctx, runID, 0, func(rs *runState) error { | 329 | + | err = c.withRun(ctx, runID, 0, func(rs *runState) error { |
| 326 | if rs.run.Status == domain.CICompleted { | 330 | if rs.run.Status == domain.CICompleted { | ||
| 327 | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | 331 | return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID) | ||
| 328 | } | 332 | } | ||
| @@ -351,7 +355,11 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 351 | if err != nil { | 355 | if err != nil { | ||
| 352 | return domain.CIRun{}, err | 356 | return domain.CIRun{}, err | ||
| 353 | } | 357 | } | ||
| 354 | - | if _, err := c.runOf(ctx, name, runID); err != nil { | 358 | + | run, err := c.runOf(ctx, name, runID) |
| 359 | + | if err != nil { | |||
| 360 | + | return domain.CIRun{}, err | |||
| 361 | + | } | |||
| 362 | + | if err := runWriteAccess(p, run); err != nil { | |||
| 355 | return domain.CIRun{}, err | 363 | return domain.CIRun{}, err | ||
| 356 | } | 364 | } | ||
| 357 | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | 365 | err = c.withRun(ctx, runID, 0, func(rs *runState) error { | ||
| @@ -416,7 +424,7 @@ func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoN | |||||
| 416 | if err := c.advanceRun(ctx, runID); err != nil { | 424 | if err := c.advanceRun(ctx, runID); err != nil { | ||
| 417 | return domain.CIRun{}, err | 425 | return domain.CIRun{}, err | ||
| 418 | } | 426 | } | ||
| 419 | - | run, err := c.runOf(ctx, name, runID) | 427 | + | run, err = c.runOf(ctx, name, runID) |
| 420 | if err != nil { | 428 | if err != nil { | ||
| 421 | return domain.CIRun{}, err | 429 | return domain.CIRun{}, err | ||
| 422 | } | 430 | } | ||