Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Give a job a credential for the bot it acts as

6e1fca5115b709d31eeb51864b187d79aa104e5f · Keenan Nemetz · 6d ago · parent 446542e6

Give a job a credential for the bot it acts as

A workflow can name a bot account with `bot:`. When the job is leased the
instance generates a key for that run, stores the public half with the run,
repository and expiry, and sends the private half on the lease channel. The
runner writes it into the job's run directory with a known_hosts entry for the
instance, so a step's `git push` or `vole issue comment` authenticates as the
account and nothing has to be wired per job.

The account's role is the permission, in full. The credential replaces exactly one
requirement — that writing to the issues branch needs `maintain` — and adds no rule
of its own. What a job may write there is what its account may do, asked the same
way the browser asks: discuss, file an issue, manage an item it wrote, open a
change from a branch it may push, triage and approve when the role allows it. Those
are the ACL's own capabilities (`CanDiscuss`, `CanOpenRequest`, `CanTriage`,
`CanApprove`), so a maintainer's job may do everything a maintainer's key may do,
including labels, states, and other people's items.

A session is scoped only when the fingerprint it authenticated with is a live
credential row; an expired one reads as absent, so the push falls back to the
account's own role and is refused where a contribute account cannot write.

Two things end the key: the run finishing, which drops the row, and the expiry,
which is what stops a credential whose runner was killed and never reported. The
key is also masked in the log like a secret, since a step that cats it should not
leave it in the attempt.

Closes #165.

server/hook/scope_test.go +344

@@ -0,0 +1,344 @@
1 package hook
2
3 // A job's credential is not the account's key: it may add to the issues branch, and
4 // only what the account could already do through the browser. These tests are the
5 // boundary that makes handing a bot a key safe, so they are written as refusals
6 // first and permissions second.
7
8 import (
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/rodent-software/vole/ci/runs"
15 "github.com/rodent-software/vole/forge/access"
16 "github.com/rodent-software/vole/metadata"
17 )
18
19 // scopedFixture is a repository with a metadata branch containing one issue, a bot
20 // account that may contribute, and a live run credential for it.
21 type scopedFixture struct {
22 *fixture
23 bot string
24 credFP string
25 runsDB string
26 issueID string
27 base string // the metadata tip before the push under test
28 }
29
30 func newScopedFixture(t *testing.T) *scopedFixture {
31 t.Helper()
32 f := newFixture(t, "# demo\n")
33 // The bot gets exactly the contributor role, which is the interesting case: it is
34 // the role that may not push the issues branch with a key.
35 f.setACL(func(a *access.ACL) {
36 if err := a.SetEntry("coding-agent", "contribute"); err != nil {
37 t.Fatal(err)
38 }
39 })
40 g := f.m.Git()
41 iss, err := metadata.NewIssue("Fix the thing", "It is broken.", "coding-agent", nil, nil, time.Now())
42 if err != nil {
43 t.Fatal(err)
44 }
45 if err := metadata.CreateIssue(g, iss, "coding-agent"); err != nil {
46 t.Fatal(err)
47 }
48
49 s := &scopedFixture{
50 fixture: f,
51 bot: "coding-agent",
52 credFP: "SHA256:credential-for-this-run",
53 runsDB: filepath.Join(filepath.Dir(f.usersDB), "runs.db"),
54 issueID: iss.ID,
55 }
56 store, err := runs.Open(s.runsDB)
57 if err != nil {
58 t.Fatal(err)
59 }
60 defer store.Close()
61 if err := store.SetRunCredential(runs.RunCredential{
62 Fingerprint: s.credFP, Account: s.bot, Repo: f.m.Name, RunID: "01M6FFY0BARR9YDQ3KK64VQ0MG",
63 Expires: time.Now().Add(time.Hour),
64 }, time.Now()); err != nil {
65 t.Fatal(err)
66 }
67 s.base = s.metadataTip()
68 return s
69 }
70
71 func (s *scopedFixture) metadataTip() string {
72 s.t.Helper()
73 tip, err := s.m.Git().RevParse(metadata.Ref)
74 if err != nil || tip == "" {
75 s.t.Fatalf("metadata tip: %q %v", tip, err)
76 }
77 return tip
78 }
79
80 // push runs pre-receive as the job, with the credential fingerprint the server would
81 // have supplied. It pushes everything since before the writer ran.
82 func (s *scopedFixture) push(after func()) (int, string) {
83 s.t.Helper()
84 before := s.metadataTip()
85 after()
86 s.head = s.metadataTip()
87 cfg := Config{
88 Actor: s.bot,
89 KeyID: s.credFP,
90 RunsDB: s.runsDB,
91 MaxBlobBytes: 1 << 20,
92 }
93 code, out := s.runFrom(cfg, before, metadata.Ref)
94 return code, out
95 }
96
97 func TestAJobMayAddItsOwnComment(t *testing.T) {
98 t.Parallel()
99 s := newScopedFixture(t)
100 cm, err := metadata.NewIssueComment("Looking at it now.", s.bot, time.Now())
101 if err != nil {
102 t.Fatal(err)
103 }
104 code, out := s.push(func() {
105 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, s.bot); err != nil {
106 t.Fatal(err)
107 }
108 })
109 if code != 0 {
110 t.Fatalf("a bot could not add its own comment: %s", out)
111 }
112 }
113
114 // The comment's author is a name in a file, so a job must not be able to write one
115 // as somebody else.
116 func TestAJobCannotCommentAsSomebodyElse(t *testing.T) {
117 t.Parallel()
118 s := newScopedFixture(t)
119 cm, err := metadata.NewIssueComment("Signed, the maintainer.", "writer", time.Now())
120 if err != nil {
121 t.Fatal(err)
122 }
123 code, out := s.push(func() {
124 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, "writer"); err != nil {
125 t.Fatal(err)
126 }
127 })
128 if code == 0 {
129 t.Fatal("a job's push wrote a comment as another account")
130 }
131 if !strings.Contains(out, "this comment says writer") {
132 t.Fatalf("refusal does not explain the author: %s", out)
133 }
134 }
135
136 // A contributor may manage the items it wrote: that is the rule the browser applies,
137 // and a credential inherits it rather than inventing a narrower one.
138 func TestAJobManagesItsOwnIssue(t *testing.T) {
139 t.Parallel()
140 s := newScopedFixture(t)
141 code, out := s.push(func() {
142 if _, err := metadata.UpdateIssue(s.m.Git(), s.issueID, func(i *metadata.Issue) error {
143 i.State = "closed"
144 i.Labels = []string{"done"}
145 return nil
146 }, s.bot); err != nil {
147 t.Fatal(err)
148 }
149 })
150 if code != 0 {
151 t.Fatalf("a contributor could not close the issue it filed: %s", out)
152 }
153 }
154
155 // Somebody else's issue is a maintainer's to triage, and a contributor's credential
156 // does not make it one.
157 func TestAJobCannotManageSomebodyElsesIssue(t *testing.T) {
158 t.Parallel()
159 s := newScopedFixture(t)
160 theirs, err := metadata.NewIssue("Their thing", "body", "writer", nil, nil, time.Now())
161 if err != nil {
162 t.Fatal(err)
163 }
164 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
165 t.Fatal(err)
166 }
167 code, out := s.push(func() {
168 if _, err := metadata.UpdateIssue(s.m.Git(), theirs.ID, func(i *metadata.Issue) error {
169 i.State = "closed"
170 return nil
171 }, s.bot); err != nil {
172 t.Fatal(err)
173 }
174 })
175 if code == 0 {
176 t.Fatal("a contributor closed an issue filed by somebody else")
177 }
178 if !strings.Contains(out, "may not change an issue filed by writer") {
179 t.Fatalf("refusal does not explain the author: %s", out)
180 }
181 }
182
183 // A maintainer's credential is a maintainer's key: it may do everything a maintainer
184 // may do, because the role is the grant.
185 func TestAMaintainerJobManagesAnyIssue(t *testing.T) {
186 t.Parallel()
187 s := newScopedFixture(t)
188 s.setACL(func(a *access.ACL) {
189 if err := a.SetEntry(s.bot, "maintain"); err != nil {
190 t.Fatal(err)
191 }
192 })
193 theirs, err := metadata.NewIssue("Their thing", "body", "writer", nil, nil, time.Now())
194 if err != nil {
195 t.Fatal(err)
196 }
197 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
198 t.Fatal(err)
199 }
200 code, out := s.push(func() {
201 if _, err := metadata.UpdateIssue(s.m.Git(), theirs.ID, func(i *metadata.Issue) error {
202 i.State = "closed"
203 return nil
204 }, s.bot); err != nil {
205 t.Fatal(err)
206 }
207 })
208 if code != 0 {
209 t.Fatalf("a maintainer credential could not triage an issue: %s", out)
210 }
211 }
212
213 // A contributor's change request follows a branch that contributor could have pushed.
214 func TestAJobOpensAChangeForItsOwnBranch(t *testing.T) {
215 t.Parallel()
216 s := newScopedFixture(t)
217 req, err := metadata.NewRequest("Fix the thing", "body", s.bot, "main", s.head, time.Now())
218 if err != nil {
219 t.Fatal(err)
220 }
221 req.HeadRef = "contrib/coding-agent/fix"
222 code, out := s.push(func() {
223 if err := metadata.CreateRequest(s.m.Git(), req, s.bot); err != nil {
224 t.Fatal(err)
225 }
226 })
227 if code != 0 {
228 t.Fatalf("a bot could not open a change for its own branch: %s", out)
229 }
230
231 // The same request naming somebody else's branch is refused.
232 other, err := metadata.NewRequest("Fix the thing", "body", s.bot, "main", s.head, time.Now())
233 if err != nil {
234 t.Fatal(err)
235 }
236 other.HeadRef = "contrib/somebody-else/fix"
237 code, out = s.push(func() {
238 if err := metadata.CreateRequest(s.m.Git(), other, s.bot); err != nil {
239 t.Fatal(err)
240 }
241 })
242 if code == 0 {
243 t.Fatal("a job opened a change for a branch it cannot push")
244 }
245 if !strings.Contains(out, "may only propose a branch under") {
246 t.Fatalf("refusal does not explain the namespace: %s", out)
247 }
248 }
249
250 // An approval file is an account's statement about its own review, and a job may only
251 // make one when its role could. A contribute bot cannot, which is the same rule the
252 // browser applies.
253 func TestAJobCannotApproveWithoutTheRole(t *testing.T) {
254 t.Parallel()
255 s := newScopedFixture(t)
256 req, err := metadata.NewRequest("Fix the thing", "body", "writer", "main", s.head, time.Now())
257 if err != nil {
258 t.Fatal(err)
259 }
260 req.HeadRef = "contrib/coding-agent/fix"
261 if err := metadata.CreateRequest(s.m.Git(), req, "writer"); err != nil {
262 t.Fatal(err)
263 }
264 code, out := s.push(func() {
265 if _, err := metadata.ApproveRequest(s.m.Git(), req.ID, s.bot, req.Base, req.Head, time.Now(), s.bot); err != nil {
266 t.Fatal(err)
267 }
268 })
269 if code == 0 {
270 t.Fatal("a contribute job approved a change")
271 }
272 if !strings.Contains(out, "may not approve changes here") {
273 t.Fatalf("refusal does not explain the role: %s", out)
274 }
275 }
276
277 // A credential names one repository and one run. A second repository, or one whose
278 // row has expired, is not the same job.
279 func TestACredentialIsBoundToItsRepository(t *testing.T) {
280 t.Parallel()
281 s := newScopedFixture(t)
282 store, err := runs.Open(s.runsDB)
283 if err != nil {
284 t.Fatal(err)
285 }
286 defer store.Close()
287 if err := store.SetRunCredential(runs.RunCredential{
288 Fingerprint: s.credFP, Account: s.bot, Repo: "somewhere-else", RunID: "01M6FFY0BARR9YDQ3KK64VQ0MG",
289 Expires: time.Now().Add(time.Hour),
290 }, time.Now()); err != nil {
291 t.Fatal(err)
292 }
293 cm, err := metadata.NewIssueComment("hello", s.bot, time.Now())
294 if err != nil {
295 t.Fatal(err)
296 }
297 code, out := s.push(func() {
298 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, s.bot); err != nil {
299 t.Fatal(err)
300 }
301 })
302 if code == 0 {
303 t.Fatal("a credential for another repository was accepted")
304 }
305 if !strings.Contains(out, "credential is for") {
306 t.Fatalf("refusal does not name the repository: %s", out)
307 }
308 }
309
310 // An expired row is not a credential at all, so the session falls back to being an
311 // ordinary contribute account, which may not push the issues branch.
312 func TestAnExpiredCredentialWritesNothing(t *testing.T) {
313 t.Parallel()
314 s := newScopedFixture(t)
315 // Write a row that is already spent by writing it with a future expiry and then
316 // moving the clock: RunCredential compares against the time it is given, and the
317 // hook passes the wall clock, so the row is written already expired.
318 store, err := runs.Open(s.runsDB)
319 if err != nil {
320 t.Fatal(err)
321 }
322 defer store.Close()
323 if _, err := store.ExpiredCredentials(time.Now().Add(2 * time.Hour)); err != nil {
324 t.Fatal(err)
325 }
326 if cred, err := store.RunCredential(s.credFP, time.Now()); err != nil || cred != nil {
327 t.Fatalf("an expired credential still resolves: %+v %v", cred, err)
328 }
329 cm, err := metadata.NewIssueComment("hello", s.bot, time.Now())
330 if err != nil {
331 t.Fatal(err)
332 }
333 code, out := s.push(func() {
334 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, s.bot); err != nil {
335 t.Fatal(err)
336 }
337 })
338 if code == 0 {
339 t.Fatal("an expired credential still wrote to the issues branch")
340 }
341 if !strings.Contains(out, "contribute role may only push branches under") {
342 t.Fatalf("refusal does not explain the role: %s", out)
343 }
344 }