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_test.go +29−2
| @@ -36,12 +36,15 @@ func newFixture(t *testing.T, blob string) *fixture { | |||
| 36 | 36 | if err != nil { | |
| 37 | 37 | t.Fatal(err) | |
| 38 | 38 | } | |
| 39 | if err := a.SetEntry("writer", "write"); err != nil { | ||
| 39 | if err := a.SetEntry("writer", "maintain"); err != nil { | ||
| 40 | 40 | t.Fatal(err) | |
| 41 | 41 | } | |
| 42 | 42 | if err := a.SetEntry("reader", "read"); err != nil { | |
| 43 | 43 | t.Fatal(err) | |
| 44 | 44 | } | |
| 45 | if err := a.SetEntry("contributor", "contributor"); err != nil { | ||
| 46 | t.Fatal(err) | ||
| 47 | } | ||
| 45 | 48 | if err := m.SetACL(a, "test"); err != nil { | |
| 46 | 49 | t.Fatal(err) | |
| 47 | 50 | } | |
| @@ -120,6 +123,30 @@ func TestOwnerCanPush(t *testing.T) { | |||
| 120 | 123 | } | |
| 121 | 124 | } | |
| 122 | 125 | ||
| 126 | func TestContributorCanPushOnlyTheirNamespace(t *testing.T) { | ||
| 127 | t.Parallel() | ||
| 128 | f := newFixture(t, "hello\n") | ||
| 129 | own := "refs/heads/contrib/contributor/topic" | ||
| 130 | if code, out := f.run("contributor", own, 0); code != 0 { | ||
| 131 | t.Fatalf("contributor push to their namespace was rejected: %s", out) | ||
| 132 | } | ||
| 133 | for _, ref := range []string{ | ||
| 134 | "refs/heads/main", | ||
| 135 | "refs/heads/contrib/someone-else/topic", | ||
| 136 | "refs/heads/contrib/contributor", | ||
| 137 | "refs/heads/vole", | ||
| 138 | "refs/tags/contrib/contributor/v1", | ||
| 139 | } { | ||
| 140 | code, out := f.run("contributor", ref, 0) | ||
| 141 | if code == 0 { | ||
| 142 | t.Fatalf("contributor was allowed to push %s", ref) | ||
| 143 | } | ||
| 144 | if !strings.Contains(out, "contributors may only push branches under refs/heads/contrib/contributor/") { | ||
| 145 | t.Fatalf("%s: unclear rejection: %s", ref, out) | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 123 | 150 | func TestReadOnlyCannotPush(t *testing.T) { | |
| 124 | 151 | t.Parallel() | |
| 125 | 152 | f := newFixture(t, "hello\n") | |
| @@ -127,7 +154,7 @@ func TestReadOnlyCannotPush(t *testing.T) { | |||
| 127 | 154 | if code == 0 { | |
| 128 | 155 | t.Fatal("read-only user was allowed to push") | |
| 129 | 156 | } | |
| 130 | if !strings.Contains(out, "does not have write access") { | ||
| 157 | if !strings.Contains(out, "does not have maintain access") { | ||
| 131 | 158 | t.Fatalf("message should name the reason, got: %s", out) | |
| 132 | 159 | } | |
| 133 | 160 | if !strings.Contains(out, "reader") { | |