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 3: change requests, with approvals bound to the commit they reviewed
1eeca4e67a65d71217d51b1c41632330225b351a · Keenan Nemetz · 18d ago · parent 92b37587
Phase 3: change requests, with approvals bound to the commit they reviewed Review is now a git artifact. A request names a base branch and one head commit; an approval names that same commit, so a branch that moves after approval is not approved any more. Because `git push` is the merge, the gate lives in pre-receive and there is no authenticated write surface to build. requests/<ulid>/meta.json title, author, base, head, state requests/<ulid>/description.md requests/<ulid>/approvals/<user>.json base + head + created Two push shapes are accepted for a protected branch: the new tip is the approved head, or the new tip is a merge whose parents are the branch tip and the approved head. "The approved head is an ancestor of the new tip" is refused on purpose — accepting it would let anyone append unapproved commits and land them together with an approved one. Admins keep their bypass; `vole acl approvals <repo> <n>` sets the count, and 0 restores admin-only. Three integrity holes found while building it, each now covered by a test: - An approval file names a principal, but nothing tied that name to the pusher, so any writer could commit approvals/<someone-else>.json. The hook now requires a newly added approval to be named after the SSH identity. That check initially skipped the push that *creates* the branch, where there is no old commit to diff against — which let whoever created the branch seed any approval they liked. Fixed to inspect the whole tree on a first push. - Approvals were bound to the head but not the branch, so rewriting a request's base repointed an approval at somewhere the approver never looked. - Self-approval was refused at the CLI but not in validation, so a hand-written tree could carry one. Also relaxes the CLI to accept options after arguments, as git does. Refusing `vole request approve <id> --as keenan` is a local dialect, not git's behaviour, and `git commit -m msg path` is the shape people already type. Exit 129, no bundling and `--`/`--end-of-options` are unchanged. Verified end to end on a real instance with two keys: a writer's direct push to a protected main is refused with a message naming the exact commit; the same push still fails with a request open but unapproved; self-approval fails; an admin approval lets it through; main advances; the web shows the approval as current and the request as already merged; the audit log records the merge with request id and approver. Known rough edge: a merged request stays open until someone closes it. Marking it merged automatically needs the server to write metadata during a push, which the design deliberately does not do. Phase 4 CI is the natural place to close it.
internal/requests/requests.go +384
| @@ -0,0 +1,384 @@ | |||
| 1 | // Package requests implements change requests: a proposed update to a branch, | ||
| 2 | // plus the approvals that allow it through. | ||
| 3 | // | ||
| 4 | // refs/heads/vole | ||
| 5 | // requests/<ulid>/meta.json | ||
| 6 | // requests/<ulid>/description.md | ||
| 7 | // requests/<ulid>/approvals/<principal>.json | ||
| 8 | // | ||
| 9 | // An approval names the exact commit it reviewed. That is the whole point: a | ||
| 10 | // general "I approve this request" is worthless, because the head can move | ||
| 11 | // afterwards. If the head changes, the approvals no longer match and the gate | ||
| 12 | // closes again. | ||
| 13 | // | ||
| 14 | // Merging is a push. The pre-receive hook permits a push to a protected branch | ||
| 15 | // when it carries an approved request, so there is no merge button and no | ||
| 16 | // authenticated write surface to build. | ||
| 17 | package requests | ||
| 18 | |||
| 19 | import ( | ||
| 20 | "encoding/json" | ||
| 21 | "fmt" | ||
| 22 | "regexp" | ||
| 23 | "sort" | ||
| 24 | "strings" | ||
| 25 | "time" | ||
| 26 | |||
| 27 | "github.com/rodent-software/vole/internal/gitx" | ||
| 28 | "github.com/rodent-software/vole/internal/issues" | ||
| 29 | ) | ||
| 30 | |||
| 31 | const ( | ||
| 32 | // Dir is the requests subtree on the metadata branch. | ||
| 33 | Dir = "requests" | ||
| 34 | // Ref is the metadata branch, shared with issues. | ||
| 35 | Ref = issues.Ref | ||
| 36 | |||
| 37 | metaName = "meta.json" | ||
| 38 | descName = "description.md" | ||
| 39 | approvalDir = "approvals" | ||
| 40 | maxTitleLen = 200 | ||
| 41 | ) | ||
| 42 | |||
| 43 | var ( | ||
| 44 | ulidRE = regexp.MustCompile(`^[0-9A-HJKMNP-TV-Z]{26}$`) | ||
| 45 | shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`) | ||
| 46 | pathRE = regexp.MustCompile(`^requests/([0-9A-HJKMNP-TV-Z]{26})(?:/(meta\.json|description\.md)|/approvals/([a-z0-9_-]{2,32})\.json)$`) | ||
| 47 | userNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,31}$`) | ||
| 48 | branchNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`) | ||
| 49 | ) | ||
| 50 | |||
| 51 | type Request struct { | ||
| 52 | ID string | ||
| 53 | Title string | ||
| 54 | Author string | ||
| 55 | Base string // branch name, e.g. "main" | ||
| 56 | Head string // commit sha under review | ||
| 57 | State string // open | merged | closed | ||
| 58 | Created time.Time | ||
| 59 | Updated time.Time | ||
| 60 | Description string | ||
| 61 | Approvals []Approval | ||
| 62 | } | ||
| 63 | |||
| 64 | type Approval struct { | ||
| 65 | Principal string | ||
| 66 | // Base is the branch the approver agreed the head should land on. It is part | ||
| 67 | // of the approval, not of the request, so editing a request's base later does | ||
| 68 | // not silently repoint an approval at a different branch. | ||
| 69 | Base string | ||
| 70 | Head string | ||
| 71 | Created time.Time | ||
| 72 | } | ||
| 73 | |||
| 74 | func (r *Request) Open() bool { return r.State == "open" } | ||
| 75 | |||
| 76 | // ApprovedBy reports whether principal approved this exact head. | ||
| 77 | func (r *Request) ApprovedBy(principal string) bool { | ||
| 78 | for _, a := range r.Approvals { | ||
| 79 | if a.Principal == principal && a.Head == r.Head && a.Base == r.Base { | ||
| 80 | return true | ||
| 81 | } | ||
| 82 | } | ||
| 83 | return false | ||
| 84 | } | ||
| 85 | |||
| 86 | type meta struct { | ||
| 87 | Title string `json:"title"` | ||
| 88 | Author string `json:"author"` | ||
| 89 | Base string `json:"base"` | ||
| 90 | Head string `json:"head"` | ||
| 91 | State string `json:"state"` | ||
| 92 | Created string `json:"created"` | ||
| 93 | Updated string `json:"updated,omitempty"` | ||
| 94 | } | ||
| 95 | |||
| 96 | // Load reads and validates every request on the metadata branch. | ||
| 97 | func Load(g *gitx.Repo, ref string) ([]*Request, error) { | ||
| 98 | commit, err := g.RevParse(ref) | ||
| 99 | if err != nil { | ||
| 100 | return nil, err | ||
| 101 | } | ||
| 102 | if commit == "" { | ||
| 103 | return nil, nil | ||
| 104 | } | ||
| 105 | blobs, err := g.LsTreeR(commit) | ||
| 106 | if err != nil { | ||
| 107 | return nil, err | ||
| 108 | } | ||
| 109 | contents := map[string]string{} | ||
| 110 | for _, e := range blobs { | ||
| 111 | if !strings.HasPrefix(e.Name, Dir+"/") { | ||
| 112 | continue | ||
| 113 | } | ||
| 114 | data, err := g.BlobBySHA(e.SHA) | ||
| 115 | if err != nil { | ||
| 116 | return nil, err | ||
| 117 | } | ||
| 118 | contents[e.Name] = string(data) | ||
| 119 | } | ||
| 120 | return Parse(contents) | ||
| 121 | } | ||
| 122 | |||
| 123 | // Parse turns requests paths into requests, validating as it goes. | ||
| 124 | func Parse(contents map[string]string) ([]*Request, error) { | ||
| 125 | var problems []string | ||
| 126 | byID := map[string]*Request{} | ||
| 127 | |||
| 128 | paths := make([]string, 0, len(contents)) | ||
| 129 | for p := range contents { | ||
| 130 | paths = append(paths, p) | ||
| 131 | } | ||
| 132 | sort.Strings(paths) | ||
| 133 | |||
| 134 | for _, path := range paths { | ||
| 135 | if !strings.HasPrefix(path, Dir+"/") { | ||
| 136 | continue | ||
| 137 | } | ||
| 138 | m := pathRE.FindStringSubmatch(path) | ||
| 139 | if m == nil { | ||
| 140 | problems = append(problems, fmt.Sprintf( | ||
| 141 | "%s: not part of the request format (expected requests/<ulid>/meta.json, description.md or approvals/<user>.json)", path)) | ||
| 142 | continue | ||
| 143 | } | ||
| 144 | id := m[1] | ||
| 145 | req := byID[id] | ||
| 146 | if req == nil { | ||
| 147 | req = &Request{ID: id, State: "open"} | ||
| 148 | byID[id] = req | ||
| 149 | } | ||
| 150 | switch { | ||
| 151 | case m[2] == metaName: | ||
| 152 | applyMeta(req, contents[path], path, &problems) | ||
| 153 | case m[2] == descName: | ||
| 154 | req.Description = contents[path] | ||
| 155 | case m[3] != "": | ||
| 156 | a, prob := parseApproval(m[3], contents[path], path) | ||
| 157 | if prob != "" { | ||
| 158 | problems = append(problems, prob) | ||
| 159 | continue | ||
| 160 | } | ||
| 161 | req.Approvals = append(req.Approvals, a) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | out := make([]*Request, 0, len(byID)) | ||
| 166 | for _, req := range byID { | ||
| 167 | if req.Title == "" { | ||
| 168 | problems = append(problems, fmt.Sprintf("%s/%s/%s: title is required", Dir, req.ID, metaName)) | ||
| 169 | } | ||
| 170 | // Self-approval is not an approval. Catching it here rather than at the | ||
| 171 | // gate means the author learns immediately instead of after a rejection. | ||
| 172 | for _, a := range req.Approvals { | ||
| 173 | if a.Principal == req.Author { | ||
| 174 | problems = append(problems, fmt.Sprintf( | ||
| 175 | "%s/%s/%s/%s.json: %s cannot approve their own request", Dir, req.ID, approvalDir, a.Principal, a.Principal)) | ||
| 176 | } | ||
| 177 | } | ||
| 178 | sort.Slice(req.Approvals, func(i, j int) bool { return req.Approvals[i].Principal < req.Approvals[j].Principal }) | ||
| 179 | out = append(out, req) | ||
| 180 | } | ||
| 181 | sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) | ||
| 182 | |||
| 183 | if len(problems) > 0 { | ||
| 184 | return nil, &ValidationError{Problems: problems} | ||
| 185 | } | ||
| 186 | return out, nil | ||
| 187 | } | ||
| 188 | |||
| 189 | func applyMeta(req *Request, raw, path string, problems *[]string) { | ||
| 190 | var m meta | ||
| 191 | dec := json.NewDecoder(strings.NewReader(raw)) | ||
| 192 | dec.DisallowUnknownFields() | ||
| 193 | if err := dec.Decode(&m); err != nil { | ||
| 194 | *problems = append(*problems, fmt.Sprintf("%s: cannot parse: %v", path, err)) | ||
| 195 | return | ||
| 196 | } | ||
| 197 | req.Title = strings.TrimSpace(m.Title) | ||
| 198 | if len(req.Title) > maxTitleLen { | ||
| 199 | *problems = append(*problems, fmt.Sprintf("%s: title is %d bytes, limit %d", path, len(req.Title), maxTitleLen)) | ||
| 200 | } | ||
| 201 | req.Author = strings.TrimSpace(m.Author) | ||
| 202 | if req.Author == "" { | ||
| 203 | *problems = append(*problems, fmt.Sprintf("%s: author is required", path)) | ||
| 204 | } | ||
| 205 | req.Base = strings.TrimSpace(m.Base) | ||
| 206 | if !branchNameRE.MatchString(req.Base) { | ||
| 207 | *problems = append(*problems, fmt.Sprintf("%s: base must be a branch name, got %q", path, req.Base)) | ||
| 208 | } | ||
| 209 | req.Head = strings.TrimSpace(m.Head) | ||
| 210 | if req.Head != "" && !shaRE.MatchString(req.Head) { | ||
| 211 | *problems = append(*problems, fmt.Sprintf("%s: head must be a full 40-character commit id, got %q", path, req.Head)) | ||
| 212 | } | ||
| 213 | if m.State == "" { | ||
| 214 | m.State = "open" | ||
| 215 | } | ||
| 216 | switch m.State { | ||
| 217 | case "open", "merged", "closed": | ||
| 218 | req.State = m.State | ||
| 219 | default: | ||
| 220 | *problems = append(*problems, fmt.Sprintf("%s: state must be open, merged or closed, got %q", path, m.State)) | ||
| 221 | } | ||
| 222 | if m.Created == "" { | ||
| 223 | *problems = append(*problems, fmt.Sprintf("%s: created is required", path)) | ||
| 224 | } else if t, err := time.Parse(time.RFC3339, m.Created); err != nil { | ||
| 225 | *problems = append(*problems, fmt.Sprintf("%s: created must be RFC3339: %v", path, err)) | ||
| 226 | } else { | ||
| 227 | req.Created = t.UTC() | ||
| 228 | } | ||
| 229 | if m.Updated != "" { | ||
| 230 | if t, err := time.Parse(time.RFC3339, m.Updated); err == nil { | ||
| 231 | req.Updated = t.UTC() | ||
| 232 | } | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 236 | func parseApproval(principal, raw, path string) (Approval, string) { | ||
| 237 | a := Approval{Principal: principal} | ||
| 238 | if !userNameRE.MatchString(principal) { | ||
| 239 | return a, fmt.Sprintf("%s: %q is not a valid principal name", path, principal) | ||
| 240 | } | ||
| 241 | var body struct { | ||
| 242 | Base string `json:"base"` | ||
| 243 | Head string `json:"head"` | ||
| 244 | Created string `json:"created"` | ||
| 245 | } | ||
| 246 | dec := json.NewDecoder(strings.NewReader(raw)) | ||
| 247 | dec.DisallowUnknownFields() | ||
| 248 | if err := dec.Decode(&body); err != nil { | ||
| 249 | return a, fmt.Sprintf("%s: cannot parse: %v", path, err) | ||
| 250 | } | ||
| 251 | if !shaRE.MatchString(body.Head) { | ||
| 252 | return a, fmt.Sprintf("%s: head must be the full commit id that was reviewed", path) | ||
| 253 | } | ||
| 254 | a.Head = body.Head | ||
| 255 | if !branchNameRE.MatchString(body.Base) { | ||
| 256 | return a, fmt.Sprintf("%s: base must name the branch you approved (e.g. \"main\")", path) | ||
| 257 | } | ||
| 258 | a.Base = body.Base | ||
| 259 | t, err := time.Parse(time.RFC3339, body.Created) | ||
| 260 | if err != nil { | ||
| 261 | return a, fmt.Sprintf("%s: created must be RFC3339: %v", path, err) | ||
| 262 | } | ||
| 263 | a.Created = t.UTC() | ||
| 264 | return a, "" | ||
| 265 | } | ||
| 266 | |||
| 267 | // ApprovalPath returns the principal an approvals/ path belongs to, or "". | ||
| 268 | func ApprovalPath(path string) string { | ||
| 269 | m := pathRE.FindStringSubmatch(path) | ||
| 270 | if m == nil { | ||
| 271 | return "" | ||
| 272 | } | ||
| 273 | return m[3] | ||
| 274 | } | ||
| 275 | |||
| 276 | type ValidationError struct{ Problems []string } | ||
| 277 | |||
| 278 | func (e *ValidationError) Error() string { | ||
| 279 | if len(e.Problems) == 1 { | ||
| 280 | return e.Problems[0] | ||
| 281 | } | ||
| 282 | return fmt.Sprintf("%d problems: %s", len(e.Problems), strings.Join(e.Problems, "; ")) | ||
| 283 | } | ||
| 284 | |||
| 285 | // Validate checks the requests subtree at commit. | ||
| 286 | func Validate(g *gitx.Repo, commit string) []string { | ||
| 287 | if commit == "" || commit == gitx.ZeroSHA { | ||
| 288 | return nil | ||
| 289 | } | ||
| 290 | blobs, err := g.LsTreeR(commit) | ||
| 291 | if err != nil { | ||
| 292 | return []string{fmt.Sprintf("cannot read the requests tree: %v", err)} | ||
| 293 | } | ||
| 294 | contents := map[string]string{} | ||
| 295 | for _, e := range blobs { | ||
| 296 | if !strings.HasPrefix(e.Name, Dir+"/") { | ||
| 297 | continue | ||
| 298 | } | ||
| 299 | data, err := g.BlobBySHA(e.SHA) | ||
| 300 | if err != nil { | ||
| 301 | return []string{fmt.Sprintf("cannot read %s: %v", e.Name, err)} | ||
| 302 | } | ||
| 303 | contents[e.Name] = string(data) | ||
| 304 | } | ||
| 305 | if _, err := Parse(contents); err != nil { | ||
| 306 | if ve, ok := err.(*ValidationError); ok { | ||
| 307 | return ve.Problems | ||
| 308 | } | ||
| 309 | return []string{err.Error()} | ||
| 310 | } | ||
| 311 | return nil | ||
| 312 | } | ||
| 313 | |||
| 314 | // ApprovalRequirement is what the gate needs to decide: how many approvals, and | ||
| 315 | // which commit is being landed. | ||
| 316 | type ApprovalRequirement struct { | ||
| 317 | // Branch is the short branch name being pushed to, e.g. "main". | ||
| 318 | Branch string | ||
| 319 | // NewTip is the commit being written. | ||
| 320 | NewTip string | ||
| 321 | // OldTip is the commit currently at the branch. | ||
| 322 | OldTip string | ||
| 323 | // Required is the number of distinct approvals the ACL demands. | ||
| 324 | Required int | ||
| 325 | } | ||
| 326 | |||
| 327 | // Merge describes an approved outcome: the request that authorised the push and | ||
| 328 | // the principals whose approvals matched. | ||
| 329 | type Merge struct { | ||
| 330 | Request *Request | ||
| 331 | Approvers []string | ||
| 332 | } | ||
| 333 | |||
| 334 | // FindApprovedMatch reports whether a push to a protected branch is authorised by | ||
| 335 | // an approved request. | ||
| 336 | // | ||
| 337 | // Two shapes are accepted: | ||
| 338 | // | ||
| 339 | // fast-forward the new tip *is* the approved head | ||
| 340 | // merge the new tip is a merge whose parents are the old tip and the | ||
| 341 | // approved head (either order) | ||
| 342 | // | ||
| 343 | // Anything else is rejected. In particular "the approved head is an ancestor of | ||
| 344 | // the new tip" is NOT accepted: it would let anyone append unapproved commits to | ||
| 345 | // an approved one and land them together. | ||
| 346 | func FindApprovedMatch(g *gitx.Repo, list []*Request, req ApprovalRequirement) (*Merge, error) { | ||
| 347 | if req.Required <= 0 { | ||
| 348 | return nil, nil | ||
| 349 | } | ||
| 350 | var nearest error | ||
| 351 | for _, r := range list { | ||
| 352 | if r.State != "open" || r.Base != req.Branch || r.Head == "" { | ||
| 353 | continue | ||
| 354 | } | ||
| 355 | matches := false | ||
| 356 | switch { | ||
| 357 | case r.Head == req.NewTip: | ||
| 358 | matches = true | ||
| 359 | case req.OldTip != "" && req.OldTip != gitx.ZeroSHA: | ||
| 360 | parents, err := g.CommitParents(req.NewTip) | ||
| 361 | if err != nil { | ||
| 362 | nearest = err | ||
| 363 | continue | ||
| 364 | } | ||
| 365 | if len(parents) == 2 { | ||
| 366 | matches = (parents[0] == req.OldTip && parents[1] == r.Head) || | ||
| 367 | (parents[1] == req.OldTip && parents[0] == r.Head) | ||
| 368 | } | ||
| 369 | } | ||
| 370 | if !matches { | ||
| 371 | continue | ||
| 372 | } | ||
| 373 | var approvers []string | ||
| 374 | for _, a := range r.Approvals { | ||
| 375 | if a.Head == r.Head && a.Base == r.Base { | ||
| 376 | approvers = append(approvers, a.Principal) | ||
| 377 | } | ||
| 378 | } | ||
| 379 | if len(approvers) >= req.Required { | ||
| 380 | return &Merge{Request: r, Approvers: approvers}, nil | ||
| 381 | } | ||
| 382 | } | ||
| 383 | return nil, nearest | ||
| 384 | } | ||