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.

Add browser auth, contributor access, and gated CI

6c84d387e5f608360e30484a1a5b1cff41076850 · Keenan Nemetz · 16d ago · parent c36d5073

Add browser auth, contributor access, and gated CI

Browser sign-in now uses passkeys. Enrollment links are one-time, sessions are hashed in users.db, and issue/comment/approval writes use the session account. HTTPS public_url is required for WebAuthn.

Add the contributor role between read and maintain. Contributors can discuss and open change requests, and may push only refs/heads/contrib/<account-id>/. Contributor CI is held per commit until a maintainer allows the exact tip from the repository page.

Rename the write role to maintain. write remains accepted as the historical spelling, and access.Write remains as a Go alias so existing callers and ACL rows keep working.

Update the web, CLI, queue schema, and guides for the new flows, and cover the role, namespace, auth, and CI-gating paths in tests.

forge/access/acl.go +86−9

@@ -14,7 +14,8 @@ type Role int
14 14 const (
15 15 None Role = iota
16 16 Read
17 Write
17 Contributor
18 Maintain
18 19 Admin
19 20 )
20 21
@@ -24,19 +25,25 @@ func ParseRole(s string) (Role, error) {
24 25 return None, nil
25 26 case "read":
26 27 return Read, nil
27 case "write":
28 return Write, nil
28 case "contributor", "contribute", "contrib":
29 return Contributor, nil
30 case "maintain", "maintainer", "write":
31 // "write" is the historical spelling. Existing ACL rows and scripts keep
32 // working; new entries are written as "maintain".
33 return Maintain, nil
29 34 case "admin", "owner":
30 35 return Admin, nil
31 36 }
32 return None, fmt.Errorf("unknown role %q (want none, read, write or admin)", s)
37 return None, fmt.Errorf("unknown role %q (want none, read, contributor, maintain or admin)", s)
33 38 }
34 39 func (r Role) String() string {
35 40 switch r {
36 41 case Read:
37 42 return "read"
38 case Write:
39 return "write"
43 case Contributor:
44 return "contributor"
45 case Maintain:
46 return "maintain"
40 47 case Admin:
41 48 return "admin"
42 49 }
@@ -44,6 +51,11 @@ func (r Role) String() string {
44 51 }
45 52 func (r Role) AtLeast(need Role) bool { return r >= need }
46 53
54 // Write is the historical name for Maintain. It stays so external code using the
55 // access package, and ACL rows written before the rename, keep compiling and
56 // parsing. New code should say Maintain.
57 const Write = Maintain
58
47 59 // ACL is one repository's rules. They live in the instance database beside the roster
48 60 // (see the access package), not inside the repository: they are configuration the
49 61 // instance owns, and they are keyed by account id, so a copy in a clone would mean
@@ -70,7 +82,7 @@ type ACL struct {
70 82 //
71 83 // Admins are implicit: they can rewrite this list, and a protected branch
72 84 // already lets them land anything with no checks at all, so restricting them
73 // here would be theatre. Everyone else — a writer who runs CI on their laptop
85 // here would be theatre. Everyone else — a maintainer who runs CI on their laptop
74 86 // included — gets their result recorded but not counted.
75 87 Runners []string `json:"runners,omitempty"`
76 88 }
@@ -143,16 +155,20 @@ func Parse(data []byte) (*ACL, error) {
143 155 if a.Entries == nil {
144 156 a.Entries = map[string]string{}
145 157 }
146 if _, err := ParseRole(a.Default); err != nil {
158 def, err := ParseRole(a.Default)
159 if err != nil {
147 160 return nil, err
148 161 }
162 a.Default = def.String()
149 163 if a.ApprovalsRequired != nil && *a.ApprovalsRequired < 0 {
150 164 return nil, fmt.Errorf("approvals_required cannot be negative")
151 165 }
152 166 for k, v := range a.Entries {
153 if _, err := ParseRole(v); err != nil {
167 role, err := ParseRole(v)
168 if err != nil {
154 169 return nil, fmt.Errorf("acl entry %q: %w", k, err)
155 170 }
171 a.Entries[k] = role.String()
156 172 }
157 173 a.SetRunners(a.Runners)
158 174 return a, nil
@@ -181,6 +197,67 @@ func (a *ACL) RoleFor(user string) Role {
181 197 }
182 198 func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) }
183 199
200 // ContributorBranchPrefix is the only branch namespace a contributor may push.
201 // The account id is in the path, so one contributor cannot move another's branch
202 // and a maintainer can see whose work a ref belongs to without a join.
203 func ContributorBranchPrefix(accountID string) string {
204 return "refs/heads/contrib/" + accountID + "/"
205 }
206
207 // IsContributorRef reports whether ref is a non-empty branch inside accountID's
208 // namespace.
209 func IsContributorRef(accountID, ref string) bool {
210 if accountID == "" {
211 return false
212 }
213 prefix := ContributorBranchPrefix(accountID)
214 return strings.HasPrefix(ref, prefix) && len(ref) > len(prefix)
215 }
216
217 // ContributorRefAccount returns the account id embedded in a contributor ref.
218 // It is the reverse of ContributorBranchPrefix, used by the CI gate before it
219 // has a principal in hand.
220 func ContributorRefAccount(ref string) (accountID string, ok bool) {
221 const prefix = "refs/heads/contrib/"
222 if !strings.HasPrefix(ref, prefix) {
223 return "", false
224 }
225 rest := strings.TrimPrefix(ref, prefix)
226 i := strings.IndexByte(rest, '/')
227 if i <= 0 || i == len(rest)-1 {
228 return "", false
229 }
230 return rest[:i], true
231 }
232
233 // CanDiscuss covers filing issues and commenting. A contributor may do this
234 // without being able to move a shared branch.
235 func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
236
237 // CanOpenRequest covers opening a change request. The head must still be a
238 // branch the caller may push: the web checks the contributor namespace.
239 func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
240
241 // CanPush says the account may push somewhere. Use CanPushRef to ask about a
242 // particular ref; a contributor may only push their own namespace.
243 func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contributor) }
244
245 // CanPushRef is the ref-level answer the pre-receive gate needs.
246 func (a *ACL) CanPushRef(user, ref string) bool {
247 role := a.RoleFor(user)
248 if role.AtLeast(Maintain) {
249 return true
250 }
251 return role == Contributor && IsContributorRef(user, ref)
252 }
253
254 // CanTriage covers changing somebody else's issue: labels, assignees, state.
255 func (a *ACL) CanTriage(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }
256
257 // CanApprove covers review approvals. Self-approval is a separate check against
258 // the request's author id.
259 func (a *ACL) CanApprove(user string) bool { return a.RoleFor(user).AtLeast(Maintain) }
260
184 261 // IsProtected reports whether ref matches a protected pattern. Patterns are an
185 262 // exact ref or a trailing "*" prefix such as "refs/heads/release/*".
186 263 func (a *ACL) IsProtected(ref string) bool {