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.

Let the CLI do what the browser does for the same account

7e0b5950c190caf11089e1aaac10f18678074fed · Keenan Nemetz · 6d ago · parent 6e1fca51

Let the CLI do what the browser does for the same account

A contributor could file an issue, comment, manage the item it wrote, and open a
change from its own branch — but only in the browser, because a push to
`refs/heads/vole` required `maintain`. The role was not the reason: the hook had
no per-path authorization for that branch, so the role gate stood in for one.

The per-path checker is now applied to every session, not only to run
credentials. It asks the ACL the questions the browser's forms ask —
CanDiscuss, CanOpenRequest, CanTriage, CanApprove — so a contributor's key can
write what a contributor may write, and a maintainer's key is unchanged. A
non-maintainer's new file must name that account as its author, which is what the
browser does for it; a maintainer keeps writing an imported issue under its
original author, because that is a trust decision rather than a capability.

Creating the issues branch still needs a maintainer, as it does from the browser,
and an approval still cannot approve its author's own change — the tree validator
already refused that, and now every transport gets the same answer. The one rule
that moved is self-approval for a maintainer's push, which is now refused earlier
rather than only at the landing gate.

With the branch no longer bundled into `maintain`, CONTRIBUTING and the guides stop
telling contributors to use the web. Both transports are one authorization model.

Part of #168, stacked on #167, which built the checker this generalises.

server/hook/scope_test.go +232−13

@@ -12,6 +12,7 @@ import (
12 12 "time"
13 13
14 14 "github.com/rodent-software/vole/ci/runs"
15 "github.com/rodent-software/vole/core/git"
15 16 "github.com/rodent-software/vole/forge/access"
16 17 "github.com/rodent-software/vole/metadata"
17 18 )
@@ -80,17 +81,24 @@ func (s *scopedFixture) metadataTip() string {
80 81 // push runs pre-receive as the job, with the credential fingerprint the server would
81 82 // have supplied. It pushes everything since before the writer ran.
82 83 func (s *scopedFixture) push(after func()) (int, string) {
84 s.t.Helper()
85 return s.pushAs(s.bot, s.credFP, after)
86 }
87
88 // pushAs is the same push by any account, with or without a run credential. An empty
89 // keyID is an ordinary key session, which is how the browser's rules and the CLI's
90 // are compared.
91 func (s *scopedFixture) pushAs(actor, keyID string, after func()) (int, string) {
83 92 s.t.Helper()
84 93 before := s.metadataTip()
85 94 after()
86 95 s.head = s.metadataTip()
87 cfg := Config{
88 Actor: s.bot,
89 KeyID: s.credFP,
96 code, out := s.runFrom(Config{
97 Actor: actor,
98 KeyID: keyID,
90 99 RunsDB: s.runsDB,
91 100 MaxBlobBytes: 1 << 20,
92 }
93 code, out := s.runFrom(cfg, before, metadata.Ref)
101 }, before, metadata.Ref)
94 102 return code, out
95 103 }
96 104
@@ -307,14 +315,11 @@ func TestACredentialIsBoundToItsRepository(t *testing.T) {
307 315 }
308 316 }
309 317
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) {
318 // An expired row is not a credential: the session is the account's own, and it may do
319 // exactly what a key session may do — no more, because expiry is not a grant.
320 func TestAnExpiredCredentialIsJustTheAccount(t *testing.T) {
313 321 t.Parallel()
314 322 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 323 store, err := runs.Open(s.runsDB)
319 324 if err != nil {
320 325 t.Fatal(err)
@@ -326,6 +331,7 @@ func TestAnExpiredCredentialWritesNothing(t *testing.T) {
326 331 if cred, err := store.RunCredential(s.credFP, time.Now()); err != nil || cred != nil {
327 332 t.Fatalf("an expired credential still resolves: %+v %v", cred, err)
328 333 }
334 // What the role allows still works...
329 335 cm, err := metadata.NewIssueComment("hello", s.bot, time.Now())
330 336 if err != nil {
331 337 t.Fatal(err)
@@ -335,10 +341,223 @@ func TestAnExpiredCredentialWritesNothing(t *testing.T) {
335 341 t.Fatal(err)
336 342 }
337 343 })
344 if code != 0 {
345 t.Fatalf("an expired credential lost the account's own capability: %s", out)
346 }
347 // ...and what it does not allow is still refused: nobody else's issue.
348 theirs, err := metadata.NewIssue("Their thing", "body", "writer", nil, nil, time.Now())
349 if err != nil {
350 t.Fatal(err)
351 }
352 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
353 t.Fatal(err)
354 }
355 code, out = s.push(func() {
356 if _, err := metadata.UpdateIssue(s.m.Git(), theirs.ID, func(i *metadata.Issue) error {
357 i.State = "closed"
358 return nil
359 }, s.bot); err != nil {
360 t.Fatal(err)
361 }
362 })
338 363 if code == 0 {
339 t.Fatal("an expired credential still wrote to the issues branch")
364 t.Fatal("an expired credential triaged somebody else's issue")
340 365 }
341 if !strings.Contains(out, "contribute role may only push branches under") {
366 if !strings.Contains(out, "may not change an issue filed by writer") {
342 367 t.Fatalf("refusal does not explain the role: %s", out)
343 368 }
344 369 }
370
371 // The browser and the CLI have to offer the same account the same actions. These are
372 // the push-path half: a contributor's key may file an issue, comment, manage the item
373 // it wrote, and open a change from its own branch, which is what the browser's forms
374 // let the same account do.
375
376 func TestAContributorKeyMayComment(t *testing.T) {
377 t.Parallel()
378 s := newScopedFixture(t)
379 cm, err := metadata.NewIssueComment("Working on it.", "contributor", time.Now())
380 if err != nil {
381 t.Fatal(err)
382 }
383 code, out := s.pushAs("contributor", "", func() {
384 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, "contributor"); err != nil {
385 t.Fatal(err)
386 }
387 })
388 if code != 0 {
389 t.Fatalf("a contributor could not comment with its key: %s", out)
390 }
391 }
392
393 func TestAContributorKeyMayFileAnIssue(t *testing.T) {
394 t.Parallel()
395 s := newScopedFixture(t)
396 iss, err := metadata.NewIssue("A new thing", "body", "contributor", []string{"bug"}, nil, time.Now())
397 if err != nil {
398 t.Fatal(err)
399 }
400 code, out := s.pushAs("contributor", "", func() {
401 if err := metadata.CreateIssue(s.m.Git(), iss, "contributor"); err != nil {
402 t.Fatal(err)
403 }
404 })
405 if code != 0 {
406 t.Fatalf("a contributor could not file an issue: %s", out)
407 }
408
409 // The author is this account, as the browser fills it in from the session.
410 s = newScopedFixture(t)
411 theirs, err := metadata.NewIssue("A new thing", "body", "writer", nil, nil, time.Now())
412 if err != nil {
413 t.Fatal(err)
414 }
415 code, out = s.pushAs("contributor", "", func() {
416 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
417 t.Fatal(err)
418 }
419 })
420 if code == 0 {
421 t.Fatal("a contributor filed an issue under another account")
422 }
423 if !strings.Contains(out, "this push files the issue as contributor") {
424 t.Fatalf("refusal does not explain the author: %s", out)
425 }
426 }
427
428 func TestAContributorKeyManagesTheIssueItFiled(t *testing.T) {
429 t.Parallel()
430 s := newScopedFixture(t)
431 iss, err := metadata.NewIssue("Mine", "body", "contributor", nil, nil, time.Now())
432 if err != nil {
433 t.Fatal(err)
434 }
435 if err := metadata.CreateIssue(s.m.Git(), iss, "contributor"); err != nil {
436 t.Fatal(err)
437 }
438 code, out := s.pushAs("contributor", "", func() {
439 if _, err := metadata.UpdateIssue(s.m.Git(), iss.ID, func(i *metadata.Issue) error {
440 i.State = "closed"
441 i.Labels = []string{"wontfix"}
442 return nil
443 }, "contributor"); err != nil {
444 t.Fatal(err)
445 }
446 })
447 if code != 0 {
448 t.Fatalf("a contributor could not close its own issue: %s", out)
449 }
450 }
451
452 func TestAContributorKeyOpensAChangeForItsBranch(t *testing.T) {
453 t.Parallel()
454 s := newScopedFixture(t)
455 req, err := metadata.NewRequest("Mine", "body", "contributor", "main", s.head, time.Now())
456 if err != nil {
457 t.Fatal(err)
458 }
459 req.HeadRef = "contrib/contributor/fix"
460 code, out := s.pushAs("contributor", "", func() {
461 if err := metadata.CreateRequest(s.m.Git(), req, "contributor"); err != nil {
462 t.Fatal(err)
463 }
464 })
465 if code != 0 {
466 t.Fatalf("a contributor could not open a change from its own branch: %s", out)
467 }
468
469 // Somebody else's branch is not its to propose, with a key or a credential.
470 other, err := metadata.NewRequest("Mine", "body", "contributor", "main", s.head, time.Now())
471 if err != nil {
472 t.Fatal(err)
473 }
474 other.HeadRef = "contrib/writer/fix"
475 code, out = s.pushAs("contributor", "", func() {
476 if err := metadata.CreateRequest(s.m.Git(), other, "contributor"); err != nil {
477 t.Fatal(err)
478 }
479 })
480 if code == 0 {
481 t.Fatal("a contributor proposed a branch it cannot push")
482 }
483 if !strings.Contains(out, "may only propose a branch under") {
484 t.Fatalf("refusal does not explain the namespace: %s", out)
485 }
486 }
487
488 func TestAContributorKeyCannotApprove(t *testing.T) {
489 t.Parallel()
490 s := newScopedFixture(t)
491 req, err := metadata.NewRequest("Theirs", "body", "writer", "main", s.head, time.Now())
492 if err != nil {
493 t.Fatal(err)
494 }
495 if err := metadata.CreateRequest(s.m.Git(), req, "writer"); err != nil {
496 t.Fatal(err)
497 }
498 code, out := s.pushAs("contributor", "", func() {
499 if _, err := metadata.ApproveRequest(s.m.Git(), req.ID, "contributor", req.Base, req.Head, time.Now(), "contributor"); err != nil {
500 t.Fatal(err)
501 }
502 })
503 if code == 0 {
504 t.Fatal("a contributor approved a change")
505 }
506 if !strings.Contains(out, "may not approve changes here") {
507 t.Fatalf("refusal does not explain the role: %s", out)
508 }
509 }
510
511 // A maintainer keeps everything a maintainer has, and gains the one rule the browser
512 // applies to everyone: a review is somebody else's judgement.
513 func TestAMaintainerKeyApprovesSomebodyElsesChange(t *testing.T) {
514 t.Parallel()
515 s := newScopedFixture(t)
516 req, err := metadata.NewRequest("Mine", "body", "contributor", "main", s.head, time.Now())
517 if err != nil {
518 t.Fatal(err)
519 }
520 req.HeadRef = "contrib/contributor/fix"
521 if err := metadata.CreateRequest(s.m.Git(), req, "contributor"); err != nil {
522 t.Fatal(err)
523 }
524 code, out := s.pushAs("writer", "", func() {
525 if _, err := metadata.ApproveRequest(s.m.Git(), req.ID, "writer", req.Base, req.Head, time.Now(), "writer"); err != nil {
526 t.Fatal(err)
527 }
528 })
529 if code != 0 {
530 t.Fatalf("a maintainer could not approve somebody else's change: %s", out)
531 }
532 }
533
534 func TestAMaintainerCannotApproveItsOwnChange(t *testing.T) {
535 t.Parallel()
536 s := newScopedFixture(t)
537 req, err := metadata.NewRequest("Mine", "body", "writer", "main", s.head, time.Now())
538 if err != nil {
539 t.Fatal(err)
540 }
541 if err := metadata.CreateRequest(s.m.Git(), req, "writer"); err != nil {
542 t.Fatal(err)
543 }
544 // The metadata writer refuses self-approval too, so this is written by hand: the
545 // hook is the boundary for a push that did not come from the CLI.
546 path := metadata.RequestsDir + "/" + req.ID + "/approvals/writer.md"
547 raw := []byte("---\nbase: main\nhead: " + s.head + "\ncreated: " +
548 time.Now().UTC().Format(time.RFC3339) + "\n---\n")
549 code, out := s.pushAs("writer", "", func() {
550 if _, err := git.CommitFiles(s.m.Git(), metadata.Ref, func(files map[string][]byte) error {
551 files[path] = raw
552 return nil
553 }, "hand-written approval", "writer"); err != nil {
554 t.Fatal(err)
555 }
556 })
557 if code == 0 {
558 t.Fatal("a maintainer approved its own change")
559 }
560 if !strings.Contains(out, "cannot approve their own request") {
561 t.Fatalf("refusal does not explain self-approval: %s", out)
562 }
563 }