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

orgs: shared-namespace organizations with membership-derived access

- org create/list/show/delete and org members add/remove/list; creator
  becomes admin; last-admin demotion/removal refused; delete refused
  while the org owns repositories
- users and orgs share one owner namespace, enforced on every creation
  path (org create, admin user create, self-registration)
- repo queries resolve owners from either table; ListReposForUser also
  fixes a latent owner_id collision (missing owner_kind check) and now
  includes org repos via membership
- AccessRole returns the effective role: strongest of the explicit
  grant and the org-derived role (org admin -> admin, member -> write);
  every access check (control, git transport, web) picks this up
- repo create under an org requires org admin; forks still land under
  the forking user
- CLI org passthrough group
- e2e: namespace collisions both directions, member write via git push,
  outsider not-found on private org repo, member vs admin boundaries,
  explicit grant alongside membership, promote/last-admin protection,
  delete lifecycle, org repos on the anonymous web index
 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(-)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index b96105d..874a7d5 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -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]",
diff --git a/e2e/org_test.go b/e2e/org_test.go
new file mode 100644
index 0000000..4d3863e
--- /dev/null
+++ b/e2e/org_test.go
@@ -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)
+	}
+}
diff --git a/e2e/ssh_test.go b/e2e/ssh_test.go
index 8e18740..d3d8607 100644
--- a/e2e/ssh_test.go
+++ b/e2e/ssh_test.go
@@ -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()
diff --git a/internal/control/org.go b/internal/control/org.go
new file mode 100644
index 0000000..427d5c7
--- /dev/null
+++ b/internal/control/org.go
@@ -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)
+}
diff --git a/internal/control/repo.go b/internal/control/repo.go
index e42d81c..819fae6 100644
--- a/internal/control/repo.go
+++ b/internal/control/repo.go
@@ -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)
 	}
diff --git a/internal/store/orgs.go b/internal/store/orgs.go
new file mode 100644
index 0000000..6ae94ee
--- /dev/null
+++ b/internal/store/orgs.go
@@ -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
+}
diff --git a/internal/store/registration.go b/internal/store/registration.go
index 8871c7b..2f20b8c 100644
--- a/internal/store/registration.go
+++ b/internal/store/registration.go
@@ -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) {
diff --git a/internal/store/repos.go b/internal/store/repos.go
index 2d6e334..95ce9dd 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -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)
diff --git a/internal/store/users.go b/internal/store/users.go
index 0ae36a3..bdc5484 100644
--- a/internal/store/users.go
+++ b/internal/store/users.go
@@ -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) {