Commit b04b2221fd

b04b2221fd70c1d56776271e5ce33481d45c1d40

parent: e1063b0300

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 01:30 UTC

repo import: allow org owners

Same ownership rule as repo create — yourself, or an org you admin.
The self-only restriction predated orgs and was never relaxed; e2e
covers the org path and the updated refusal message.

Layout: unified · split

e2e/import_test.go +12 −1
@@ -89,6 +89,17 @@ func TestRepoImport(t *testing.T) {
8989 t.Fatalf("git:// import: %s", errOut)
9090 }
9191
92 // Org-owned imports: allowed for org admins, refused for non-members.
93 if _, errOut, code := inst.ssh(t, aliceKey, "", "org", "create", "imports"); code != 0 {
94 t.Fatalf("org create: %s", errOut)
95 }
96 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "import", "imports/mirror", "--from", httpURL); code != 0 {
97 t.Fatalf("org import: %s", errOut)
98 }
99 if out, _, code := inst.ssh(t, aliceKey, "", "repo", "log", "imports/mirror"); code != 0 || !strings.Contains(out, "first") {
100 t.Fatalf("org import log: %d\n%s", code, out)
101 }
102
92103 // Refusals: bad scheme, credentials in URL, existing name, foreign owner.
93104 cases := []struct {
94105 args []string
@@ -97,7 +108,7 @@ func TestRepoImport(t *testing.T) {
97108 {[]string{"repo", "import", "alice/x", "--from", "file:///etc"}, "https://, http://, and git://"},
98109 {[]string{"repo", "import", "alice/x", "--from", "https://token@github.com/a/b"}, "--token-stdin"},
99110 {[]string{"repo", "import", "alice/mirror", "--from", httpURL}, "already exists"},
100 {[]string{"repo", "import", "bob/x", "--from", httpURL}, "your own account"},
111 {[]string{"repo", "import", "bob/x", "--from", httpURL}, "not you and not an organization"},
101112 }
102113 for _, tc := range cases {
103114 _, errOut, code := inst.ssh(t, aliceKey, "", tc.args...)
internal/control/import.go +19 −3
@@ -58,12 +58,28 @@ func runRepoImport(c *Ctx, args []string) int {
5858 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url> [--private] [--token-stdin]")
5959 }
6060 owner, name, ok := strings.Cut(path, "/")
61 if !ok || owner != c.User.Username {
62 return c.fail(protocol.ExitDenied, "imports land under your own account: %s/<name>", c.User.Username)
61 if !ok {
62 return c.fail(protocol.ExitUsage, "usage: repo import <owner/name> --from <url>")
6363 }
6464 if err := policy.ValidateName(name); err != nil {
6565 return c.fail(protocol.ExitUsage, "%v", err)
6666 }
67 // Same ownership rule as repo create: yourself, or an org you admin.
68 ownerKind, ownerID := "user", c.User.ID
69 if owner != c.User.Username {
70 org, err := c.Store.OrgByName(owner)
71 if err != nil {
72 return c.fail(protocol.ExitDenied, "cannot import under %q: not you and not an organization you can see", owner)
73 }
74 role, err := c.Store.OrgRole(org.ID, c.User.ID)
75 if err != nil {
76 return c.fail(protocol.ExitFailure, "%v", err)
77 }
78 if role != "admin" {
79 return c.fail(protocol.ExitDenied, "only admins of %s can import repositories there", owner)
80 }
81 ownerKind, ownerID = "org", org.ID
82 }
6783
6884 // Scheme allowlist. file:// (and anything else local) would read the
6985 // server's filesystem; ssh:// would use the server's own keys.
@@ -107,7 +123,7 @@ func runRepoImport(c *Ctx, args []string) int {
107123 if private {
108124 visibility = "private"
109125 }
110 id, err := c.Store.CreateRepo("user", c.User.ID, name, visibility)
126 id, err := c.Store.CreateRepo(ownerKind, ownerID, name, visibility)
111127 if err != nil {
112128 return c.fail(protocol.ExitFailure, "%v", err)
113129 }