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