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 | } | ||