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