krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
08edfb55e9aaa928470ea444e141008827e77aa2
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T00:35:32Z
cmd/gitbay/main.go | 15 +++ e2e/org_test.go | 130 +++++++++++++++++++++++++ e2e/ssh_test.go | 11 +++ internal/control/org.go | 216 +++++++++++++++++++++++++++++++++++++++++ internal/control/repo.go | 20 +++- internal/store/orgs.go | 192 ++++++++++++++++++++++++++++++++++++ internal/store/registration.go | 5 + internal/store/repos.go | 121 ++++++++++++----------- internal/store/users.go | 5 + 9 files changed, 656 insertions(+), 59 deletions(-) @@ -30,6 +30,7 @@ func main() { issueCmd(), mrCmd(), webCmd(), + orgCmd(), remoteCmd(), initCmd(), pass("register", "create an account on the default instance: gitbay register --username <n> --email <a> | --invite <code>", @@ -287,6 +288,20 @@ func webCmd() *cobra.Command { ) } +func orgCmd() *cobra.Command { + return group("org", "organizations", + pass("create", "create an organization", passOpts{server: []string{"org", "create"}}), + pass("list", "list organizations you belong to", passOpts{server: []string{"org", "list"}}), + pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}), + pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}), + group("members", "manage members", + pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}), + pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}), + pass("list", "list members: <org>", passOpts{server: []string{"org", "members", "list"}}), + ), + ) +} + func remoteCmd() *cobra.Command { return group("remote", "local instance profiles (no server contact)", local("add", "add a named gitbay instance: gitbay remote add <name> <host> [--port n] [--user u] [--ssh-option o]... [--default]", new file mode 100644 @@ -0,0 +1,130 @@ +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOrganizations(t *testing.T) { + inst := startInstance(t) + aliceKey := inst.newKey(t, "alice") + bobKey := inst.newKey(t, "bob") + eveKey := inst.newKey(t, "eve") + inst.admin(t, "admin", "user", "create", "alice", + "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") + inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") + inst.admin(t, "admin", "user", "create", "eve", "--key", eveKey+".pub") + + // Alice creates an org; the namespace is shared with users. + if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "krz"); code != 0 { + t.Fatalf("org create: %s", errOut) + } + if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "bob"); code == 0 { + t.Fatal("org created with a user's name") + } + if out := inst.admin(t, "admin", "user", "create", "krz2", "--key", inst.newKey(t, "krz2")+".pub"); out == "" { + t.Fatal("control user create failed") + } + // A user cannot claim an org's name either. + cmd := inst.forgedAdminErr(t, "admin", "user", "create", "krz") + if !strings.Contains(cmd, "taken") { + t.Fatalf("user with org name: %s", cmd) + } + + // Only org admins create repos under the org. + if _, errOut, code := inst.ssh(t, bobKey, "", "repo", "create", "krz/lib"); code != 4 { + t.Fatalf("non-member org repo create: %d %s", code, errOut) + } + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "krz/lib", "--private"); code != 0 { + t.Fatalf("org repo create: %s", errOut) + } + + // Membership-derived access: bob (member) gets write, eve (outsider) + // sees nothing on the private repo. + if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "krz", "bob"); code != 0 { + t.Fatalf("members add: %s", errOut) + } + work := t.TempDir() + bobEnv := inst.gitEnv(bobKey) + mustGit(t, work, bobEnv, "clone", inst.sshURL("krz/lib"), "w") + dir := filepath.Join(work, "w") + os.WriteFile(filepath.Join(dir, "f.txt"), []byte("org work\n"), 0o644) + mustGit(t, dir, bobEnv, "checkout", "-q", "-b", "main") + mustGit(t, dir, bobEnv, "add", ".") + mustGit(t, dir, bobEnv, "commit", "-q", "-m", "bob pushes to org repo") + mustGit(t, dir, bobEnv, "push", "-q", "origin", "main") + + if out, code := gitRun(t, t.TempDir(), inst.gitEnv(eveKey), "clone", inst.sshURL("krz/lib")); code == 0 || !strings.Contains(out, "repository not found") { + t.Fatalf("outsider on private org repo: %d\n%s", code, out) + } + + // Members are not repo admins: bob cannot change settings or grant + // access; an org admin can. + if _, _, code := inst.ssh(t, bobKey, "", "repo", "settings", "protect", "krz/lib", "main"); code != 4 { + t.Fatal("member changed org repo settings") + } + if _, _, code := inst.ssh(t, bobKey, "", "org", "members", "add", "krz", "eve"); code != 4 { + t.Fatal("member managed org membership") + } + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "protect", "krz/lib", "main"); code != 0 { + t.Fatalf("org admin protect: %s", errOut) + } + + // Explicit per-repo grants still work alongside membership: eve gets + // read on the private org repo. + if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "krz/lib", "eve", "read"); code != 0 { + t.Fatalf("grant: %s", errOut) + } + mustGit(t, t.TempDir(), inst.gitEnv(eveKey), "clone", inst.sshURL("krz/lib")) + + // Org repos list for members; org shows in org list. + out, _, _ := inst.ssh(t, bobKey, "", "repo", "list") + if !strings.Contains(out, "krz/lib") { + t.Fatalf("member repo list missing org repo:\n%s", out) + } + out, _, _ = inst.ssh(t, bobKey, "", "org", "list") + if !strings.Contains(out, "krz\tmember") { + t.Fatalf("org list: %s", out) + } + + // Promotion works; the last admin is protected. + if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "krz", "bob", "--role", "admin"); code != 0 { + t.Fatalf("promote: %s", errOut) + } + if _, errOut, code := inst.ssh(t, bobKey, "", "org", "members", "remove", "krz", "alice"); code != 0 { + t.Fatalf("bob (now admin) removing alice: %s", errOut) + } + _, errOut, code := inst.ssh(t, bobKey, "", "org", "members", "remove", "krz", "bob") + if code != 2 || !strings.Contains(errOut, "at least one admin") { + t.Fatalf("last admin removal: %d %s", code, errOut) + } + + // Org deletion refuses while repos exist, then succeeds. + _, errOut, code = inst.ssh(t, bobKey, "", "org", "delete", "krz", "--yes") + if code != 1 || !strings.Contains(errOut, "still owns") { + t.Fatalf("delete with repos: %d %s", code, errOut) + } + if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "delete", "krz/lib", "--yes"); code != 0 { + t.Fatalf("org repo delete: %s", errOut) + } + if _, errOut, code = inst.ssh(t, bobKey, "", "org", "delete", "krz", "--yes"); code != 0 { + t.Fatalf("org delete: %s", errOut) + } + + // Public org repos appear on the anonymous web index. + if _, _, code = inst.ssh(t, aliceKey, "", "org", "create", "puborg"); code != 0 { + t.Fatal("org create failed") + } + if _, _, code = inst.ssh(t, aliceKey, "", "repo", "create", "puborg/site"); code != 0 { + t.Fatal("org public repo failed") + } + status, body := inst.get(t, "/") + if status != 200 || !strings.Contains(body, "puborg/site") { + t.Fatalf("org repo missing from index: %d", status) + } + if status, _ := inst.get(t, "/puborg/site"); status != 200 { + t.Fatalf("org repo page: %d", status) + } +} @@ -115,6 +115,17 @@ func (i *instance) admin(t *testing.T, args ...string) string { return string(out) } +// forgedAdminErr runs an admin command expected to fail, returning output. +func (i *instance) forgedAdminErr(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command(i.gitbayd, append([]string{"--config", i.config}, args...)...) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("gitbayd %v unexpectedly succeeded:\n%s", args, out) + } + return string(out) +} + // newKey generates a client keypair and returns the private key path. func (i *instance) newKey(t *testing.T, name string) string { t.Helper() new file mode 100644 @@ -0,0 +1,216 @@ +package control + +import ( + "errors" + "fmt" + "io" + + "gitbay.org/gitbay/internal/policy" + "gitbay.org/gitbay/internal/protocol" + "gitbay.org/gitbay/internal/store" +) + +func init() { + register(Command{Path: []string{"org", "create"}, + Summary: "create an organization (you become its first admin): org create <name>", Run: runOrgCreate}) + register(Command{Path: []string{"org", "list"}, + Summary: "list organizations you belong to", Run: runOrgList}) + register(Command{Path: []string{"org", "show"}, + Summary: "show an organization and its members: org show <name>", Run: runOrgShow}) + register(Command{Path: []string{"org", "delete"}, + Summary: "delete an empty organization: org delete <name> --yes", Run: runOrgDelete}) + register(Command{Path: []string{"org", "members", "add"}, + Summary: "add or update a member: org members add <org> <user> [--role member|admin]", Run: runOrgMembersAdd}) + register(Command{Path: []string{"org", "members", "remove"}, + Summary: "remove a member: org members remove <org> <user>", Run: runOrgMembersRemove}) + register(Command{Path: []string{"org", "members", "list"}, + Summary: "list members: org members list <org>", Run: runOrgMembersList}) +} + +// orgAdmin loads an org and requires the caller to be one of its admins. +func orgAdmin(c *Ctx, name string) (store.Org, int) { + org, err := c.Store.OrgByName(name) + if errors.Is(err, store.ErrNotFound) { + return org, c.fail(protocol.ExitNotFound, "no organization %q", name) + } + if err != nil { + return org, c.fail(protocol.ExitFailure, "%v", err) + } + role, err := c.Store.OrgRole(org.ID, c.User.ID) + if err != nil { + return org, c.fail(protocol.ExitFailure, "%v", err) + } + if role != "admin" { + return org, c.fail(protocol.ExitDenied, "only admins of %s can do that", name) + } + return org, -1 +} + +func runOrgCreate(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: org create <name>") + } + if err := policy.ValidateOwnerName(args[0]); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + if _, err := c.Store.CreateOrg(args[0], c.User.ID); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"org": args[0], "role": "admin"}, func(w io.Writer) { + fmt.Fprintf(w, "created organization %s; you are its admin\n", args[0]) + }) +} + +func runOrgList(c *Ctx, args []string) int { + orgs, err := c.Store.ListOrgsForUser(c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type out struct { + Org string `json:"org"` + Role string `json:"role"` + } + var ds []out + for _, o := range orgs { + ds = append(ds, out{o.Username, o.Role}) + } + return c.emit(ds, func(w io.Writer) { + for _, d := range ds { + fmt.Fprintf(w, "%s\t%s\n", d.Org, d.Role) + } + }) +} + +func runOrgShow(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: org show <name>") + } + org, err := c.Store.OrgByName(args[0]) + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "no organization %q", args[0]) + } + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + members, err := c.Store.OrgMembers(org.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + type memberOut struct { + User string `json:"user"` + Role string `json:"role"` + } + var ms []memberOut + for _, m := range members { + ms = append(ms, memberOut{m.Username, m.Role}) + } + d := struct { + Org string `json:"org"` + Members []memberOut `json:"members"` + }{org.Name, ms} + return c.emit(d, func(w io.Writer) { + fmt.Fprintf(w, "%s\n", d.Org) + for _, m := range ms { + fmt.Fprintf(w, " %s\t%s\n", m.User, m.Role) + } + }) +} + +func runOrgDelete(c *Ctx, args []string) int { + var name string + yes := false + for _, a := range args { + if a == "--yes" { + yes = true + } else if name == "" { + name = a + } else { + return c.fail(protocol.ExitUsage, "usage: org delete <name> --yes") + } + } + if name == "" { + return c.fail(protocol.ExitUsage, "usage: org delete <name> --yes") + } + org, code := orgAdmin(c, name) + if code >= 0 { + return code + } + if !yes { + return c.fail(protocol.ExitUsage, "org delete is permanent; re-run with --yes") + } + if err := c.Store.DeleteOrg(org.ID); err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + return c.emit(map[string]string{"deleted": name}, func(w io.Writer) { + fmt.Fprintf(w, "deleted organization %s\n", name) + }) +} + +func runOrgMembersAdd(c *Ctx, args []string) int { + role := "member" + var rest []string + for i := 0; i < len(args); i++ { + if args[i] == "--role" { + if i+1 >= len(args) { + return c.fail(protocol.ExitUsage, "--role requires member|admin") + } + role = args[i+1] + i++ + continue + } + rest = append(rest, args[i]) + } + if len(rest) != 2 || (role != "member" && role != "admin") { + return c.fail(protocol.ExitUsage, "usage: org members add <org> <user> [--role member|admin]") + } + org, code := orgAdmin(c, rest[0]) + if code >= 0 { + return code + } + target, err := c.Store.UserByUsername(rest[1]) + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "no such user %q", rest[1]) + } + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := c.Store.SetOrgMember(org.ID, target.ID, role); err != nil { + return c.fail(protocol.ExitUsage, "%v", err) + } + return c.emit(map[string]string{"org": org.Name, "user": target.Username, "role": role}, func(w io.Writer) { + fmt.Fprintf(w, "%s is now a %s of %s\n", target.Username, role, org.Name) + }) +} + +func runOrgMembersRemove(c *Ctx, args []string) int { + if len(args) != 2 { + return c.fail(protocol.ExitUsage, "usage: org members remove <org> <user>") + } + org, code := orgAdmin(c, args[0]) + if code >= 0 { + return code + } + target, err := c.Store.UserByUsername(args[1]) + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "no such user %q", args[1]) + } + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if err := c.Store.RemoveOrgMember(org.ID, target.ID); err != nil { + if errors.Is(err, store.ErrNotFound) { + return c.fail(protocol.ExitNotFound, "%s is not a member of %s", target.Username, org.Name) + } + return c.fail(protocol.ExitUsage, "%v", err) + } + return c.emit(map[string]string{"org": org.Name, "removed": target.Username}, func(w io.Writer) { + fmt.Fprintf(w, "removed %s from %s\n", target.Username, org.Name) + }) +} + +func runOrgMembersList(c *Ctx, args []string) int { + if len(args) != 1 { + return c.fail(protocol.ExitUsage, "usage: org members list <org>") + } + return runOrgShow(c, args) +} @@ -90,13 +90,25 @@ func runRepoCreate(c *Ctx, args []string) int { if !ok { return c.fail(protocol.ExitUsage, "usage: repo create <owner/name> [--private]") } - if owner != c.User.Username { - return c.fail(protocol.ExitDenied, "cannot create repositories under %q (orgs not yet supported)", owner) - } if err := policyValidateRepoName(name); err != nil { return c.fail(protocol.ExitUsage, "%v", err) } - id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility) + ownerKind, ownerID := "user", c.User.ID + if owner != c.User.Username { + org, err := c.Store.OrgByName(owner) + if err != nil { + return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner) + } + role, err := c.Store.OrgRole(org.ID, c.User.ID) + if err != nil { + return c.fail(protocol.ExitFailure, "%v", err) + } + if role != "admin" { + return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner) + } + ownerKind, ownerID = "org", org.ID + } + id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility) if err != nil { return c.fail(protocol.ExitFailure, "%v", err) } new file mode 100644 @@ -0,0 +1,192 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" +) + +type Org struct { + ID int64 + Name string +} + +type OrgMember struct { + Username string + Role string // member | admin +} + +// ownerNameTaken reports whether name is claimed by any user or org. Users +// and orgs share one namespace: /<owner>/<repo> must be unambiguous. +func ownerNameTaken(q interface { + QueryRow(string, ...any) *sql.Row +}, name string) (bool, error) { + var n int + err := q.QueryRow( + "SELECT (SELECT COUNT(*) FROM users WHERE username = ?) + (SELECT COUNT(*) FROM orgs WHERE name = ?)", + name, name).Scan(&n) + return n > 0, err +} + +// CreateOrg makes an organization with the creator as its first admin. +func (s *Store) CreateOrg(name string, creatorID int64) (int64, error) { + tx, err := s.DB.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + taken, err := ownerNameTaken(tx, name) + if err != nil { + return 0, err + } + if taken { + return 0, fmt.Errorf("the name %q is taken", name) + } + res, err := tx.Exec("INSERT INTO orgs (name) VALUES (?)", name) + if err != nil { + return 0, err + } + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + if _, err := tx.Exec( + "INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, 'admin')", id, creatorID); err != nil { + return 0, err + } + return id, tx.Commit() +} + +func (s *Store) OrgByName(name string) (Org, error) { + var o Org + err := s.DB.QueryRow("SELECT id, name FROM orgs WHERE name = ?", name).Scan(&o.ID, &o.Name) + if errors.Is(err, sql.ErrNoRows) { + return o, ErrNotFound + } + return o, err +} + +// OrgRole returns the user's role in the org ("" for non-members). +func (s *Store) OrgRole(orgID, userID int64) (string, error) { + var role string + err := s.DB.QueryRow( + "SELECT role FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID).Scan(&role) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return role, err +} + +func (s *Store) OrgMembers(orgID int64) ([]OrgMember, error) { + rows, err := s.DB.Query(` + SELECT u.username, m.role FROM org_members m + JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY u.username`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []OrgMember + for rows.Next() { + var m OrgMember + if err := rows.Scan(&m.Username, &m.Role); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// ListOrgsForUser returns the orgs the user belongs to, with their role. +func (s *Store) ListOrgsForUser(userID int64) ([]OrgMember, error) { + rows, err := s.DB.Query(` + SELECT o.name, m.role FROM org_members m + JOIN orgs o ON o.id = m.org_id WHERE m.user_id = ? ORDER BY o.name`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []OrgMember + for rows.Next() { + var m OrgMember + if err := rows.Scan(&m.Username, &m.Role); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// SetOrgMember adds a member or updates their role. Demoting the last admin +// is refused: an org must always have one. +func (s *Store) SetOrgMember(orgID, userID int64, role string) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if role == "member" { + ok, err := wouldKeepAdmin(tx, orgID, userID) + if err != nil { + return err + } + if !ok { + return errors.New("an organization needs at least one admin") + } + } + if _, err := tx.Exec(` + INSERT INTO org_members (org_id, user_id, role) VALUES (?, ?, ?) + ON CONFLICT (org_id, user_id) DO UPDATE SET role = excluded.role`, + orgID, userID, role); err != nil { + return err + } + return tx.Commit() +} + +// RemoveOrgMember drops a member, refusing to remove the last admin. +func (s *Store) RemoveOrgMember(orgID, userID int64) error { + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + ok, err := wouldKeepAdmin(tx, orgID, userID) + if err != nil { + return err + } + if !ok { + return errors.New("an organization needs at least one admin") + } + res, err := tx.Exec("DELETE FROM org_members WHERE org_id = ? AND user_id = ?", orgID, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return tx.Commit() +} + +// wouldKeepAdmin reports whether the org keeps at least one admin after +// userID stops being one. +func wouldKeepAdmin(tx *sql.Tx, orgID, userID int64) (bool, error) { + var n int + err := tx.QueryRow( + "SELECT COUNT(*) FROM org_members WHERE org_id = ? AND role = 'admin' AND user_id <> ?", + orgID, userID).Scan(&n) + return n > 0, err +} + +// DeleteOrg removes an empty organization; orgs still owning repositories +// are refused. +func (s *Store) DeleteOrg(orgID int64) error { + var n int + if err := s.DB.QueryRow( + "SELECT COUNT(*) FROM repos WHERE owner_kind = 'org' AND owner_id = ?", orgID).Scan(&n); err != nil { + return err + } + if n > 0 { + return fmt.Errorf("the organization still owns %d repositories; delete or transfer them first", n) + } + _, err := s.DB.Exec("DELETE FROM orgs WHERE id = ?", orgID) + return err +} @@ -56,6 +56,11 @@ func (s *Store) ConsumeEmailToken(userID int64, tokenHash string) (string, error // CreateRegisteredUser makes a self-registered account, pending until its // email is verified. func (s *Store) CreateRegisteredUser(username string, pending bool) (int64, error) { + if taken, err := ownerNameTaken(s.DB, username); err != nil { + return 0, err + } else if taken { + return 0, errors.New("that username is taken") + } res, err := s.DB.Exec("INSERT INTO users (username, pending) VALUES (?, ?)", username, boolInt(pending)) if err != nil { if isUniqueErr(err) { @@ -42,29 +42,39 @@ func (s *Store) CreateRepo(ownerKind string, ownerID int64, name, visibility str return res.LastInsertId() } -// RepoByPath resolves "owner/name". Only user owners exist until orgs land. +// repoSelect resolves the owner name from whichever table owns the repo. +const repoSelect = ` + SELECT r.id, r.owner_kind, r.owner_id, COALESCE(u.username, o.name), + r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json + FROM repos r + LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id + LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id` + +func scanRepo(row interface{ Scan(...any) error }) (Repo, error) { + var r Repo + var settingsJSON string + err := row.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) + if err != nil { + return r, err + } + if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { + return r, fmt.Errorf("repo %d settings: %w", r.ID, err) + } + return r, nil +} + +// RepoByPath resolves "owner/name"; the owner may be a user or an org. func (s *Store) RepoByPath(path string) (Repo, error) { owner, name, ok := strings.Cut(strings.TrimSuffix(strings.TrimPrefix(path, "/"), ".git"), "/") if !ok || owner == "" || name == "" || strings.Contains(name, "/") { return Repo{}, fmt.Errorf("%w: repository path must be owner/name", ErrNotFound) } - var r Repo - var settingsJSON string - err := s.DB.QueryRow(` - SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json - FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id - WHERE u.username = ? AND r.name = ?`, owner, name). - Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) + r, err := scanRepo(s.DB.QueryRow( + repoSelect+" WHERE COALESCE(u.username, o.name) = ? AND r.name = ?", owner, name)) if errors.Is(err, sql.ErrNoRows) { return Repo{}, ErrNotFound } - if err != nil { - return Repo{}, err - } - if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { - return Repo{}, fmt.Errorf("repo %d settings: %w", r.ID, err) - } - return r, nil + return r, err } func (s *Store) SetRepoSettings(repoID int64, settings RepoSettings) error { @@ -92,27 +102,23 @@ func (s *Store) DeleteRepo(repoID int64) error { return nil } -// ListReposForUser returns repos the user owns or has an explicit grant on. +// ListReposForUser returns repos the user owns, belongs to through an org, +// or has an explicit grant on. func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { - rows, err := s.DB.Query(` - SELECT DISTINCT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json - FROM repos r - JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id + rows, err := s.DB.Query(repoSelect+` LEFT JOIN repo_access a ON a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ? - WHERE r.owner_id = ? OR a.subject_id IS NOT NULL - ORDER BY u.username, r.name`, userID, userID) + LEFT JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? + WHERE (r.owner_kind = 'user' AND r.owner_id = ?) OR a.subject_id IS NOT NULL OR m.user_id IS NOT NULL + GROUP BY r.id + ORDER BY 4, r.name`, userID, userID, userID) if err != nil { return nil, err } defer rows.Close() var out []Repo for rows.Next() { - var r Repo - var settingsJSON string - if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { - return nil, err - } - if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { + r, err := scanRepo(rows) + if err != nil { return nil, err } out = append(out, r) @@ -120,16 +126,37 @@ func (s *Store) ListReposForUser(userID int64) ([]Repo, error) { return out, rows.Err() } -// AccessRole returns the explicit grant for userID on repoID ("" if none). +// AccessRole returns the user's effective role on the repo ("" if none): +// the strongest of any explicit grant and, for org-owned repos, the role +// derived from org membership (org admin -> admin, org member -> write). func (s *Store) AccessRole(repoID, userID int64) (string, error) { - var role string + rank := map[string]int{"": 0, "read": 1, "write": 2, "admin": 3} + best := "" + + var explicit string err := s.DB.QueryRow( "SELECT role FROM repo_access WHERE repo_id = ? AND subject_kind = 'user' AND subject_id = ?", - repoID, userID).Scan(&role) - if errors.Is(err, sql.ErrNoRows) { - return "", nil + repoID, userID).Scan(&explicit) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + if rank[explicit] > rank[best] { + best = explicit + } + + var orgRole string + err = s.DB.QueryRow(` + SELECT m.role FROM repos r + JOIN org_members m ON r.owner_kind = 'org' AND m.org_id = r.owner_id AND m.user_id = ? + WHERE r.id = ?`, userID, repoID).Scan(&orgRole) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + derived := map[string]string{"admin": "admin", "member": "write"}[orgRole] + if rank[derived] > rank[best] { + best = derived } - return role, err + return best, nil } func (s *Store) GrantAccess(repoID, userID int64, role string) error { @@ -179,40 +206,24 @@ func (s *Store) ListAccess(repoID int64) ([]AccessEntry, error) { } func (s *Store) RepoByID(id int64) (Repo, error) { - var r Repo - var settingsJSON string - err := s.DB.QueryRow(` - SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, COALESCE(r.fork_of, 0), r.settings_json - FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id - WHERE r.id = ?`, id). - Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &r.ForkOf, &settingsJSON) + r, err := scanRepo(s.DB.QueryRow(repoSelect+" WHERE r.id = ?", id)) if errors.Is(err, sql.ErrNoRows) { return Repo{}, ErrNotFound } - if err != nil { - return Repo{}, err - } - if err := json.Unmarshal([]byte(settingsJSON), &r.Settings); err != nil { - return Repo{}, err - } - return r, nil + return r, err } // ListPublicRepos returns all public repositories, for the anonymous index. func (s *Store) ListPublicRepos() ([]Repo, error) { - rows, err := s.DB.Query(` - SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json - FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id - WHERE r.visibility = 'public' ORDER BY u.username, r.name`) + rows, err := s.DB.Query(repoSelect + " WHERE r.visibility = 'public' ORDER BY 4, r.name") if err != nil { return nil, err } defer rows.Close() var out []Repo for rows.Next() { - var r Repo - var settingsJSON string - if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil { + r, err := scanRepo(rows) + if err != nil { return nil, err } out = append(out, r) @@ -30,6 +30,11 @@ var ErrDuplicateKey = errors.New("that key is already registered to another acco var ErrNotFound = errors.New("not found") func (s *Store) CreateUser(username string, isAdmin bool) (int64, error) { + if taken, err := ownerNameTaken(s.DB, username); err != nil { + return 0, err + } else if taken { + return 0, fmt.Errorf("username %q is taken", username) + } res, err := s.DB.Exec("INSERT INTO users (username, is_admin) VALUES (?, ?)", username, boolInt(isAdmin)) if err != nil { if isUniqueErr(err) {