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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Phase 4: only allowed runners green-light a branch

852c067d5fb8226a46574c82077c2fc10bba2eb4 · Keenan Nemetz · 17d ago · parent 5d6db024

Phase 4: only allowed runners green-light a branch

Signing made a result attributable; this makes the attribution matter. A required
check is a claim about a commit, and until now any admin of the repository could
satisfy it — which is exactly the party the check exists to constrain.

`acl.runners` is an allowlist of the accounts whose CI results count for a
repository, and the gate's rule is: a green run counts only when its attestation
names an account on that list. Admins are implicit, because they can rewrite the list
and a protected branch already lets them land anything with no checks at all — so the
list is about everybody else. A writer running CI on their laptop gets their result
recorded and not counted, and the run page says which, so the answer to "why is my
green run not gating?" is where somebody looks for it rather than only in a refusal.

It is deliberately not a role. Read/write answers "may this account touch the
repository"; this answers "may this account's build be believed about it", and
conflating them would make every writer trusted about builds.

The same list decides who may lease a job, since running one executes the
repository's code: the answer arrives before the build rather than after it. That
also loosens the old lease rule, which required admin — a named runner needs no
write access at all.

`vole acl runners <repo>` shows the list, bare names replace it, `+name` adds, and
`none` clears it. There is no `-name`: revoking trust is the operation where being
made to state the whole list is a feature, and the list is printed after every change
so a dropped name is visible at once.

Fixed on the way: the new "skip non-successful runs" test would have dereferenced a
nil run for the no-run case, which TestMissingRunBlocksWithAHint caught as a panic in
the hook rather than a message.

Verified: TestOnlyAllowedRunnersGreenLightABranch walks the whole rule — an unlisted
writer is given no work, and the run page and the refusal both explain it; naming them
a runner lets them lease, run and report (attested to them); revoking the runner
refuses the merge naming the account and the command that would allow it, with the run
page saying "does not count as a check"; allowing them again lets the same green
result land, with nothing else changed.
TestACLRunnersEditsTheList covers show, +add, replacement and the unknown-account
refusal. internal/acl tests pin the rule itself: admins implicit, listed accounts
count, read-only accounts do not, and an empty account never does; plus list
normalisation and the round trip through the ref it is stored on.
scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).

cmd/vole/main.go +94−1

@@ -496,7 +496,7 @@ func cmdRepo(args []string) error {
496 496
497 497 func cmdACL(args []string) error {
498 498 if len(args) == 0 {
499 return errors.New("usage: vole acl show|set|default|protect")
499 return errors.New("usage: vole acl show|set|default|protect|checks|approvals|runners")
500 500 }
501 501 sub := args[0]
502 502 f, err := parseFlags(args[1:], configFlag())
@@ -541,6 +541,17 @@ func cmdACL(args []string) error {
541 541 if len(a.Protected) > 0 {
542 542 fmt.Printf("protected: %s\n", strings.Join(a.Protected, ", "))
543 543 }
544 // Admins are implicit runners, so an empty list is not "nobody": it is
545 // "nobody beyond the people who can edit this file".
546 if len(a.Runners) == 0 {
547 fmt.Println("runners: none named (admins may report CI results)")
548 } else {
549 names := make([]string, 0, len(a.Runners))
550 for _, r := range a.Runners {
551 names = append(names, st.ident.Name(r)+" ("+r+")")
552 }
553 fmt.Printf("runners: %s (admins too)\n", strings.Join(names, ", "))
554 }
544 555 return nil
545 556
546 557 case "set":
@@ -620,6 +631,56 @@ func cmdACL(args []string) error {
620 631 }
621 632 return nil
622 633
634 case "runners":
635 // `runners <repo>` shows the allowlist. Bare names replace it, `+name` adds,
636 // and `none` clears it back to admins-only — the word `acl set` already uses
637 // for "no role".
638 //
639 // There is deliberately no `-name`: revoking trust is the operation where
640 // being made to state the whole list is a feature, and it is the same shape
641 // as `acl protect`. The list is printed after every change, so a name dropped
642 // by accident is visible at once.
643 if len(f.rest) < 2 {
644 printRunners(st, a, m.Name)
645 return nil
646 }
647 args := f.rest[1:]
648 var next []string
649 switch {
650 case len(args) == 1 && strings.EqualFold(args[0], "none"):
651 // next stays empty
652 case strings.HasPrefix(args[0], "+"):
653 next = append(next, a.Runners...)
654 for _, arg := range args {
655 if !strings.HasPrefix(arg, "+") {
656 return usagef("%q: add names one at a time (`+name`) or replace the list with bare names, not both", arg)
657 }
658 id, err := runnerAccount(st, arg[1:])
659 if err != nil {
660 return err
661 }
662 next = append(next, id)
663 }
664 default:
665 for _, arg := range args {
666 if arg == "" || strings.HasPrefix(arg, "-") {
667 return usagef("a runner needs an account name or id, not %q", arg)
668 }
669 id, err := runnerAccount(st, arg)
670 if err != nil {
671 return err
672 }
673 next = append(next, id)
674 }
675 }
676 a.SetRunners(next)
677 if err := m.SetACL(a, os.Getenv("USER")); err != nil {
678 return err
679 }
680 _ = st.index.Upsert(m)
681 printRunners(st, a, m.Name)
682 return nil
683
623 684 case "approvals":
624 685 if len(f.rest) != 2 {
625 686 return errors.New("usage: vole acl approvals <repo> <n>")
@@ -643,6 +704,38 @@ func cmdACL(args []string) error {
643 704 return fmt.Errorf("unknown acl subcommand %q", sub)
644 705 }
645 706
707 // runnerAccount resolves a name or id to an account id, refusing an unknown one
708 // rather than writing a typo that silently permits nobody.
709 func runnerAccount(st *stores, name string) (string, error) {
710 if name == "" {
711 return "", usagef("a runner needs an account name or id")
712 }
713 account, err := st.ident.Find(name)
714 if err != nil {
715 return "", err
716 }
717 return account.ID, nil
718 }
719
720 // printRunners renders the allowlist as names with ids, and says what an empty list
721 // means, since "no runners" reads like "CI cannot count" rather than "admins still
722 // count".
723 func printRunners(st *stores, a *acl.ACL, repo string) {
724 for _, r := range a.Runners {
725 fmt.Printf("runner %-28s %s\n", r, st.ident.Name(r))
726 }
727 if len(a.Runners) > 0 {
728 fmt.Println("(admins may also report CI results)")
729 return
730 }
731 fmt.Printf("runners: none named; only admins may report CI results for %s\n", repo)
732 for _, p := range a.Principals() {
733 if a.CanReport(p) {
734 fmt.Printf(" admin %-28s %s\n", p, st.ident.Name(p))
735 }
736 }
737 }
738
646 739 // ---------- index ----------
647 740
648 741 func cmdIndex(args []string) error {