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