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