Commit 6e33f61b02
Verified · cmc
cmd/gitbay/main.go +13
| @@ -406,6 +406,19 @@ func orgCmd() *cobra.Command { | ||
| 406 | 406 | pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), |
| 407 | 407 | pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}), |
| 408 | 408 | ), |
| 409 | group("team", "scope repository access with teams", | |
| 410 | pass("create", "create a team: <org> <team>", passOpts{server: []string{"org", "team", "create"}}), | |
| 411 | pass("delete", "delete a team: <org> <team>", passOpts{server: []string{"org", "team", "delete"}}), | |
| 412 | pass("list", "list teams: <org>", passOpts{server: []string{"org", "team", "list"}}), | |
| 413 | pass("show", "show members and grants: <org> <team>", passOpts{server: []string{"org", "team", "show"}}), | |
| 414 | pass("add", "add org members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "add"}}), | |
| 415 | pass("remove", "remove members: <org> <team> <user>...", passOpts{server: []string{"org", "team", "remove"}}), | |
| 416 | pass("grant", "grant a repo role: <org> <team> <owner/name> read|write|admin", passOpts{server: []string{"org", "team", "grant"}}), | |
| 417 | pass("revoke", "revoke a repo grant: <org> <team> <owner/name>", passOpts{server: []string{"org", "team", "revoke"}}), | |
| 418 | ), | |
| 419 | group("settings", "organization settings", | |
| 420 | pass("members-role", "role plain membership implies: <org> write|read|none", passOpts{server: []string{"org", "settings", "members-role"}}), | |
| 421 | ), | |
| 409 | 422 | ) |
| 410 | 423 | } |
| 411 | 424 | |
docs/users.org +15 −2
| @@ -159,8 +159,8 @@ gitbay repo mirror sync / remove <id> | ||
| 159 | 159 | * Organizations |
| 160 | 160 | |
| 161 | 161 | Orgs share the owner namespace with users and own repositories at |
| 162 | =org/repo=. Members get write on all org repos; org admins get repo | |
| 163 | admin, create repos under the org, and manage membership. | |
| 162 | =org/repo=. By default members get write on all org repos; org admins | |
| 163 | get repo admin, create repos under the org, and manage membership. | |
| 164 | 164 | |
| 165 | 165 | #+begin_src sh |
| 166 | 166 | gitbay org create krz |
| @@ -170,6 +170,19 @@ gitbay org rename krz newname # clone URLs change | ||
| 170 | 170 | gitbay org delete krz --yes # only when it owns no repositories |
| 171 | 171 | #+end_src |
| 172 | 172 | |
| 173 | Large orgs scope access with teams: set what plain membership implies, | |
| 174 | then grant per-repo roles through named teams (org admins always keep | |
| 175 | admin; the default =write= keeps the simple model): | |
| 176 | ||
| 177 | #+begin_src sh | |
| 178 | gitbay org settings members-role krz none # write | read | none | |
| 179 | gitbay org team create krz core-devs | |
| 180 | gitbay org team add krz core-devs alice bob # org members only | |
| 181 | gitbay org team grant krz core-devs krz/gitbay write | |
| 182 | gitbay org team show krz core-devs # members + grants | |
| 183 | gitbay org team revoke / remove / delete ... | |
| 184 | #+end_src | |
| 185 | ||
| 173 | 186 | * Issues |
| 174 | 187 | |
| 175 | 188 | Anyone who can read a repository can file and comment. Closing/reopening |
e2e/teams_test.go added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | func TestOrgTeams(t *testing.T) { | |
| 9 | inst := startInstance(t) | |
| 10 | adminKey := inst.newKey(t, "alice") | |
| 11 | bobKey := inst.newKey(t, "bob") | |
| 12 | carolKey := inst.newKey(t, "carol") | |
| 13 | eveKey := inst.newKey(t, "eve") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", "--key", adminKey+".pub") | |
| 15 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 16 | inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub") | |
| 17 | inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") | |
| 18 | ||
| 19 | // Org with two private repos; bob and carol are plain members. | |
| 20 | for _, args := range [][]string{ | |
| 21 | {"org", "create", "acme"}, | |
| 22 | {"org", "members", "add", "acme", "bob"}, | |
| 23 | {"org", "members", "add", "acme", "carol"}, | |
| 24 | {"repo", "create", "acme/core", "--private"}, | |
| 25 | {"repo", "create", "acme/site", "--private"}, | |
| 26 | } { | |
| 27 | if _, errOut, code := inst.ssh(t, adminKey, "", args...); code != 0 { | |
| 28 | t.Fatalf("%v: %s", args, errOut) | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | // Degenerate case: plain membership implies write everywhere. | |
| 33 | if _, _, code := inst.ssh(t, bobKey, "", "issue", "create", "acme/core", "--title", "'pre'"); code != 0 { | |
| 34 | t.Fatal("member write lost (degenerate case broken)") | |
| 35 | } | |
| 36 | ||
| 37 | // Scope the org: members get nothing by default, teams grant. | |
| 38 | if _, _, code := inst.ssh(t, bobKey, "", "org", "settings", "members-role", "acme", "none"); code != 4 { | |
| 39 | t.Fatal("non-admin changed members-role") | |
| 40 | } | |
| 41 | if _, _, code := inst.ssh(t, adminKey, "", "org", "settings", "members-role", "acme", "none"); code != 0 { | |
| 42 | t.Fatal("members-role failed") | |
| 43 | } | |
| 44 | // bob now cannot even see the private repo. | |
| 45 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/core"); code != 3 { | |
| 46 | t.Fatal("scoped member still sees private repo") | |
| 47 | } | |
| 48 | ||
| 49 | // Team "core-devs": bob gets write on core, read on site. | |
| 50 | if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "create", "acme", "core-devs"); code != 0 { | |
| 51 | t.Fatal("team create failed") | |
| 52 | } | |
| 53 | if _, errOut, code := inst.ssh(t, adminKey, "", "org", "team", "add", "acme", "core-devs", "eve"); code != 2 || !strings.Contains(errOut, "not a member") { | |
| 54 | t.Fatalf("non-member added to team: %d %s", code, errOut) | |
| 55 | } | |
| 56 | if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "add", "acme", "core-devs", "bob"); code != 0 { | |
| 57 | t.Fatal("team add failed") | |
| 58 | } | |
| 59 | if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "grant", "acme", "core-devs", "acme/core", "write"); code != 0 { | |
| 60 | t.Fatal("team grant failed") | |
| 61 | } | |
| 62 | if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "grant", "acme", "core-devs", "acme/site", "read"); code != 0 { | |
| 63 | t.Fatal("second grant failed") | |
| 64 | } | |
| 65 | // Grants are limited to the org's own repos. | |
| 66 | if _, _, code := inst.ssh(t, adminKey, "", "repo", "create", "alice/own"); code != 0 { | |
| 67 | t.Fatal("repo create failed") | |
| 68 | } | |
| 69 | if _, errOut, code := inst.ssh(t, adminKey, "", "org", "team", "grant", "acme", "core-devs", "alice/own", "read"); code != 2 || !strings.Contains(errOut, "own org") { | |
| 70 | t.Fatalf("cross-org grant allowed: %d %s", code, errOut) | |
| 71 | } | |
| 72 | ||
| 73 | // bob: write on core (can open issues), read-only on site (visible, | |
| 74 | // not writable). carol (no team): nothing. | |
| 75 | if _, _, code := inst.ssh(t, bobKey, "", "issue", "create", "acme/core", "--title", "'works'"); code != 0 { | |
| 76 | t.Fatal("team write not effective") | |
| 77 | } | |
| 78 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/site"); code != 0 { | |
| 79 | t.Fatal("team read not effective") | |
| 80 | } | |
| 81 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "protect", "acme/site", "main"); code != 4 { | |
| 82 | t.Fatal("read grant allowed admin action") | |
| 83 | } | |
| 84 | if _, _, code := inst.ssh(t, carolKey, "", "repo", "show", "acme/core"); code != 3 { | |
| 85 | t.Fatal("teamless member sees scoped repo") | |
| 86 | } | |
| 87 | out, _, _ := inst.ssh(t, bobKey, "", "repo", "list") | |
| 88 | if !strings.Contains(out, "acme/core") || !strings.Contains(out, "acme/site") { | |
| 89 | t.Fatalf("team repos missing from listing: %s", out) | |
| 90 | } | |
| 91 | ||
| 92 | // show reflects members and grants; member removal drops access. | |
| 93 | out, _, _ = inst.ssh(t, adminKey, "", "org", "team", "show", "acme", "core-devs", "--json") | |
| 94 | if !strings.Contains(out, `"members":["bob"]`) || !strings.Contains(out, `"repo":"acme/core","role":"write"`) { | |
| 95 | t.Fatalf("team show: %s", out) | |
| 96 | } | |
| 97 | if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "remove", "acme", "core-devs", "bob"); code != 0 { | |
| 98 | t.Fatal("team remove failed") | |
| 99 | } | |
| 100 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "show", "acme/core"); code != 3 { | |
| 101 | t.Fatal("removed member kept access") | |
| 102 | } | |
| 103 | ||
| 104 | // Deleting the team cascades its grants. | |
| 105 | inst.ssh(t, adminKey, "", "org", "team", "add", "acme", "core-devs", "carol") | |
| 106 | if _, _, code := inst.ssh(t, carolKey, "", "repo", "show", "acme/core"); code != 0 { | |
| 107 | t.Fatal("carol team access missing") | |
| 108 | } | |
| 109 | if _, _, code := inst.ssh(t, adminKey, "", "org", "team", "delete", "acme", "core-devs"); code != 0 { | |
| 110 | t.Fatal("team delete failed") | |
| 111 | } | |
| 112 | if _, _, code := inst.ssh(t, carolKey, "", "repo", "show", "acme/core"); code != 3 { | |
| 113 | t.Fatal("deleted team's grant survived") | |
| 114 | } | |
| 115 | // Org admins keep admin regardless of scoping. | |
| 116 | if _, _, code := inst.ssh(t, adminKey, "", "repo", "settings", "protect", "acme/core", "main"); code != 0 { | |
| 117 | t.Fatal("org admin lost access") | |
| 118 | } | |
| 119 | } | |
internal/control/teams.go added +288
| @@ -0,0 +1,288 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "fmt" | |
| 6 | "io" | |
| 7 | "slices" | |
| 8 | "strings" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/policy" | |
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | func init() { | |
| 16 | register(Command{Path: []string{"org", "team", "create"}, | |
| 17 | Summary: "create a team: org team create <org> <team>", Run: runTeamCreate}) | |
| 18 | register(Command{Path: []string{"org", "team", "delete"}, | |
| 19 | Summary: "delete a team (its grants with it): org team delete <org> <team>", Run: runTeamDelete}) | |
| 20 | register(Command{Path: []string{"org", "team", "list"}, | |
| 21 | Summary: "list an org's teams: org team list <org>", ReadOnly: true, Run: runTeamList}) | |
| 22 | register(Command{Path: []string{"org", "team", "show"}, | |
| 23 | Summary: "show a team's members and grants: org team show <org> <team>", ReadOnly: true, Run: runTeamShow}) | |
| 24 | register(Command{Path: []string{"org", "team", "add"}, | |
| 25 | Summary: "add org members to a team: org team add <org> <team> <user>...", Run: runTeamAdd}) | |
| 26 | register(Command{Path: []string{"org", "team", "remove"}, | |
| 27 | Summary: "remove members from a team: org team remove <org> <team> <user>...", Run: runTeamRemove}) | |
| 28 | register(Command{Path: []string{"org", "team", "grant"}, | |
| 29 | Summary: "grant a team a role on an org repo: org team grant <org> <team> <owner/name> read|write|admin", Run: runTeamGrant}) | |
| 30 | register(Command{Path: []string{"org", "team", "revoke"}, | |
| 31 | Summary: "revoke a team's grant: org team revoke <org> <team> <owner/name>", Run: runTeamRevoke}) | |
| 32 | register(Command{Path: []string{"org", "settings", "members-role"}, | |
| 33 | Summary: "role plain membership implies on every org repo: org settings members-role <org> write|read|none (default write)", Run: runOrgMembersRole}) | |
| 34 | } | |
| 35 | ||
| 36 | // orgAdminRef resolves an org and requires the caller to admin it. | |
| 37 | func orgAdminRef(c *Ctx, name string) (store.Org, int) { | |
| 38 | org, err := c.Store.OrgByName(name) | |
| 39 | if err != nil { | |
| 40 | return org, c.fail(protocol.ExitNotFound, "no organization %q", name) | |
| 41 | } | |
| 42 | role, err := c.Store.OrgRole(org.ID, c.User.ID) | |
| 43 | if err != nil { | |
| 44 | return org, c.fail(protocol.ExitFailure, "%v", err) | |
| 45 | } | |
| 46 | if role != "admin" { | |
| 47 | return org, c.fail(protocol.ExitDenied, "only admins of %s can manage teams", name) | |
| 48 | } | |
| 49 | return org, -1 | |
| 50 | } | |
| 51 | ||
| 52 | // orgMemberRef resolves an org, requiring at least membership. | |
| 53 | func orgMemberRef(c *Ctx, name string) (store.Org, int) { | |
| 54 | org, err := c.Store.OrgByName(name) | |
| 55 | if err != nil { | |
| 56 | return org, c.fail(protocol.ExitNotFound, "no organization %q", name) | |
| 57 | } | |
| 58 | role, err := c.Store.OrgRole(org.ID, c.User.ID) | |
| 59 | if err != nil { | |
| 60 | return org, c.fail(protocol.ExitFailure, "%v", err) | |
| 61 | } | |
| 62 | if role == "" { | |
| 63 | return org, c.fail(protocol.ExitNotFound, "no organization %q", name) | |
| 64 | } | |
| 65 | return org, -1 | |
| 66 | } | |
| 67 | ||
| 68 | func teamRef(c *Ctx, org store.Org, name string) (store.Team, int) { | |
| 69 | team, err := c.Store.TeamByName(org.ID, name) | |
| 70 | if errors.Is(err, store.ErrNotFound) { | |
| 71 | return team, c.fail(protocol.ExitNotFound, "no team %q in %s", name, org.Name) | |
| 72 | } | |
| 73 | if err != nil { | |
| 74 | return team, c.fail(protocol.ExitFailure, "%v", err) | |
| 75 | } | |
| 76 | return team, -1 | |
| 77 | } | |
| 78 | ||
| 79 | func runTeamCreate(c *Ctx, args []string) int { | |
| 80 | if len(args) != 2 { | |
| 81 | return c.fail(protocol.ExitUsage, "usage: org team create <org> <team>") | |
| 82 | } | |
| 83 | org, code := orgAdminRef(c, args[0]) | |
| 84 | if code >= 0 { | |
| 85 | return code | |
| 86 | } | |
| 87 | if err := policy.ValidateName(args[1]); err != nil { | |
| 88 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 89 | } | |
| 90 | if _, err := c.Store.CreateTeam(org.ID, args[1]); err != nil { | |
| 91 | return c.fail(protocol.ExitUsage, "%v", err) | |
| 92 | } | |
| 93 | return c.emit(map[string]string{"team": args[1]}, func(w io.Writer) { | |
| 94 | fmt.Fprintf(w, "created team %s/%s\n", org.Name, args[1]) | |
| 95 | }) | |
| 96 | } | |
| 97 | ||
| 98 | func runTeamDelete(c *Ctx, args []string) int { | |
| 99 | if len(args) != 2 { | |
| 100 | return c.fail(protocol.ExitUsage, "usage: org team delete <org> <team>") | |
| 101 | } | |
| 102 | org, code := orgAdminRef(c, args[0]) | |
| 103 | if code >= 0 { | |
| 104 | return code | |
| 105 | } | |
| 106 | team, code := teamRef(c, org, args[1]) | |
| 107 | if code >= 0 { | |
| 108 | return code | |
| 109 | } | |
| 110 | if err := c.Store.DeleteTeam(team.ID); err != nil { | |
| 111 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 112 | } | |
| 113 | return c.emit(map[string]string{"deleted": team.Name}, func(w io.Writer) { | |
| 114 | fmt.Fprintf(w, "deleted team %s/%s\n", org.Name, team.Name) | |
| 115 | }) | |
| 116 | } | |
| 117 | ||
| 118 | func runTeamList(c *Ctx, args []string) int { | |
| 119 | if len(args) != 1 { | |
| 120 | return c.fail(protocol.ExitUsage, "usage: org team list <org>") | |
| 121 | } | |
| 122 | org, code := orgMemberRef(c, args[0]) | |
| 123 | if code >= 0 { | |
| 124 | return code | |
| 125 | } | |
| 126 | teams, err := c.Store.ListTeams(org.ID) | |
| 127 | if err != nil { | |
| 128 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 129 | } | |
| 130 | var names []string | |
| 131 | for _, t := range teams { | |
| 132 | names = append(names, t.Name) | |
| 133 | } | |
| 134 | return c.emit(names, func(w io.Writer) { | |
| 135 | for _, n := range names { | |
| 136 | fmt.Fprintln(w, n) | |
| 137 | } | |
| 138 | }) | |
| 139 | } | |
| 140 | ||
| 141 | func runTeamShow(c *Ctx, args []string) int { | |
| 142 | if len(args) != 2 { | |
| 143 | return c.fail(protocol.ExitUsage, "usage: org team show <org> <team>") | |
| 144 | } | |
| 145 | org, code := orgMemberRef(c, args[0]) | |
| 146 | if code >= 0 { | |
| 147 | return code | |
| 148 | } | |
| 149 | team, code := teamRef(c, org, args[1]) | |
| 150 | if code >= 0 { | |
| 151 | return code | |
| 152 | } | |
| 153 | members, err := c.Store.TeamMembers(team.ID) | |
| 154 | if err != nil { | |
| 155 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 156 | } | |
| 157 | grants, err := c.Store.TeamGrants(team.ID) | |
| 158 | if err != nil { | |
| 159 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 160 | } | |
| 161 | d := struct { | |
| 162 | Team string `json:"team"` | |
| 163 | Members []string `json:"members,omitempty"` | |
| 164 | Grants []store.TeamGrant `json:"grants,omitempty"` | |
| 165 | }{team.Name, members, grants} | |
| 166 | return c.emit(d, func(w io.Writer) { | |
| 167 | fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", ")) | |
| 168 | for _, g := range grants { | |
| 169 | fmt.Fprintf(w, "%s\t%s\n", g.RepoPath, g.Role) | |
| 170 | } | |
| 171 | }) | |
| 172 | } | |
| 173 | ||
| 174 | func runTeamAdd(c *Ctx, args []string) int { return editTeamMembers(c, args, true) } | |
| 175 | func runTeamRemove(c *Ctx, args []string) int { return editTeamMembers(c, args, false) } | |
| 176 | ||
| 177 | func editTeamMembers(c *Ctx, args []string, add bool) int { | |
| 178 | verb := "add" | |
| 179 | if !add { | |
| 180 | verb = "remove" | |
| 181 | } | |
| 182 | if len(args) < 3 { | |
| 183 | return c.fail(protocol.ExitUsage, "usage: org team %s <org> <team> <user>...", verb) | |
| 184 | } | |
| 185 | org, code := orgAdminRef(c, args[0]) | |
| 186 | if code >= 0 { | |
| 187 | return code | |
| 188 | } | |
| 189 | team, code := teamRef(c, org, args[1]) | |
| 190 | if code >= 0 { | |
| 191 | return code | |
| 192 | } | |
| 193 | for _, name := range args[2:] { | |
| 194 | u, err := c.Store.UserByUsername(name) | |
| 195 | if err != nil { | |
| 196 | return c.fail(protocol.ExitNotFound, "no such user %q", name) | |
| 197 | } | |
| 198 | if add { | |
| 199 | // Teams organize existing members; they do not grant | |
| 200 | // membership by side effect. | |
| 201 | role, err := c.Store.OrgRole(org.ID, u.ID) | |
| 202 | if err != nil { | |
| 203 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 204 | } | |
| 205 | if role == "" { | |
| 206 | return c.fail(protocol.ExitUsage, "%s is not a member of %s — add them to the org first", name, org.Name) | |
| 207 | } | |
| 208 | if err := c.Store.AddTeamMember(team.ID, u.ID); err != nil { | |
| 209 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 210 | } | |
| 211 | } else if err := c.Store.RemoveTeamMember(team.ID, u.ID); err != nil { | |
| 212 | if errors.Is(err, store.ErrNotFound) { | |
| 213 | return c.fail(protocol.ExitNotFound, "%s is not in team %s", name, team.Name) | |
| 214 | } | |
| 215 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 216 | } | |
| 217 | } | |
| 218 | return c.emit(map[string]any{"team": team.Name, verb: args[2:]}, func(w io.Writer) { | |
| 219 | fmt.Fprintf(w, "%sed %s on team %s/%s\n", verb, strings.Join(args[2:], ", "), org.Name, team.Name) | |
| 220 | }) | |
| 221 | } | |
| 222 | ||
| 223 | func runTeamGrant(c *Ctx, args []string) int { | |
| 224 | if len(args) != 4 || !slices.Contains([]string{"read", "write", "admin"}, args[3]) { | |
| 225 | return c.fail(protocol.ExitUsage, "usage: org team grant <org> <team> <owner/name> read|write|admin") | |
| 226 | } | |
| 227 | org, code := orgAdminRef(c, args[0]) | |
| 228 | if code >= 0 { | |
| 229 | return code | |
| 230 | } | |
| 231 | team, code := teamRef(c, org, args[1]) | |
| 232 | if code >= 0 { | |
| 233 | return code | |
| 234 | } | |
| 235 | repo, err := c.Store.RepoByPath(args[2]) | |
| 236 | if err != nil || repo.OwnerKind != "org" || repo.OwnerID != org.ID { | |
| 237 | return c.fail(protocol.ExitUsage, "teams grant access to their own org's repositories; %q is not one", args[2]) | |
| 238 | } | |
| 239 | if err := c.Store.GrantTeamRepo(team.ID, repo.ID, args[3]); err != nil { | |
| 240 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 241 | } | |
| 242 | return c.emit(map[string]string{"team": team.Name, "repo": repo.Path(), "role": args[3]}, func(w io.Writer) { | |
| 243 | fmt.Fprintf(w, "granted %s to team %s on %s\n", args[3], team.Name, repo.Path()) | |
| 244 | }) | |
| 245 | } | |
| 246 | ||
| 247 | func runTeamRevoke(c *Ctx, args []string) int { | |
| 248 | if len(args) != 3 { | |
| 249 | return c.fail(protocol.ExitUsage, "usage: org team revoke <org> <team> <owner/name>") | |
| 250 | } | |
| 251 | org, code := orgAdminRef(c, args[0]) | |
| 252 | if code >= 0 { | |
| 253 | return code | |
| 254 | } | |
| 255 | team, code := teamRef(c, org, args[1]) | |
| 256 | if code >= 0 { | |
| 257 | return code | |
| 258 | } | |
| 259 | repo, err := c.Store.RepoByPath(args[2]) | |
| 260 | if err != nil { | |
| 261 | return c.fail(protocol.ExitNotFound, "repository %s not found", args[2]) | |
| 262 | } | |
| 263 | if err := c.Store.RevokeTeamRepo(team.ID, repo.ID); err != nil { | |
| 264 | if errors.Is(err, store.ErrNotFound) { | |
| 265 | return c.fail(protocol.ExitNotFound, "team %s has no grant on %s", team.Name, repo.Path()) | |
| 266 | } | |
| 267 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 268 | } | |
| 269 | return c.emit(map[string]string{"revoked": repo.Path()}, func(w io.Writer) { | |
| 270 | fmt.Fprintf(w, "revoked team %s on %s\n", team.Name, repo.Path()) | |
| 271 | }) | |
| 272 | } | |
| 273 | ||
| 274 | func runOrgMembersRole(c *Ctx, args []string) int { | |
| 275 | if len(args) != 2 || !slices.Contains([]string{"write", "read", "none"}, args[1]) { | |
| 276 | return c.fail(protocol.ExitUsage, "usage: org settings members-role <org> write|read|none") | |
| 277 | } | |
| 278 | org, code := orgAdminRef(c, args[0]) | |
| 279 | if code >= 0 { | |
| 280 | return code | |
| 281 | } | |
| 282 | if err := c.Store.SetOrgMembersRole(org.ID, args[1]); err != nil { | |
| 283 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 284 | } | |
| 285 | return c.emit(map[string]string{"members_role": args[1]}, func(w io.Writer) { | |
| 286 | fmt.Fprintf(w, "plain members of %s now get %q on org repositories\n", org.Name, args[1]) | |
| 287 | }) | |
| 288 | } | |
internal/store/dashboard.go +6 −1
| @@ -18,7 +18,12 @@ const involvedCond = `( | ||
| 18 | 18 | OR EXISTS (SELECT 1 FROM repo_access a |
| 19 | 19 | WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1) |
| 20 | 20 | OR EXISTS (SELECT 1 FROM org_members mm |
| 21 | WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1) | |
| 21 | JOIN orgs oo ON oo.id = mm.org_id | |
| 22 | WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1 | |
| 23 | AND (mm.role = 'admin' OR oo.members_role <> 'none')) | |
| 24 | OR EXISTS (SELECT 1 FROM team_repos tr | |
| 25 | JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?1 | |
| 26 | WHERE tr.repo_id = r.id) | |
| 22 | 27 | )` |
| 23 | 28 | |
| 24 | 29 | func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) { |
internal/store/migrations/0020_teams.down.sql added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | DROP TABLE team_repos; | |
| 2 | DROP TABLE team_members; | |
| 3 | DROP TABLE teams; | |
| 4 | ALTER TABLE orgs DROP COLUMN members_role; | |
internal/store/migrations/0020_teams.up.sql added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | - Teams scope repository access inside an organization. The pre-teams | |
| 2 | - model (every member writes every org repo) survives as the default via | |
| 3 | - orgs.members_role = 'write'; large orgs set it to 'read' or 'none' and | |
| 4 | - grant through teams instead. | |
| 5 | ALTER TABLE orgs ADD COLUMN members_role TEXT NOT NULL DEFAULT 'write' | |
| 6 | CHECK (members_role IN ('write', 'read', 'none')); | |
| 7 | ||
| 8 | CREATE TABLE teams ( | |
| 9 | id INTEGER PRIMARY KEY, | |
| 10 | org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, | |
| 11 | name TEXT NOT NULL, | |
| 12 | UNIQUE (org_id, name) | |
| 13 | ); | |
| 14 | CREATE TABLE team_members ( | |
| 15 | team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 16 | user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 17 | PRIMARY KEY (team_id, user_id) | |
| 18 | ); | |
| 19 | CREATE TABLE team_repos ( | |
| 20 | team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 21 | repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 22 | role TEXT NOT NULL CHECK (role IN ('read', 'write', 'admin')), | |
| 23 | PRIMARY KEY (team_id, repo_id) | |
| 24 | ); | |
| 25 | CREATE INDEX team_repos_repo ON team_repos(repo_id); | |
internal/store/repos.go +42 −16
| @@ -106,15 +106,22 @@ func (s *Store) DeleteRepo(repoID int64) error { | ||
| 106 | 106 | return nil |
| 107 | 107 | } |
| 108 | 108 | |
| 109 | // ListReposForUser returns repos the user owns, belongs to through an org, | |
| 110 | // or has an explicit grant on. | |
| 109 | // ListReposForUser returns repos the user owns, reaches through an org | |
| 110 | // (unless the org scopes members to 'none'), has an explicit grant on, or | |
| 111 | // reaches through a team. | |
| 111 | 112 | func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { |
| 112 | 113 | rows, err := s.DB.Query(repoSelect+` |
| 113 | 114 | LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? |
| 114 | 115 | LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? |
| 115 | WHERE (r.owner_kind = 'user' AND r.owner_id = ?) OR a.subject_id IS NOT NULL OR m.user_id IS NOT NULL | |
| 116 | LEFT JOIN orgs og ON r.owner_kind = 'org' AND og.id = r.owner_id | |
| 117 | WHERE (r.owner_kind = 'user' AND r.owner_id = ?) | |
| 118 | OR a.subject_id IS NOT NULL | |
| 119 | OR (m.user_id IS NOT NULL AND (m.role = 'admin' OR og.members_role <> 'none')) | |
| 120 | OR EXISTS (SELECT 1 FROM team_repos tr | |
| 121 | JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ? | |
| 122 | WHERE tr.repo_id = r.id) | |
| 116 | 123 | GROUP BY r.id |
| 117 | ORDER BY 4, r.name`, userID, userID, userID) | |
| 124 | ORDER BY 4, r.name`, userID, userID, userID, userID) | |
| 118 | 125 | if err != nil { |
| 119 | 126 | return nil, err |
| 120 | 127 | } |
| @@ -131,11 +138,18 @@ func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { | ||
| 131 | 138 | } |
| 132 | 139 | |
| 133 | 140 | // AccessRole returns the user's effective role on the repo ("" if none): |
| 134 | // the strongest of any explicit grant and, for org-owned repos, the role | |
| 135 | // derived from org membership (org admin -> admin, org member -> write). | |
| 141 | // the strongest of any explicit grant, the role derived from org | |
| 142 | // membership (org admin -> admin; plain member -> the org's members_role, | |
| 143 | // 'write' by default so the pre-teams model is the degenerate case), and | |
| 144 | // any team grants on the repo. | |
| 136 | 145 | func (s *Store) AccessRole(repoID, userID int64) (string, error) { |
| 137 | rank := map[string]int{"": 0, "read": 1, "write": 2, "admin": 3} | |
| 146 | rank := map[string]int{"": 0, "none": 0, "read": 1, "write": 2, "admin": 3} | |
| 138 | 147 | best := "" |
| 148 | better := func(role string) { | |
| 149 | if rank[role] > rank[best] { | |
| 150 | best = role | |
| 151 | } | |
| 152 | } | |
| 139 | 153 | |
| 140 | 154 | var explicit string |
| 141 | 155 | err := s.DB.QueryRow( |
| @@ -144,22 +158,34 @@ func (s *Store) AccessRole(repoID, userID int64) (string, error) { | ||
| 144 | 158 | if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 145 | 159 | return "", err |
| 146 | 160 | } |
| 147 | if rank[explicit] > rank[best] { | |
| 148 | best = explicit | |
| 149 | } | |
| 161 | better(explicit) | |
| 150 | 162 | |
| 151 | var orgRole string | |
| 163 | var orgRole, membersRole string | |
| 152 | 164 | err = s.DB.QueryRow(` |
| 153 | SELECT m.role FROM repos r | |
| 165 | SELECT m.role, o.members_role FROM repos r | |
| 154 | 166 | JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? |
| 155 | WHERE r.id = ?`, userID, repoID).Scan(&orgRole) | |
| 167 | JOIN orgs o ON o.id = r.owner_id | |
| 168 | WHERE r.id = ?`, userID, repoID).Scan(&orgRole, &membersRole) | |
| 156 | 169 | if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 157 | 170 | return "", err |
| 158 | 171 | } |
| 159 | derived := map[string]string{"admin": "admin", "member": "write"}[orgRole] | |
| 160 | if rank[derived] > rank[best] { | |
| 161 | best = derived | |
| 172 | if orgRole == "admin" { | |
| 173 | better("admin") | |
| 174 | } else if orgRole == "member" { | |
| 175 | better(membersRole) // write | read | none | |
| 176 | } | |
| 177 | ||
| 178 | var teamRole string | |
| 179 | err = s.DB.QueryRow(` | |
| 180 | SELECT tr.role FROM team_repos tr | |
| 181 | JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ? | |
| 182 | WHERE tr.repo_id = ? | |
| 183 | ORDER BY CASE tr.role WHEN 'admin' THEN 3 WHEN 'write' THEN 2 ELSE 1 END DESC | |
| 184 | LIMIT 1`, userID, repoID).Scan(&teamRole) | |
| 185 | if err != nil && !errors.Is(err, sql.ErrNoRows) { | |
| 186 | return "", err | |
| 162 | 187 | } |
| 188 | better(teamRole) | |
| 163 | 189 | return best, nil |
| 164 | 190 | } |
| 165 | 191 | |
internal/store/teams.go added +151
| @@ -0,0 +1,151 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | "fmt" | |
| 7 | ) | |
| 8 | ||
| 9 | type Team struct { | |
| 10 | ID int64 | |
| 11 | OrgID int64 | |
| 12 | Name string | |
| 13 | } | |
| 14 | ||
| 15 | func (s *Store) CreateTeam(orgID int64, name string) (int64, error) { | |
| 16 | res, err := s.DB.Exec("INSERT INTO teams (org_id, name) VALUES (?, ?)", orgID, name) | |
| 17 | if err != nil { | |
| 18 | if isUniqueErr(err) { | |
| 19 | return 0, fmt.Errorf("team %q already exists", name) | |
| 20 | } | |
| 21 | return 0, err | |
| 22 | } | |
| 23 | return res.LastInsertId() | |
| 24 | } | |
| 25 | ||
| 26 | func (s *Store) TeamByName(orgID int64, name string) (Team, error) { | |
| 27 | var t Team | |
| 28 | err := s.DB.QueryRow("SELECT id, org_id, name FROM teams WHERE org_id = ? AND name = ?", | |
| 29 | orgID, name).Scan(&t.ID, &t.OrgID, &t.Name) | |
| 30 | if errors.Is(err, sql.ErrNoRows) { | |
| 31 | return t, ErrNotFound | |
| 32 | } | |
| 33 | return t, err | |
| 34 | } | |
| 35 | ||
| 36 | func (s *Store) DeleteTeam(teamID int64) error { | |
| 37 | res, err := s.DB.Exec("DELETE FROM teams WHERE id = ?", teamID) | |
| 38 | if err != nil { | |
| 39 | return err | |
| 40 | } | |
| 41 | if n, _ := res.RowsAffected(); n == 0 { | |
| 42 | return ErrNotFound | |
| 43 | } | |
| 44 | return nil | |
| 45 | } | |
| 46 | ||
| 47 | func (s *Store) ListTeams(orgID int64) ([]Team, error) { | |
| 48 | rows, err := s.DB.Query("SELECT id, org_id, name FROM teams WHERE org_id = ? ORDER BY name", orgID) | |
| 49 | if err != nil { | |
| 50 | return nil, err | |
| 51 | } | |
| 52 | defer rows.Close() | |
| 53 | var out []Team | |
| 54 | for rows.Next() { | |
| 55 | var t Team | |
| 56 | if err := rows.Scan(&t.ID, &t.OrgID, &t.Name); err != nil { | |
| 57 | return nil, err | |
| 58 | } | |
| 59 | out = append(out, t) | |
| 60 | } | |
| 61 | return out, rows.Err() | |
| 62 | } | |
| 63 | ||
| 64 | func (s *Store) AddTeamMember(teamID, userID int64) error { | |
| 65 | _, err := s.DB.Exec( | |
| 66 | "INSERT INTO team_members (team_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", | |
| 67 | teamID, userID) | |
| 68 | return err | |
| 69 | } | |
| 70 | ||
| 71 | func (s *Store) RemoveTeamMember(teamID, userID int64) error { | |
| 72 | res, err := s.DB.Exec("DELETE FROM team_members WHERE team_id = ? AND user_id = ?", teamID, userID) | |
| 73 | if err != nil { | |
| 74 | return err | |
| 75 | } | |
| 76 | if n, _ := res.RowsAffected(); n == 0 { | |
| 77 | return ErrNotFound | |
| 78 | } | |
| 79 | return nil | |
| 80 | } | |
| 81 | ||
| 82 | func (s *Store) TeamMembers(teamID int64) ([]string, error) { | |
| 83 | rows, err := s.DB.Query(` | |
| 84 | SELECT u.username FROM team_members tm JOIN users u ON u.id = tm.user_id | |
| 85 | WHERE tm.team_id = ? ORDER BY u.username`, teamID) | |
| 86 | if err != nil { | |
| 87 | return nil, err | |
| 88 | } | |
| 89 | defer rows.Close() | |
| 90 | var out []string | |
| 91 | for rows.Next() { | |
| 92 | var n string | |
| 93 | if err := rows.Scan(&n); err != nil { | |
| 94 | return nil, err | |
| 95 | } | |
| 96 | out = append(out, n) | |
| 97 | } | |
| 98 | return out, rows.Err() | |
| 99 | } | |
| 100 | ||
| 101 | // GrantTeamRepo attaches (or updates) a team's role on a repo. | |
| 102 | func (s *Store) GrantTeamRepo(teamID, repoID int64, role string) error { | |
| 103 | _, err := s.DB.Exec(` | |
| 104 | INSERT INTO team_repos (team_id, repo_id, role) VALUES (?, ?, ?) | |
| 105 | ON CONFLICT (team_id, repo_id) DO UPDATE SET role = excluded.role`, | |
| 106 | teamID, repoID, role) | |
| 107 | return err | |
| 108 | } | |
| 109 | ||
| 110 | func (s *Store) RevokeTeamRepo(teamID, repoID int64) error { | |
| 111 | res, err := s.DB.Exec("DELETE FROM team_repos WHERE team_id = ? AND repo_id = ?", teamID, repoID) | |
| 112 | if err != nil { | |
| 113 | return err | |
| 114 | } | |
| 115 | if n, _ := res.RowsAffected(); n == 0 { | |
| 116 | return ErrNotFound | |
| 117 | } | |
| 118 | return nil | |
| 119 | } | |
| 120 | ||
| 121 | type TeamGrant struct { | |
| 122 | RepoPath string `json:"repo"` | |
| 123 | Role string `json:"role"` | |
| 124 | } | |
| 125 | ||
| 126 | func (s *Store) TeamGrants(teamID int64) ([]TeamGrant, error) { | |
| 127 | rows, err := s.DB.Query(` | |
| 128 | SELECT COALESCE(u.username, o.name) || '/' || r.name, tr.role | |
| 129 | FROM team_repos tr JOIN repos r ON r.id = tr.repo_id | |
| 130 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | |
| 131 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | |
| 132 | WHERE tr.team_id = ? ORDER BY 1`, teamID) | |
| 133 | if err != nil { | |
| 134 | return nil, err | |
| 135 | } | |
| 136 | defer rows.Close() | |
| 137 | var out []TeamGrant | |
| 138 | for rows.Next() { | |
| 139 | var g TeamGrant | |
| 140 | if err := rows.Scan(&g.RepoPath, &g.Role); err != nil { | |
| 141 | return nil, err | |
| 142 | } | |
| 143 | out = append(out, g) | |
| 144 | } | |
| 145 | return out, rows.Err() | |
| 146 | } | |
| 147 | ||
| 148 | func (s *Store) SetOrgMembersRole(orgID int64, role string) error { | |
| 149 | _, err := s.DB.Exec("UPDATE orgs SET members_role = ? WHERE id = ?", role, orgID) | |
| 150 | return err | |
| 151 | } | |