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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 18m ago 4b8c9e1d
history blame raw 8.4 KiB
1package hook
2
3// What a session may write to the issues branch.
4//
5// There are two ways to write there: the browser, one action at a time through a
6// handler that asks the account's capability, and a push, which is this file. They
7// have to offer the same actions for the same account, so both ask the ACL the same
8// questions — CanDiscuss, CanOpenRequest, CanTriage, CanApprove — and this is the
9// push boundary's half of that: a whole diff, checked path by path.
10//
11// Before this existed, "writing the issues branch requires maintain" stood in for the
12// per-path rules, which is why only the browser could offer a contributor an issue or
13// a comment. The rules were always expressible; they just had not been asked here.
14
15import (
16 "fmt"
17 "sort"
18 "strings"
19 "time"
20
21 "rodent.software/vole/ci/runs"
22 "rodent.software/vole/core/git"
23 "rodent.software/vole/forge/access"
24 "rodent.software/vole/metadata"
25)
26
27// lookupRunCredential resolves the fingerprint a session authenticated with to the
28// job it belongs to, or nil when the session used an account key. An expired
29// credential reads as absent: the row is swept later, and the refusal it produces is
30// the same one an unknown key gives.
31// isRequestFile reports whether a path is a change request's own file. It is spelled
32// out here rather than assumed from a prefix so a future file beside change.md is
33// not silently treated as one.
34func isRequestFile(path string) bool {
35 return strings.HasPrefix(path, metadata.ChangesDir+"/") && strings.HasSuffix(path, "/change.md")
36}
37
38func lookupRunCredential(cfg Config, fingerprint string) (*runs.RunCredential, error) {
39 if cfg.RunsDB == "" {
40 return nil, nil
41 }
42 open := openRuns(cfg.RunsDB)
43 store, err := open()
44 if err != nil {
45 return nil, err
46 }
47 defer store.Close()
48 return store.RunCredential(fingerprint, time.Now().UTC())
49}
50
51// writableMetadata reports what a push to the issues branch may not contain. It is
52// called for every session, key or run credential: the credential changes how a job
53// authenticates, not what the account may do.
54//
55// A maintainer may do what a maintainer may do, which is every write the branch
56// carries; the one rule that applies to everybody is that an approval must be
57// somebody else's. A contributor may discuss, file an issue, manage the item it wrote,
58// and open a change from a branch it could have pushed, which is exactly what the
59// browser offers the same account.
60func writableMetadata(g *git.Repo, c change, acl *access.ACL, actor string) []string {
61 if c.old == "" || c.old == git.ZeroSHA {
62 // Nothing to compare against: the branch does not exist yet, and creating it is
63 // a maintainer's act, exactly as creating it from the browser is.
64 if acl.CanTriage(actor) {
65 return nil
66 }
67 return []string{"creating the issues branch needs a maintainer; it must already exist"}
68 }
69 status, err := g.DiffStatus(c.old, c.new)
70 if err != nil {
71 return []string{"cannot check what this push writes: " + err.Error()}
72 }
73 paths := make([]string, 0, len(status))
74 for path := range status {
75 paths = append(paths, path)
76 }
77 sort.Strings(paths)
78
79 var problems []string
80 for _, path := range paths {
81 change := status[path]
82 // An approval is an account's statement about its own review, whether it arrives
83 // through the browser or a push. approvalOwnership already requires the file to
84 // name the actor, and ValidateRequests already refuses one that approves its
85 // author's own change; what this adds is the eligibility the browser's approve
86 // button asks for, which the push boundary used to get for free from "writing the
87 // issues branch requires maintain".
88 if metadata.ApprovalPath(path) != "" {
89 if change == "A" && !acl.CanApprove(actor) {
90 problems = append(problems, fmt.Sprintf(
91 "%s: %s may not approve changes here", path, actor))
92 }
93 continue
94 }
95 // Everything else on the branch is a maintainer's to write: triage covers
96 // issues, changes, and the files they carry.
97 if acl.CanTriage(actor) {
98 continue
99 }
100 switch {
101 case metadata.IsIssueCommentPath(path), metadata.IsRequestCommentPath(path):
102 // Adding a comment is discussing. Changing or removing one is the author's,
103 // and commentOwnership already checked that against the old blob.
104 if change != "A" {
105 continue
106 }
107 if !acl.CanDiscuss(actor) {
108 problems = append(problems, fmt.Sprintf(
109 "%s: %s may not discuss in this repository", path, actor))
110 continue
111 }
112 author, ok := authorAt(g, c.new, path)
113 if !ok {
114 problems = append(problems, fmt.Sprintf("%s: cannot read whose comment this is", path))
115 continue
116 }
117 if author != actor {
118 problems = append(problems, fmt.Sprintf(
119 "%s: this push writes as %s, and this comment says %s", path, actor, author))
120 }
121 case isIssueFile(path):
122 id, _ := metadata.IssueIDFromPath(path)
123 if change == "D" {
124 // An issue is closed, not removed; the browser offers no delete, and
125 // neither does the CLI.
126 problems = append(problems, fmt.Sprintf("%s: an issue cannot be removed", path))
127 continue
128 }
129 if change == "A" {
130 // Filing an issue is part of discussing, and the author is this push:
131 // the browser fills that field in from the session, so a push has to
132 // put the same name there.
133 if !acl.CanDiscuss(actor) {
134 problems = append(problems, fmt.Sprintf(
135 "%s: %s may not file an issue in this repository", path, actor))
136 continue
137 }
138 if author, ok := authorAt(g, c.new, path); !ok || author != actor {
139 problems = append(problems, fmt.Sprintf(
140 "%s: this push files the issue as %s", path, actor))
141 }
142 continue
143 }
144 // State, labels, assignees, and the description: a maintainer may triage
145 // any issue (handled above), and an author may manage their own.
146 old, found, err := metadata.LoadIssueAtCommit(g, c.old, id)
147 if err != nil || !found || old == nil {
148 problems = append(problems, fmt.Sprintf("%s: cannot read the issue being changed", path))
149 continue
150 }
151 if old.Author != actor {
152 problems = append(problems, fmt.Sprintf(
153 "%s: %s may not change an issue filed by %s", path, actor, old.Author))
154 }
155 case isRequestFile(path):
156 id, _ := metadata.RequestIDFromPath(path)
157 old, _, err := metadata.LoadRequestAt(g, c.old, id)
158 if change == "M" {
159 if err != nil || old == nil {
160 problems = append(problems, fmt.Sprintf("%s: cannot read the change being changed", path))
161 continue
162 }
163 if old.Author != actor {
164 problems = append(problems, fmt.Sprintf(
165 "%s: %s may not change a change opened by %s", path, actor, old.Author))
166 }
167 continue
168 }
169 if change != "A" {
170 problems = append(problems, fmt.Sprintf("%s: a job may not remove a change request", path))
171 continue
172 }
173 req, found, err := metadata.LoadRequestAt(g, c.new, id)
174 if err != nil || !found || req == nil {
175 problems = append(problems, fmt.Sprintf("%s: cannot read the change request: %v", path, err))
176 continue
177 }
178 if !acl.CanOpenRequest(actor) {
179 problems = append(problems, fmt.Sprintf(
180 "%s: %s may not open a change in this repository", path, actor))
181 continue
182 }
183 if req.Author != actor {
184 problems = append(problems, fmt.Sprintf(
185 "%s: this push opens a change as %s, and this one says %s", path, actor, req.Author))
186 }
187 // A contributor proposes a branch it could have pushed itself. A bare
188 // commit is the maintainer form, and a contributor has no business
189 // pointing at one.
190 switch {
191 case req.HeadRef == "":
192 problems = append(problems, fmt.Sprintf(
193 "%s: a contribution must follow a branch, not a bare commit", path))
194 case !access.IsContributorRef(actor, "refs/heads/"+req.HeadRef):
195 problems = append(problems, fmt.Sprintf(
196 "%s: %s may only propose a branch under %s",
197 path, actor, access.ContributorBranchPrefix(actor)))
198 }
199 default:
200 problems = append(problems, fmt.Sprintf(
201 "%s: a contributor may write a comment, an issue, or a change request", path))
202 }
203 }
204 return problems
205}
206
207// authorAt reads the author of a metadata file at a commit.
208func authorAt(g *git.Repo, commit, path string) (string, bool) {
209 raw, err := g.BlobAt(commit, path)
210 if err != nil {
211 return "", false
212 }
213 author, err := metadata.AuthorOf(raw)
214 if err != nil {
215 return "", false
216 }
217 return author, true
218}
219
220// isIssueFile reports whether path is an issue's own file rather than one of its
221// comments.
222func isIssueFile(path string) bool {
223 if _, ok := metadata.IssueIDFromPath(path); !ok {
224 return false
225 }
226 return !metadata.IsIssueCommentPath(path)
227}
228