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 the instance access editor and repository danger zone
2973f7274b3c569de6018239ac8c90df98c38a1b · Keenan Nemetz · 15d ago · parent 1948358f
Add the instance access editor and repository danger zone Instance admins get /admin: a repository list and a per-repository ACL editor for default role, per-account roles, protected branches, approvals, checks, and runners. The editor reads repositories directly instead of through the read rule, because an instance admin may not have a role in the repository they are administering. Everything else still hides behind a 404. Repository admins get rename and delete on the settings page behind typed confirmation. Both go through the instance operations that also move the index, queue, LFS namespace, and locks, carry CSRF, and are audited.
server/admin_web_test.go +177
| @@ -0,0 +1,177 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/url" | ||
| 5 | "os" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func TestAdminAccessEditor(t *testing.T) { | ||
| 11 | t.Parallel() | ||
| 12 | f := browserFixture(t) | ||
| 13 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 14 | t.Fatal(err) | ||
| 15 | } | ||
| 16 | f.addWriter(t) | ||
| 17 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 18 | |||
| 19 | resp, body := f.browserGet(t, "/", cookie) | ||
| 20 | if resp.StatusCode != 200 || !strings.Contains(body, `href="/admin"`) { | ||
| 21 | t.Fatalf("instance admin does not see the Admin link: %d", resp.StatusCode) | ||
| 22 | } | ||
| 23 | resp, body = f.browserGet(t, "/admin", cookie) | ||
| 24 | if resp.StatusCode != 200 || !strings.Contains(body, f.repo) || !strings.Contains(body, "/access") { | ||
| 25 | t.Fatalf("admin page = %d:\n%s", resp.StatusCode, body) | ||
| 26 | } | ||
| 27 | |||
| 28 | // The repository is private and the admin has no ACL entry beyond the owner | ||
| 29 | // role; the admin route must still open it. | ||
| 30 | resp, body = f.browserGet(t, "/admin/repos/"+f.repo+"/access", cookie) | ||
| 31 | if resp.StatusCode != 200 { | ||
| 32 | t.Fatalf("access page = %d:\n%s", resp.StatusCode, body) | ||
| 33 | } | ||
| 34 | for _, want := range []string{"Default role", "Approvals required", "wilbur", "Runner"} { | ||
| 35 | if !strings.Contains(body, want) { | ||
| 36 | t.Fatalf("access page does not show %q:\n%s", want, body) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | form := url.Values{ | ||
| 41 | "csrf": {sess.CSRF}, | ||
| 42 | "default": {"read"}, | ||
| 43 | "approvals": {"2"}, | ||
| 44 | "protected": {"refs/heads/main\nrefs/heads/release/*"}, | ||
| 45 | "role:" + f.ownerID: {"admin"}, | ||
| 46 | "role:" + f.writerID: {"maintain"}, | ||
| 47 | "runner:" + f.writerID: {"on"}, | ||
| 48 | } | ||
| 49 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form) | ||
| 50 | if resp.StatusCode != 303 || resp.Header.Get("Location") != "/admin/repos/"+f.repo+"/access?saved=1" { | ||
| 51 | t.Fatalf("save = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 52 | } | ||
| 53 | |||
| 54 | m, err := f.srv.Repos.Open(f.repo) | ||
| 55 | if err != nil { | ||
| 56 | t.Fatal(err) | ||
| 57 | } | ||
| 58 | acl, err := m.ACL() | ||
| 59 | if err != nil { | ||
| 60 | t.Fatal(err) | ||
| 61 | } | ||
| 62 | if acl.Default != "read" { | ||
| 63 | t.Fatalf("default role = %q", acl.Default) | ||
| 64 | } | ||
| 65 | if got := acl.RequiredApprovals(); got != 2 { | ||
| 66 | t.Fatalf("approvals = %d, want 2", got) | ||
| 67 | } | ||
| 68 | if acl.RequiresChecks() { | ||
| 69 | t.Fatal("checks stayed required after an unchecked save") | ||
| 70 | } | ||
| 71 | if got := strings.Join(acl.Protected, ","); got != "refs/heads/main,refs/heads/release/*" { | ||
| 72 | t.Fatalf("protected = %q", got) | ||
| 73 | } | ||
| 74 | if acl.Entries[f.ownerID] != "admin" || acl.Entries[f.writerID] != "maintain" { | ||
| 75 | t.Fatalf("entries = %v", acl.Entries) | ||
| 76 | } | ||
| 77 | if got := strings.Join(acl.Runners, ","); got != f.writerID { | ||
| 78 | t.Fatalf("runners = %q, want %s", got, f.writerID) | ||
| 79 | } | ||
| 80 | entry, err := f.srv.Index.Get(f.repo) | ||
| 81 | if err != nil { | ||
| 82 | t.Fatal(err) | ||
| 83 | } | ||
| 84 | if entry.ACLDefault != "read" || entry.ACLPrincipals != 2 { | ||
| 85 | t.Fatalf("index entry = %+v", entry) | ||
| 86 | } | ||
| 87 | audit, err := os.ReadFile(f.cfg.AuditLog()) | ||
| 88 | if err != nil { | ||
| 89 | t.Fatal(err) | ||
| 90 | } | ||
| 91 | if !strings.Contains(string(audit), "web.admin.acl") { | ||
| 92 | t.Fatalf("ACL change was not audited:\n%s", audit) | ||
| 93 | } | ||
| 94 | if code, _ := f.get("/r/" + f.repo); code != 200 { | ||
| 95 | t.Fatalf("default read did not publish the repository: %d", code) | ||
| 96 | } | ||
| 97 | |||
| 98 | // A second save exercises checks on, zero approvals, and removing an entry | ||
| 99 | // by setting the role back to none. | ||
| 100 | form.Set("checks", "on") | ||
| 101 | form.Set("approvals", "0") | ||
| 102 | form.Set("role:"+f.writerID, "none") | ||
| 103 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form) | ||
| 104 | if resp.StatusCode != 303 { | ||
| 105 | t.Fatalf("second save = %d", resp.StatusCode) | ||
| 106 | } | ||
| 107 | acl, err = m.ACL() | ||
| 108 | if err != nil { | ||
| 109 | t.Fatal(err) | ||
| 110 | } | ||
| 111 | if !acl.RequiresChecks() || acl.RequiredApprovals() != 0 { | ||
| 112 | t.Fatalf("policy = checks %t approvals %d", acl.RequiresChecks(), acl.RequiredApprovals()) | ||
| 113 | } | ||
| 114 | if _, ok := acl.Entries[f.writerID]; ok { | ||
| 115 | t.Fatalf("role none did not remove the entry: %v", acl.Entries) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | func TestAdminAccessRequiresInstanceAdmin(t *testing.T) { | ||
| 120 | t.Parallel() | ||
| 121 | f := browserFixture(t) | ||
| 122 | f.addWriter(t) | ||
| 123 | |||
| 124 | // Anonymous is sent to login; an authenticated non-admin gets the same 404 | ||
| 125 | // as a missing page. | ||
| 126 | resp, _ := f.browserGet(t, "/admin", "") | ||
| 127 | if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "/login") { | ||
| 128 | t.Fatalf("anonymous admin = %d %q", resp.StatusCode, resp.Header.Get("Location")) | ||
| 129 | } | ||
| 130 | writerSess, writerCookie := f.browserSession(t, f.writerID) | ||
| 131 | if resp, _ := f.browserGet(t, "/admin", writerCookie); resp.StatusCode != 404 { | ||
| 132 | t.Fatalf("non-admin admin page = %d, want 404", resp.StatusCode) | ||
| 133 | } | ||
| 134 | if resp, _ := f.browserGet(t, "/admin/repos/"+f.repo+"/access", writerCookie); resp.StatusCode != 404 { | ||
| 135 | t.Fatalf("non-admin access page = %d, want 404", resp.StatusCode) | ||
| 136 | } | ||
| 137 | if _, body := f.browserGet(t, "/", writerCookie); strings.Contains(body, `href="/admin"`) { | ||
| 138 | t.Fatal("non-admin sees the Admin link") | ||
| 139 | } | ||
| 140 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", writerCookie, url.Values{ | ||
| 141 | "csrf": {writerSess.CSRF}, "default": {"read"}, "approvals": {"0"}, | ||
| 142 | }) | ||
| 143 | if resp.StatusCode != 404 { | ||
| 144 | t.Fatalf("non-admin save = %d, want 404", resp.StatusCode) | ||
| 145 | } | ||
| 146 | |||
| 147 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 148 | t.Fatal(err) | ||
| 149 | } | ||
| 150 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 151 | form := url.Values{"default": {"read"}, "approvals": {"0"}} | ||
| 152 | resp, _ = f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form) | ||
| 153 | if resp.StatusCode != 403 { | ||
| 154 | t.Fatalf("missing CSRF = %d, want 403", resp.StatusCode) | ||
| 155 | } | ||
| 156 | form.Set("csrf", sess.CSRF) | ||
| 157 | form.Set("approvals", "-1") | ||
| 158 | if resp, body := f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form); resp.StatusCode != 400 || !strings.Contains(body, "non-negative") { | ||
| 159 | t.Fatalf("negative approvals = %d:\n%s", resp.StatusCode, body) | ||
| 160 | } | ||
| 161 | form.Set("approvals", "1") | ||
| 162 | form.Set("default", "wizard") | ||
| 163 | if resp, body := f.browserPost(t, "/admin/repos/"+f.repo+"/access", cookie, form); resp.StatusCode != 400 || !strings.Contains(body, "unknown role") { | ||
| 164 | t.Fatalf("unknown role = %d:\n%s", resp.StatusCode, body) | ||
| 165 | } | ||
| 166 | m, err := f.srv.Repos.Open(f.repo) | ||
| 167 | if err != nil { | ||
| 168 | t.Fatal(err) | ||
| 169 | } | ||
| 170 | acl, err := m.ACL() | ||
| 171 | if err != nil { | ||
| 172 | t.Fatal(err) | ||
| 173 | } | ||
| 174 | if acl.Default == "read" { | ||
| 175 | t.Fatal("rejected save changed the default role") | ||
| 176 | } | ||
| 177 | } | ||