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.go +205
| @@ -0,0 +1,205 @@ | |||
| 1 | package hook | ||
| 2 | |||
| 3 | // What a job may write. | ||
| 4 | // | ||
| 5 | // A run credential authenticates as the account the job acts as, but it is not that | ||
| 6 | // account's key: it exists for one job, in one repository, until the run ends. The | ||
| 7 | // role still decides what the account may do — the credential does not widen it — and | ||
| 8 | // this file is the one place that turns a metadata diff into that decision. | ||
| 9 | // | ||
| 10 | // The rule is the browser's write surface, moved to the push boundary. The browser | ||
| 11 | // already lets a contributor comment, open a change from a branch they may push, and | ||
| 12 | // lets an eligible approver approve; everything else it does is a maintainer action. | ||
| 13 | // A job gets the same list and no more, which is what makes it safe to hand a bot a | ||
| 14 | // key without handing it the role that used to be the only way to write metadata. | ||
| 15 | |||
| 16 | import ( | ||
| 17 | "fmt" | ||
| 18 | "sort" | ||
| 19 | "strings" | ||
| 20 | "time" | ||
| 21 | |||
| 22 | "github.com/rodent-software/vole/ci/runs" | ||
| 23 | "github.com/rodent-software/vole/core/git" | ||
| 24 | "github.com/rodent-software/vole/forge/access" | ||
| 25 | "github.com/rodent-software/vole/metadata" | ||
| 26 | ) | ||
| 27 | |||
| 28 | // lookupRunCredential resolves the fingerprint a session authenticated with to the | ||
| 29 | // job it belongs to, or nil when the session used an account key. An expired | ||
| 30 | // credential reads as absent: the row is swept later, and the refusal it produces is | ||
| 31 | // the same one an unknown key gives. | ||
| 32 | // isRequestFile reports whether a path is a change request's own file. It is spelled | ||
| 33 | // out here rather than assumed from a prefix so a future file beside request.md is | ||
| 34 | // not silently treated as one. | ||
| 35 | func isRequestFile(path string) bool { | ||
| 36 | return strings.HasPrefix(path, metadata.RequestsDir+"/") && strings.HasSuffix(path, "/request.md") | ||
| 37 | } | ||
| 38 | |||
| 39 | func lookupRunCredential(cfg Config, fingerprint string) (*runs.RunCredential, error) { | ||
| 40 | if cfg.RunsDB == "" { | ||
| 41 | return nil, nil | ||
| 42 | } | ||
| 43 | open := openRuns(cfg.RunsDB) | ||
| 44 | store, err := open() | ||
| 45 | if err != nil { | ||
| 46 | return nil, err | ||
| 47 | } | ||
| 48 | defer store.Close() | ||
| 49 | return store.RunCredential(fingerprint, time.Now().UTC()) | ||
| 50 | } | ||
| 51 | |||
| 52 | // scopedMetadata reports what a push to the issues branch may not contain when it | ||
| 53 | // came from a run credential. | ||
| 54 | // | ||
| 55 | // The rule is the account's capabilities, asked the way the browser asks them. A | ||
| 56 | // credential does not make the account stronger or weaker: a maintainer may do what a | ||
| 57 | // maintainer may do, a contributor may file issues, comment, open a change from a | ||
| 58 | // branch it could push, and manage the items it wrote, and an account that may approve | ||
| 59 | // may approve. The credential replaces exactly one requirement — that writing the | ||
| 60 | // issues branch at all needs maintain — and adds no rule of its own. | ||
| 61 | func scopedMetadata(g *git.Repo, c change, acl *access.ACL, actor string) []string { | ||
| 62 | if c.old == "" || c.old == git.ZeroSHA { | ||
| 63 | return []string{"a job cannot create the issues branch; it must already exist"} | ||
| 64 | } | ||
| 65 | // Triaging other people's items covers every write the issues branch carries, so a | ||
| 66 | // maintainer needs nothing further: what a key may do, a credential may do. | ||
| 67 | if acl.CanTriage(actor) { | ||
| 68 | return nil | ||
| 69 | } | ||
| 70 | status, err := g.DiffStatus(c.old, c.new) | ||
| 71 | if err != nil { | ||
| 72 | return []string{"cannot check what this job's push writes: " + err.Error()} | ||
| 73 | } | ||
| 74 | paths := make([]string, 0, len(status)) | ||
| 75 | for path := range status { | ||
| 76 | paths = append(paths, path) | ||
| 77 | } | ||
| 78 | sort.Strings(paths) | ||
| 79 | |||
| 80 | var problems []string | ||
| 81 | for _, path := range paths { | ||
| 82 | change := status[path] | ||
| 83 | switch { | ||
| 84 | case metadata.IsIssueCommentPath(path), metadata.IsRequestCommentPath(path): | ||
| 85 | // Adding a comment is discussing. Changing or removing one is the author's, | ||
| 86 | // and commentOwnership already checked that against the old blob. | ||
| 87 | if change != "A" { | ||
| 88 | continue | ||
| 89 | } | ||
| 90 | if !acl.CanDiscuss(actor) { | ||
| 91 | problems = append(problems, fmt.Sprintf( | ||
| 92 | "%s: %s may not discuss in this repository", path, actor)) | ||
| 93 | continue | ||
| 94 | } | ||
| 95 | author, ok := authorAt(g, c.new, path) | ||
| 96 | if !ok { | ||
| 97 | problems = append(problems, fmt.Sprintf("%s: cannot read whose comment this is", path)) | ||
| 98 | continue | ||
| 99 | } | ||
| 100 | if author != actor { | ||
| 101 | problems = append(problems, fmt.Sprintf( | ||
| 102 | "%s: a job writes as %s, and this comment says %s", path, actor, author)) | ||
| 103 | } | ||
| 104 | case isIssueFile(path): | ||
| 105 | id, _ := metadata.IssueIDFromPath(path) | ||
| 106 | if change == "A" { | ||
| 107 | // Filing an issue is part of discussing. | ||
| 108 | if !acl.CanDiscuss(actor) { | ||
| 109 | problems = append(problems, fmt.Sprintf( | ||
| 110 | "%s: %s may not file an issue in this repository", path, actor)) | ||
| 111 | } | ||
| 112 | continue | ||
| 113 | } | ||
| 114 | // State, labels, assignees, and the description: a maintainer may triage | ||
| 115 | // any issue (handled above), and an author may manage their own. | ||
| 116 | old, found, err := metadata.LoadIssueAtCommit(g, c.old, id) | ||
| 117 | if err != nil || !found || old == nil { | ||
| 118 | problems = append(problems, fmt.Sprintf("%s: cannot read the issue being changed", path)) | ||
| 119 | continue | ||
| 120 | } | ||
| 121 | if old.Author != actor { | ||
| 122 | problems = append(problems, fmt.Sprintf( | ||
| 123 | "%s: %s may not change an issue filed by %s", path, actor, old.Author)) | ||
| 124 | } | ||
| 125 | case isRequestFile(path): | ||
| 126 | id, _ := metadata.RequestIDFromPath(path) | ||
| 127 | old, _, err := metadata.LoadRequestAt(g, c.old, id) | ||
| 128 | if change == "M" { | ||
| 129 | if err != nil || old == nil { | ||
| 130 | problems = append(problems, fmt.Sprintf("%s: cannot read the change being changed", path)) | ||
| 131 | continue | ||
| 132 | } | ||
| 133 | if old.Author != actor { | ||
| 134 | problems = append(problems, fmt.Sprintf( | ||
| 135 | "%s: %s may not change a change opened by %s", path, actor, old.Author)) | ||
| 136 | } | ||
| 137 | continue | ||
| 138 | } | ||
| 139 | if change != "A" { | ||
| 140 | problems = append(problems, fmt.Sprintf("%s: a job may not remove a change request", path)) | ||
| 141 | continue | ||
| 142 | } | ||
| 143 | req, found, err := metadata.LoadRequestAt(g, c.new, id) | ||
| 144 | if err != nil || !found || req == nil { | ||
| 145 | problems = append(problems, fmt.Sprintf("%s: cannot read the change request: %v", path, err)) | ||
| 146 | continue | ||
| 147 | } | ||
| 148 | if !acl.CanOpenRequest(actor) { | ||
| 149 | problems = append(problems, fmt.Sprintf( | ||
| 150 | "%s: %s may not open a change in this repository", path, actor)) | ||
| 151 | continue | ||
| 152 | } | ||
| 153 | if req.Author != actor { | ||
| 154 | problems = append(problems, fmt.Sprintf( | ||
| 155 | "%s: a job opens a change as %s, and this one says %s", path, actor, req.Author)) | ||
| 156 | } | ||
| 157 | // A contributor proposes a branch it could have pushed itself. A bare | ||
| 158 | // commit is the maintainer form, and a contributor has no business | ||
| 159 | // pointing at one. | ||
| 160 | switch { | ||
| 161 | case req.HeadRef == "": | ||
| 162 | problems = append(problems, fmt.Sprintf( | ||
| 163 | "%s: a contribution must follow a branch, not a bare commit", path)) | ||
| 164 | case !access.IsContributorRef(actor, "refs/heads/"+req.HeadRef): | ||
| 165 | problems = append(problems, fmt.Sprintf( | ||
| 166 | "%s: %s may only propose a branch under %s", | ||
| 167 | path, actor, access.ContributorBranchPrefix(actor))) | ||
| 168 | } | ||
| 169 | case metadata.ApprovalPath(path) != "": | ||
| 170 | // approvalOwnership already requires the file to name the actor; this is | ||
| 171 | // the eligibility the browser checks, which the push boundary used to get | ||
| 172 | // for free from "writing the issues branch requires maintain". | ||
| 173 | if !acl.CanApprove(actor) { | ||
| 174 | problems = append(problems, fmt.Sprintf( | ||
| 175 | "%s: %s may not approve changes here", path, actor)) | ||
| 176 | } | ||
| 177 | default: | ||
| 178 | problems = append(problems, fmt.Sprintf( | ||
| 179 | "%s: a job may write a comment, an issue, a change request, or an approval", path)) | ||
| 180 | } | ||
| 181 | } | ||
| 182 | return problems | ||
| 183 | } | ||
| 184 | |||
| 185 | // authorAt reads the author of a metadata file at a commit. | ||
| 186 | func authorAt(g *git.Repo, commit, path string) (string, bool) { | ||
| 187 | raw, err := g.BlobAt(commit, path) | ||
| 188 | if err != nil { | ||
| 189 | return "", false | ||
| 190 | } | ||
| 191 | author, err := metadata.AuthorOf(raw) | ||
| 192 | if err != nil { | ||
| 193 | return "", false | ||
| 194 | } | ||
| 195 | return author, true | ||
| 196 | } | ||
| 197 | |||
| 198 | // isIssueFile reports whether path is an issue's own file rather than one of its | ||
| 199 | // comments. | ||
| 200 | func isIssueFile(path string) bool { | ||
| 201 | if _, ok := metadata.IssueIDFromPath(path); !ok { | ||
| 202 | return false | ||
| 203 | } | ||
| 204 | return !metadata.IsIssueCommentPath(path) | ||
| 205 | } | ||