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 instance-wide roles and refine access UI

5b814e9958090cfd1fa826efb2d375bf613547c1 · Keenan Nemetz · 14d ago · parent 9579c1f9

Add instance-wide roles and refine access UI

Instance roles now live on accounts, apply to public repositories, and the Admin role is the unified instance-admin permission with access to every repository. Repository ACLs can raise an instance role but not lower it.

Adds role assignment to the users UI and CLI, open-registration role config, a reusable role picker, and separates adding a repository account from saving access rules.

server/admin_web_test.go +109−10

@@ -6,6 +6,8 @@ import (
6 6 "path/filepath"
7 7 "strings"
8 8 "testing"
9
10 "github.com/rodent-software/vole/forge/access"
9 11 )
10 12
11 13 func TestRepositoryAccessSettings(t *testing.T) {
@@ -175,8 +177,9 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
175 177 t.Fatalf("non-admin access save = %d, want 404", resp.StatusCode)
176 178 }
177 179
178 // Promote the writer to instance admin. They still have no repository-admin
179 // role, so the page shows only the access section and never the danger zone.
180 // Promote the writer to instance admin. The instance Admin role governs
181 // every repository, private ones included, so the settings page now opens
182 // completely rather than showing only the access section.
180 183 if _, err := f.srv.Identity.SetAdmin(f.writerID, true, "test"); err != nil {
181 184 t.Fatal(err)
182 185 }
@@ -184,22 +187,22 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
184 187 if resp.StatusCode != 200 || !strings.Contains(body, `id="access"`) {
185 188 t.Fatalf("instance-admin settings = %d:\n%s", resp.StatusCode, body)
186 189 }
187 for _, unwanted := range []string{"Danger zone", "Default branch"} {
188 if strings.Contains(body, unwanted) {
189 t.Fatalf("instance admin without a repository role sees %q:\n%s", unwanted, body)
190 for _, want := range []string{"Danger zone", "Default branch"} {
191 if !strings.Contains(body, want) {
192 t.Fatalf("instance admin does not see %q:\n%s", want, body)
190 193 }
191 194 }
192 195 if _, home := f.browserGet(t, "/", writerCookie); !strings.Contains(home, `href="/users"`) {
193 196 t.Fatal("instance admin does not see the Users link")
194 197 }
195 198
196 // Repository details remain the repository admin's, even for an instance
197 // admin, and the handlers enforce that rather than trusting the template.
199 // Instance admin is Admin on every repository, so repository details save
200 // through the same handler as they do for a repository admin.
198 201 resp, _ = f.browserPost(t, "/r/"+f.repo+"/settings", writerCookie, url.Values{
199 "csrf": {writerSess.CSRF}, "description": {"hijack"}, "default_branch": {"main"}, "visibility": {"public"},
202 "csrf": {writerSess.CSRF}, "description": {"hijack"}, "default_branch": {"main"}, "visibility": {"private"},
200 203 })
201 if resp.StatusCode != 404 {
202 t.Fatalf("instance-admin repository save = %d, want 404", resp.StatusCode)
204 if resp.StatusCode != 303 {
205 t.Fatalf("instance-admin repository save = %d, want 303", resp.StatusCode)
203 206 }
204 207
205 208 form := url.Values{"default": {"read"}, "approvals": {"0"}}
@@ -229,3 +232,99 @@ func TestRepositoryAccessRequiresInstanceAdmin(t *testing.T) {
229 232 t.Fatal("rejected save changed the default role")
230 233 }
231 234 }
235
236 func TestInstanceRoleIsNotFrozenIntoRepositoryACL(t *testing.T) {
237 t.Parallel()
238 f := browserFixture(t)
239 f.addWriter(t)
240 if _, err := f.srv.Identity.SetInstanceRole(f.writerID, access.Maintain, "test"); err != nil {
241 t.Fatal(err)
242 }
243 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
244 t.Fatal(err)
245 }
246 m, err := f.srv.Repos.Open(f.repo)
247 if err != nil {
248 t.Fatal(err)
249 }
250 acl, err := m.ACL()
251 if err != nil {
252 t.Fatal(err)
253 }
254 acl.Default = access.Read.String()
255 if err := m.SetACL(acl, "test"); err != nil {
256 t.Fatal(err)
257 }
258 sess, cookie := f.browserSession(t, f.ownerID)
259 form := url.Values{
260 "csrf": {sess.CSRF}, "default": {"read"}, "approvals": {"1"},
261 "checks": {"on"},
262 "role:" + f.writerID: {"none"},
263 }
264 resp, body := f.browserPost(t, "/r/"+f.repo+"/settings/access", cookie, form)
265 if resp.StatusCode != 303 {
266 t.Fatalf("save access = %d:\n%s", resp.StatusCode, body)
267 }
268 acl, err = m.ACL()
269 if err != nil {
270 t.Fatal(err)
271 }
272 if _, ok := acl.Entries[f.writerID]; ok {
273 t.Fatalf("inherited instance role was written into the repo ACL: %v", acl.Entries)
274 }
275 if got := acl.RoleFor(f.writerID); got != access.Maintain {
276 t.Fatalf("public instance role = %v, want maintain", got)
277 }
278 }
279
280 func TestRepositoryAccessListsOnlyExplicitAccountsAndCanAddOne(t *testing.T) {
281 t.Parallel()
282 f := browserFixture(t)
283 f.addWriter(t)
284 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_carol"))
285 if _, err := f.srv.Identity.Create("carol", pub, "test"); err != nil {
286 t.Fatal(err)
287 }
288 if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil {
289 t.Fatal(err)
290 }
291 sess, cookie := f.browserSession(t, f.ownerID)
292
293 resp, body := f.browserGet(t, "/r/"+f.repo+"/settings", cookie)
294 if resp.StatusCode != 200 {
295 t.Fatalf("settings = %d:\n%s", resp.StatusCode, body)
296 }
297 if strings.Contains(body, `name="role:carol"`) {
298 t.Fatalf("carol has no repository entry but is in the access table:\n%s", body)
299 }
300 if !strings.Contains(body, `<option value="carol">carol</option>`) {
301 t.Fatalf("carol is not offered in Add account:\n%s", body)
302 }
303
304 form := url.Values{
305 "csrf": {sess.CSRF}, "default": {"none"}, "approvals": {"1"},
306 "new_account": {"carol"}, "new_role": {"contribute"},
307 }
308 resp, body = f.browserPost(t, "/r/"+f.repo+"/settings/access/add", cookie, form)
309 if resp.StatusCode != 303 {
310 t.Fatalf("add account = %d:\n%s", resp.StatusCode, body)
311 }
312 m, err := f.srv.Repos.Open(f.repo)
313 if err != nil {
314 t.Fatal(err)
315 }
316 acl, err := m.ACL()
317 if err != nil {
318 t.Fatal(err)
319 }
320 if acl.Entries["carol"] != access.Contribute.String() {
321 t.Fatalf("carol entry = %q, want contribute", acl.Entries["carol"])
322 }
323 _, body = f.browserGet(t, "/r/"+f.repo+"/settings", cookie)
324 if !strings.Contains(body, `name="role:carol"`) {
325 t.Fatalf("carol is not in the access table after being added:\n%s", body)
326 }
327 if strings.Contains(body, `<option value="carol">carol</option>`) {
328 t.Fatalf("carol is still offered in Add account after being added:\n%s", body)
329 }
330 }