Commit 88fc476788
Verified · cmc ci/build: success ci/test: success
.gitbay/wiki/Users.org +5 −1
| @@ -191,6 +191,7 @@ Access and settings (owner or =admin= grant): | ||
| 191 | 191 | #+begin_src sh |
| 192 | 192 | gitbay repo access grant you/project alice write # read | write | admin |
| 193 | 193 | gitbay repo access revoke you/project alice |
| 194 | gitbay repo access list you/project # everyone who can reach it, role, and via what | |
| 194 | 195 | gitbay repo settings protect you/project main # no force-push, no delete |
| 195 | 196 | gitbay repo settings require-mr you/project on # protected branches: merge requests only |
| 196 | 197 | gitbay repo settings protect-tag you/project 'v*' # matching tags: created once, never moved or deleted |
| @@ -252,7 +253,10 @@ shows the text instead. Other files show the text with highlighting. | ||
| 252 | 253 | |
| 253 | 254 | Orgs share the owner namespace with users and own repositories at |
| 254 | 255 | =org/repo=. By default members get write on all org repos; org admins |
| 255 | get repo admin, create repos under the org, and manage membership. | |
| 256 | get repo admin, create repos under the org, and manage membership. An | |
| 257 | org's existence and member list are public; its teams are visible to | |
| 258 | members, and anyone else asking is refused rather than told the org | |
| 259 | does not exist. | |
| 256 | 260 | |
| 257 | 261 | #+begin_src sh |
| 258 | 262 | gitbay org create krz |
e2e/effectiveaccess_test.go added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // repo access list reports effective access with its source, and an | |
| 9 | // outsider gets one answer about an org: it exists and has members; | |
| 10 | // its teams are not theirs to see (#200). | |
| 11 | func TestEffectiveAccessAndOutsiders(t *testing.T) { | |
| 12 | inst := startInstance(t) | |
| 13 | keys := map[string]string{} | |
| 14 | for _, u := range []string{"alice", "bob", "carol", "dave", "eve"} { | |
| 15 | keys[u] = inst.newKey(t, u) | |
| 16 | inst.admin(t, "admin", "user", "create", u, "--key", keys[u]+".pub") | |
| 17 | } | |
| 18 | for _, args := range [][]string{ | |
| 19 | {"org", "create", "acme"}, | |
| 20 | {"org", "members", "add", "acme", "bob"}, | |
| 21 | {"org", "members", "add", "acme", "carol"}, | |
| 22 | {"org", "members", "add", "acme", "dave"}, | |
| 23 | {"org", "settings", "members-role", "acme", "read"}, | |
| 24 | {"repo", "create", "acme/core", "--private"}, | |
| 25 | {"org", "team", "create", "acme", "core"}, | |
| 26 | {"org", "team", "add", "acme", "core", "bob"}, | |
| 27 | {"org", "team", "grant", "acme", "core", "acme/core", "write"}, | |
| 28 | {"repo", "access", "grant", "acme/core", "carol", "admin"}, | |
| 29 | } { | |
| 30 | if _, errOut, code := inst.ssh(t, keys["alice"], "", args...); code != 0 { | |
| 31 | t.Fatalf("%v: %s", args, errOut) | |
| 32 | } | |
| 33 | } | |
| 34 | out, _, code := inst.ssh(t, keys["alice"], "", "repo", "access", "list", "acme/core", "--json") | |
| 35 | if code != 0 { | |
| 36 | t.Fatalf("access list: %s", out) | |
| 37 | } | |
| 38 | for _, want := range []string{ | |
| 39 | `{"user":"alice","role":"admin","source":"org admin"}`, | |
| 40 | `{"user":"bob","role":"write","source":"team core"}`, | |
| 41 | `{"user":"carol","role":"admin","source":"direct"}`, | |
| 42 | `{"user":"dave","role":"read","source":"org member"}`, | |
| 43 | } { | |
| 44 | if !strings.Contains(out, want) { | |
| 45 | t.Errorf("access list missing %s:\n%s", want, out) | |
| 46 | } | |
| 47 | } | |
| 48 | if strings.Contains(out, `"eve"`) { | |
| 49 | t.Errorf("outsider listed:\n%s", out) | |
| 50 | } | |
| 51 | ||
| 52 | // eve: the org and its members are public, the teams are not. | |
| 53 | if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "show", "acme"); code != 0 { | |
| 54 | t.Fatalf("org show for an outsider: %d %s", code, errOut) | |
| 55 | } | |
| 56 | if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "members", "list", "acme"); code != 0 { | |
| 57 | t.Fatalf("org members list for an outsider: %d %s", code, errOut) | |
| 58 | } | |
| 59 | if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "team", "list", "acme"); code != 4 { | |
| 60 | t.Fatalf("org team list for an outsider: %d %s", code, errOut) | |
| 61 | } | |
| 62 | if _, errOut, code := inst.ssh(t, keys["eve"], "", "org", "team", "show", "acme", "core"); code != 4 { | |
| 63 | t.Fatalf("org team show for an outsider: %d %s", code, errOut) | |
| 64 | } | |
| 65 | if _, _, code := inst.ssh(t, keys["eve"], "", "org", "team", "list", "nosuch"); code != 3 { | |
| 66 | t.Fatalf("missing org: %d", code) | |
| 67 | } | |
| 68 | } | |
internal/control/repo.go +7 −6
| @@ -50,7 +50,7 @@ func init() { | ||
| 50 | 50 | Summary: "revoke access", |
| 51 | 51 | Usage: "repo access revoke <owner/name> <user>", Run: runAccessRevoke}) |
| 52 | 52 | register(Command{Path: []string{"repo", "access", "list"}, |
| 53 | Summary: "list access grants", | |
| 53 | Summary: "list who can reach the repository, with the role and where it comes from", | |
| 54 | 54 | Usage: "repo access list <owner/name>", ReadOnly: true, Run: runAccessList}) |
| 55 | 55 | register(Command{Path: []string{"repo", "settings", "show"}, |
| 56 | 56 | Summary: "show settings", |
| @@ -585,21 +585,22 @@ func runAccessList(c *Ctx, args []string) int { | ||
| 585 | 585 | if code >= 0 { |
| 586 | 586 | return code |
| 587 | 587 | } |
| 588 | entries, err := c.Store.ListAccess(repo.ID) | |
| 588 | entries, err := c.Store.EffectiveAccess(repo.ID) | |
| 589 | 589 | if err != nil { |
| 590 | 590 | return c.fail(protocol.ExitFailure, "%v", err) |
| 591 | 591 | } |
| 592 | 592 | type out struct { |
| 593 | User string `json:"user"` | |
| 594 | Role string `json:"role"` | |
| 593 | User string `json:"user"` | |
| 594 | Role string `json:"role"` | |
| 595 | Source string `json:"source"` | |
| 595 | 596 | } |
| 596 | 597 | var ds []out |
| 597 | 598 | for _, e := range entries { |
| 598 | ds = append(ds, out{e.Username, e.Role}) | |
| 599 | ds = append(ds, out{e.Username, e.Role, e.Source}) | |
| 599 | 600 | } |
| 600 | 601 | return c.emit(ds, func(w io.Writer) { |
| 601 | 602 | for _, d := range ds { |
| 602 | fmt.Fprintf(w, "%s\t%s\n", d.User, d.Role) | |
| 603 | fmt.Fprintf(w, "%s\t%s\tvia %s\n", d.User, d.Role, d.Source) | |
| 603 | 604 | } |
| 604 | 605 | }) |
| 605 | 606 | } |
internal/control/teams.go +4 −2
| @@ -58,7 +58,9 @@ func orgAdminRef(c *Ctx, name string) (store.Org, int) { | ||
| 58 | 58 | return org, -1 |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | // orgMemberRef resolves an org, requiring at least membership. | |
| 61 | // orgMemberRef resolves an org, requiring at least membership. An org's | |
| 62 | // existence and member list are public (org show answers anyone), so a | |
| 63 | // non-member is refused rather than told the org does not exist. | |
| 62 | 64 | func orgMemberRef(c *Ctx, name string) (store.Org, int) { |
| 63 | 65 | org, err := c.Store.OrgByName(name) |
| 64 | 66 | if err != nil { |
| @@ -69,7 +71,7 @@ func orgMemberRef(c *Ctx, name string) (store.Org, int) { | ||
| 69 | 71 | return org, c.fail(protocol.ExitFailure, "%v", err) |
| 70 | 72 | } |
| 71 | 73 | if role == "" { |
| 72 | return org, c.fail(protocol.ExitNotFound, "no organization %q", name) | |
| 74 | return org, c.fail(protocol.ExitDenied, "teams of %s are visible to its members", name) | |
| 73 | 75 | } |
| 74 | 76 | return org, -1 |
| 75 | 77 | } |
internal/store/effectiveaccess_test.go added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | // EffectiveAccess folds owner, org roles, team grants and direct grants | |
| 6 | // into one row per account carrying the highest role and its source | |
| 7 | // (#200). | |
| 8 | func TestEffectiveAccess(t *testing.T) { | |
| 9 | s := open(t) | |
| 10 | if err := s.MigrateUp(); err != nil { | |
| 11 | t.Fatal(err) | |
| 12 | } | |
| 13 | mk := func(name string) int64 { | |
| 14 | id, err := s.CreateUser(name, false) | |
| 15 | if err != nil { | |
| 16 | t.Fatal(err) | |
| 17 | } | |
| 18 | return id | |
| 19 | } | |
| 20 | alice, bob, carol, dave, eve := mk("alice"), mk("bob"), mk("carol"), mk("dave"), mk("eve") | |
| 21 | oid, err := s.CreateOrg("acme", alice) | |
| 22 | if err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | for _, u := range []int64{bob, carol, dave} { | |
| 26 | if err := s.SetOrgMember(oid, u, "member"); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | } | |
| 30 | if err := s.SetOrgMembersRole(oid, "read"); err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | repo, err := s.CreateRepo("org", oid, "core", "private") | |
| 34 | if err != nil { | |
| 35 | t.Fatal(err) | |
| 36 | } | |
| 37 | team, err := s.CreateTeam(oid, "core") | |
| 38 | if err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | if err := s.AddTeamMember(team, bob); err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | if err := s.GrantTeamRepo(team, repo, "write"); err != nil { | |
| 45 | t.Fatal(err) | |
| 46 | } | |
| 47 | if err := s.GrantAccess(repo, carol, "admin"); err != nil { | |
| 48 | t.Fatal(err) | |
| 49 | } | |
| 50 | _ = eve | |
| 51 | ||
| 52 | got, err := s.EffectiveAccess(repo) | |
| 53 | if err != nil { | |
| 54 | t.Fatal(err) | |
| 55 | } | |
| 56 | want := []EffectiveEntry{ | |
| 57 | {"alice", "admin", "org admin"}, | |
| 58 | {"bob", "write", "team core"}, | |
| 59 | {"carol", "admin", "direct"}, | |
| 60 | {"dave", "read", "org member"}, | |
| 61 | } | |
| 62 | if len(got) != len(want) { | |
| 63 | t.Fatalf("got %v, want %v", got, want) | |
| 64 | } | |
| 65 | for i := range want { | |
| 66 | if got[i] != want[i] { | |
| 67 | t.Errorf("row %d: got %v, want %v", i, got[i], want[i]) | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | // A user-owned repository: the owner, and nobody else. | |
| 72 | own, err := s.CreateRepo("user", eve, "mine", "public") | |
| 73 | if err != nil { | |
| 74 | t.Fatal(err) | |
| 75 | } | |
| 76 | got, err = s.EffectiveAccess(own) | |
| 77 | if err != nil { | |
| 78 | t.Fatal(err) | |
| 79 | } | |
| 80 | if len(got) != 1 || got[0] != (EffectiveEntry{"eve", "admin", "owner"}) { | |
| 81 | t.Fatalf("owner row: %v", got) | |
| 82 | } | |
| 83 | } | |
internal/store/repos.go +80 −16
| @@ -6,6 +6,7 @@ import ( | ||
| 6 | 6 | "encoding/json" |
| 7 | 7 | "errors" |
| 8 | 8 | "fmt" |
| 9 | "sort" | |
| 9 | 10 | "strings" |
| 10 | 11 | ) |
| 11 | 12 | |
| @@ -305,29 +306,92 @@ func (s *Store) RevokeAccess(repoID, userID int64) error { | ||
| 305 | 306 | return nil |
| 306 | 307 | } |
| 307 | 308 | |
| 308 | type AccessEntry struct { | |
| 309 | // EffectiveEntry is one account's effective role on a repository and the | |
| 310 | // grant it comes from: owner, direct, org admin, org member, or team | |
| 311 | // <name>. | |
| 312 | type EffectiveEntry struct { | |
| 309 | 313 | Username string |
| 310 | 314 | Role string |
| 315 | Source string | |
| 311 | 316 | } |
| 312 | 317 | |
| 313 | func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) { | |
| 314 | rows, err := s.DB.Query(` | |
| 315 | SELECT u.username, a.role FROM repo_access a | |
| 316 | JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id | |
| 317 | WHERE a.repo_id = ? ORDER BY u.username`, repoID) | |
| 318 | if err != nil { | |
| 319 | return nil, err | |
| 318 | // EffectiveAccess lists every account that can reach a repository with | |
| 319 | // the highest role it holds and where that role comes from. Direct | |
| 320 | // grants, org roles and team grants are folded together the way | |
| 321 | // AccessRole folds them for one account. | |
| 322 | func (s *Store) EffectiveAccess(repoID int64) ([]EffectiveEntry, error) { | |
| 323 | rank := map[string]int{"read": 1, "write": 2, "admin": 3} | |
| 324 | best := map[string]EffectiveEntry{} | |
| 325 | var order []string | |
| 326 | add := func(user, role, source string) { | |
| 327 | if rank[role] == 0 { | |
| 328 | return | |
| 329 | } | |
| 330 | cur, ok := best[user] | |
| 331 | if !ok { | |
| 332 | order = append(order, user) | |
| 333 | } | |
| 334 | if !ok || rank[role] > rank[cur.Role] { | |
| 335 | best[user] = EffectiveEntry{user, role, source} | |
| 336 | } | |
| 320 | 337 | } |
| 321 | defer rows.Close() | |
| 322 | var out []AccessEntry | |
| 323 | for rows.Next() { | |
| 324 | var e AccessEntry | |
| 325 | if err := rows.Scan(&e.Username, &e.Role); err != nil { | |
| 326 | return nil, err | |
| 338 | collect := func(query string, source func(extra string) string, args ...any) error { | |
| 339 | rows, err := s.DB.Query(query, args...) | |
| 340 | if err != nil { | |
| 341 | return err | |
| 327 | 342 | } |
| 328 | out = append(out, e) | |
| 343 | defer rows.Close() | |
| 344 | for rows.Next() { | |
| 345 | var user, role, extra string | |
| 346 | if err := rows.Scan(&user, &role, &extra); err != nil { | |
| 347 | return err | |
| 348 | } | |
| 349 | add(user, role, source(extra)) | |
| 350 | } | |
| 351 | return rows.Err() | |
| 329 | 352 | } |
| 330 | return out, rows.Err() | |
| 353 | fixed := func(name string) func(string) string { return func(string) string { return name } } | |
| 354 | ||
| 355 | // The owner: a user outright, or the org's admins and members. | |
| 356 | if err := collect(` | |
| 357 | SELECT u.username, 'admin', '' FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 358 | WHERE r.id = ?`, fixed("owner"), repoID); err != nil { | |
| 359 | return nil, err | |
| 360 | } | |
| 361 | if err := collect(` | |
| 362 | SELECT u.username, 'admin', '' FROM repos r | |
| 363 | JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.role = 'admin' | |
| 364 | JOIN users u ON u.id = m.user_id WHERE r.id = ?`, fixed("org admin"), repoID); err != nil { | |
| 365 | return nil, err | |
| 366 | } | |
| 367 | if err := collect(` | |
| 368 | SELECT u.username, a.role, '' FROM repo_access a | |
| 369 | JOIN users u ON a.subject_kind = 'user' AND u.id = a.subject_id WHERE a.repo_id = ?`, | |
| 370 | fixed("direct"), repoID); err != nil { | |
| 371 | return nil, err | |
| 372 | } | |
| 373 | if err := collect(` | |
| 374 | SELECT u.username, tr.role, t.name FROM team_repos tr | |
| 375 | JOIN teams t ON t.id = tr.team_id | |
| 376 | JOIN team_members tm ON tm.team_id = tr.team_id | |
| 377 | JOIN users u ON u.id = tm.user_id WHERE tr.repo_id = ? | |
| 378 | ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC, t.name`, | |
| 379 | func(team string) string { return "team " + team }, repoID); err != nil { | |
| 380 | return nil, err | |
| 381 | } | |
| 382 | if err := collect(` | |
| 383 | SELECT u.username, o.members_role, '' FROM repos r | |
| 384 | JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 385 | JOIN org_members m ON m.org_id = o.id AND m.role = 'member' | |
| 386 | JOIN users u ON u.id = m.user_id WHERE r.id = ?`, fixed("org member"), repoID); err != nil { | |
| 387 | return nil, err | |
| 388 | } | |
| 389 | sort.Strings(order) | |
| 390 | out := make([]EffectiveEntry, 0, len(order)) | |
| 391 | for _, u := range order { | |
| 392 | out = append(out, best[u]) | |
| 393 | } | |
| 394 | return out, nil | |
| 331 | 395 | } |
| 332 | 396 | |
| 333 | 397 | func (s *Store) RepoByID(id int64) (Repo, error) { |