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.
server/hook/hook.go +20−6
| @@ -121,8 +121,8 @@ func PreReceive(cfg Config) int { | |||
| 121 | 121 | who := func(accountID string) string { return names(accountID) } | |
| 122 | 122 | ||
| 123 | 123 | role := a.RoleFor(actorID) | |
| 124 | if !role.AtLeast(access.Write) { | ||
| 125 | reject(cfg.Out, "%s does not have write access to %s (role: %s)", actor, m.Name, role) | ||
| 124 | if !role.AtLeast(access.Contributor) { | ||
| 125 | reject(cfg.Out, "%s does not have maintain access or contributor access to %s (role: %s)", actor, m.Name, role) | ||
| 126 | 126 | return 1 | |
| 127 | 127 | } | |
| 128 | 128 | ||
| @@ -158,6 +158,16 @@ func PreReceive(cfg Config) int { | |||
| 158 | 158 | "%s: only refs/heads/* and refs/tags/* may be pushed", c.ref)) | |
| 159 | 159 | continue | |
| 160 | 160 | } | |
| 161 | // A contributor gets one branch namespace, named after the account id. | ||
| 162 | // Everything else — metadata, shared branches, tags — belongs to write or | ||
| 163 | // admin access. The check is here rather than at the role gate because the | ||
| 164 | // gate only knows that the account may push something. | ||
| 165 | if !role.AtLeast(access.Maintain) && !access.IsContributorRef(actorID, c.ref) { | ||
| 166 | problems = append(problems, fmt.Sprintf( | ||
| 167 | "%s: contributors may only push branches under %s (role: %s)", | ||
| 168 | c.ref, access.ContributorBranchPrefix(actorID), role)) | ||
| 169 | continue | ||
| 170 | } | ||
| 161 | 171 | // The metadata branch is append-only in shape: linear history, no merge | |
| 162 | 172 | // commits. A merge node in metadata says nothing and makes the log harder | |
| 163 | 173 | // to read than the change it describes. | |
| @@ -177,12 +187,16 @@ func PreReceive(cfg Config) int { | |||
| 177 | 187 | if err != nil { | |
| 178 | 188 | problems = append(problems, fmt.Sprintf("%s: cannot check fast-forward: %v", c.ref, err)) | |
| 179 | 189 | } else if !ff { | |
| 180 | if !role.AtLeast(access.Admin) { | ||
| 181 | problems = append(problems, fmt.Sprintf( | ||
| 182 | "%s: rewriting history requires admin access; %s is %s", c.ref, actor, role)) | ||
| 183 | } else { | ||
| 190 | switch { | ||
| 191 | case role.AtLeast(access.Admin): | ||
| 184 | 192 | auditLine(cfg.AuditPath, fmt.Sprintf("force-push %s %s -> %s by %s in %s", | |
| 185 | 193 | c.ref, format.SHA(c.old), format.SHA(c.new), actor, m.Name)) | |
| 194 | case role == access.Contributor && access.IsContributorRef(actorID, c.ref): | ||
| 195 | // The namespace is theirs: rebasing or deleting their own branch is | ||
| 196 | // not a rewrite of shared history. | ||
| 197 | default: | ||
| 198 | problems = append(problems, fmt.Sprintf( | ||
| 199 | "%s: rewriting history requires admin access; %s is %s", c.ref, actor, role)) | ||
| 186 | 200 | } | |
| 187 | 201 | } | |
| 188 | 202 | } | |