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