Resolve a named repository owner server-side, and prove a board refuses a stranger

87837ed082e3 · AtlantisPleb · · parent 779723d45710

Resolve a named repository owner server-side, and prove a board refuses a stranger

Two changes that both remove a guess.

`POST /api/v1/user/repos` and `POST /api/v1/orgs/{org}/repos` make the
caller pick a route by the kind of owner, and a client handed `OWNER/NAME`
knows only that an owner was named. Both CLIs read a slash as proof the
owner was an organization, so `repo create AtlantisPleb/thing` asked the
organization route to create under a person. `POST /api/v1/repos` takes
`owner` as a field and resolves the kind here, so no client has to guess.
Omitting `owner` means the caller's own namespace. The two GitHub-shaped
routes stay as they were.

The caller's own login resolves without asking GitHub, because the token
already authenticates that identity. Another person's namespace is refused
outright rather than asked about: GitHub cannot answer "is this login an
organization you administer" for a login that is a person, and the answer
it gives instead is a worse error than the true one.

The project lifecycle routes already carried the writable-membership
check, but every test of them ran against a private repository, where a
404 comes from visibility before write authority is ever consulted. A
delete route with no authorization check at all would have passed that
suite. The new test uses a public repository: the outsider reads the board
first, so the refusal that follows can only be about writing, and the
board is listed before and after so a refusal that quietly went through
would still fail. Removing `writable_repository!` from `update/2` and
`delete/2` fails it, which is the point.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 442 · 2026-08-26T08:33:57.055766Z

Changed files

  • modified lib/openagents/repositories.ex
  • modified lib/openagents/repositories/github_projection.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/repository_controller.ex
  • modified lib/openagents_web/router.ex
  • modified priv/api-contracts/repositories-v1.json
  • modified priv/docs/rest-api.md
  • modified test/openagents_web/controllers/project_controller_test.exs
  • modified test/openagents_web/controllers/repository_controller_test.exs

Diff

9 files changed, +255 -0

lib/openagents/repositories.ex modified +41

@@ -248,6 +248,47 @@ defmodule OpenAgents.Repositories do

248 248
    end
249 249
  end
250 250
251
  @doc "One active namespace by slug or alias, or `nil` when no such namespace exists."
252
  def get_namespace_by_slug(slug) when is_binary(slug) do
253
    get_namespace_by_slug!(slug)
254
  rescue
255
    Ecto.NoResultsError -> nil
256
  end
257
258
  @doc """
259
  Creates a repository in an already-resolved namespace of either kind.
260
261
  `create_user_repository/3` and `create_organization_repository/4` each name
262
  one kind because the GitHub-compatible route they serve names one kind. This
263
  one takes whichever namespace resolving an owner produced, so the caller does
264
  not have to know the kind before it asks.
265
266
  A user namespace that belongs to somebody else is refused here as well as at
267
  resolution. The check costs one comparison and closes the gap between the
268
  two callers this function will eventually have.
269
  """
270
  def create_namespace_repository(
271
        %User{} = user,
272
        %Namespace{} = namespace,
273
        attrs,
274
        idempotency_key
275
      )
276
      when is_map(attrs) and is_binary(idempotency_key) do
277
    if namespace.kind == "user" and namespace.owner_user_id != user.id do
278
      {:error, :namespace_not_allowed}
279
    else
280
      create_repository_transaction(
281
        user,
282
        namespace,
283
        attrs,
284
        nil,
285
        "create",
286
        "empty",
287
        idempotency_key
288
      )
289
    end
290
  end
291
251 292
  def create_user_repository(%User{} = user, attrs, idempotency_key)
252 293
      when is_map(attrs) and is_binary(idempotency_key) do
253 294
    with {:ok, namespace} <- ensure_user_namespace(user) do
lib/openagents/repositories/github_projection.ex modified +40

@@ -3,6 +3,7 @@ defmodule OpenAgents.Repositories.GitHubProjection do

3 3
4 4
  alias OpenAgents.{Accounts, GitHub, GitHubOAuth, Repositories}
5 5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.Repositories.Namespace
6 7
7 8
  @maximum_organization_pages 10
8 9

@@ -49,6 +50,45 @@ defmodule OpenAgents.Repositories.GitHubProjection do

49 50
    end
50 51
  end
51 52
53
  @doc """
54
  Resolves one named owner to a namespace the caller may create repositories in.
55
56
  An owner is either a personal namespace or an organization, and the name
57
  alone does not say which. Deciding here is what lets a client pass along the
58
  owner it was given instead of guessing at the kind and picking a route on
59
  the guess.
60
61
  The caller's own login resolves without asking GitHub: the token already
62
  authenticates that identity, so a personal repository does not depend on a
63
  retained GitHub token the way an organization repository does. Another
64
  person's namespace is refused outright rather than asked about, because
65
  GitHub cannot answer "is this login an organization you administer" for a
66
  login that is a person.
67
  """
68
  def authorized_namespace(%User{} = user, requested_slug) when is_binary(requested_slug) do
69
    if own_login?(user, requested_slug) do
70
      Repositories.ensure_user_namespace(user)
71
    else
72
      user_id = user.id
73
74
      case Repositories.get_namespace_by_slug(requested_slug) do
75
        %Namespace{kind: "user", owner_user_id: ^user_id} = namespace ->
76
          {:ok, namespace}
77
78
        %Namespace{kind: "user"} ->
79
          {:error, :namespace_not_allowed}
80
81
        _absent_or_organization ->
82
          authorized_organization(user, requested_slug)
83
      end
84
    end
85
  end
86
87
  defp own_login?(%User{github_login: login}, requested_slug) when is_binary(login),
88
    do: String.downcase(login) == String.downcase(requested_slug)
89
90
  defp own_login?(_user, _requested_slug), do: false
91
52 92
  def authorized_organization(%User{} = user, requested_slug) when is_binary(requested_slug) do
53 93
    with {:ok, token} <- retained_token(user),
54 94
         {:ok, membership} <- find_organization_membership(token, requested_slug, 1),
lib/openagents_web/api_route_authority.ex modified +1

@@ -291,6 +291,7 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

291 291
        {:required_bearer, :milestone, :envelope},
292 292
      "patch /api/v1/repos/:owner/:repo/projectsV2/:project_number/items/:item_id" =>
293 293
        {:required_bearer, :project, :envelope},
294
      "post /api/v1/repos" => {:required_bearer, :repository, :legacy},
294 295
      "post /api/v1/orgs/:org/repos" => {:required_bearer, :repository, :legacy},
295 296
      "post /api/v1/orgs/:org/repos/imports" => {:required_bearer, :repository, :legacy},
296 297
      "post /api/v1/repos/:owner/:repo/pulls" => {:required_bearer, :pull_request, :legacy},
lib/openagents_web/controllers/repository_controller.ex modified +43

@@ -7,6 +7,35 @@ defmodule OpenAgentsWeb.RepositoryController do

7 7
  alias OpenAgents.Repositories.GitHubProjection
8 8
  alias OpenAgentsWeb.RepositoryJSON
9 9
10
  @doc """
11
  Creates a repository under a named owner of either kind.
12
13
  `POST /api/v1/user/repos` and `POST /api/v1/orgs/{org}/repos` make the caller
14
  choose a route by the kind of owner, and a caller handed `OWNER/NAME` knows
15
  only that an owner was named. Guessing the kind from the shape of the
16
  argument sends a personal namespace to the organization route. This route
17
  takes the owner as a parameter and resolves the kind here, so no client has
18
  to guess.
19
20
  `owner` defaults to the authenticated login. Everything else — the
21
  idempotency key, the attributes, the response — matches the two routes it
22
  stands in for.
23
  """
24
  def create(conn, params) do
25
    user = conn.assigns.current_user
26
27
    with {:ok, idempotency_key} <- idempotency_key(conn),
28
         {:ok, attrs} <- repository_attrs(params),
29
         {:ok, owner} <- requested_owner(params, user),
30
         {:ok, namespace} <- GitHubProjection.authorized_namespace(user, owner),
31
         {:ok, repository, replay_state} <-
32
           Repositories.create_namespace_repository(user, namespace, attrs, idempotency_key) do
33
      render_repository(conn, repository, user, replay_state)
34
    else
35
      {:error, reason} -> render_error(conn, reason)
36
    end
37
  end
38
10 39
  def create_user(conn, params) do
11 40
    with {:ok, idempotency_key} <- idempotency_key(conn),
12 41
         {:ok, attrs} <- repository_attrs(params),

@@ -131,6 +160,20 @@ defmodule OpenAgentsWeb.RepositoryController do

131 160
    end
132 161
  end
133 162
163
  defp requested_owner(%{"owner" => owner}, _user) when is_binary(owner) do
164
    case namespace_key(owner) do
165
      {:ok, _key} -> {:ok, owner}
166
      {:error, _reason} -> {:error, :invalid_repository}
167
    end
168
  end
169
170
  defp requested_owner(%{"owner" => _owner}, _user), do: {:error, :invalid_repository}
171
172
  defp requested_owner(_params, %{github_login: login}) when is_binary(login),
173
    do: {:ok, login}
174
175
  defp requested_owner(_params, _user), do: {:error, :namespace_not_allowed}
176
134 177
  defp idempotency_key(conn) do
135 178
    case get_req_header(conn, "idempotency-key") do
136 179
      [key] when byte_size(key) in 1..200 ->
lib/openagents_web/router.ex modified +1

@@ -801,6 +801,7 @@ defmodule OpenAgentsWeb.Router do

801 801
802 802
    get "/user", ForgeUserController, :show
803 803
    get "/user/repos", RepositoryController, :index
804
    post "/repos", RepositoryController, :create
804 805
    post "/user/repos", RepositoryController, :create_user
805 806
    post "/orgs/:org/repos", RepositoryController, :create_organization
806 807
    delete "/repos/:owner/:repo", RepositoryController, :delete
priv/api-contracts/repositories-v1.json modified +2

@@ -9,6 +9,7 @@

9 9
  "idempotency": {
10 10
    "header": "Idempotency-Key",
11 11
    "required_for": [
12
      "POST /api/v1/repos",
12 13
      "POST /api/v1/user/repos",
13 14
      "POST /api/v1/orgs/{org}/repos",
14 15
      "POST /api/v1/user/repos/imports",

@@ -17,6 +18,7 @@

17 18
  },
18 19
  "endpoints": {
19 20
    "get_authenticated_user": "GET /api/v1/user",
21
    "create_repository": "POST /api/v1/repos",
20 22
    "create_user_repository": "POST /api/v1/user/repos",
21 23
    "create_organization_repository": "POST /api/v1/orgs/{org}/repos",
22 24
    "import_user_repository": "POST /api/v1/user/repos/imports",
priv/docs/rest-api.md modified +7

@@ -27,6 +27,7 @@ the API origin, load your stored credential, and return JSON.

27 27
```text
28 28
GET    /api/v1/user
29 29
GET    /api/v1/user/repos
30
POST   /api/v1/repos
30 31
POST   /api/v1/user/repos
31 32
POST   /api/v1/orgs/:org/repos
32 33
GET    /api/v1/repos/:owner/:repo

@@ -36,6 +37,12 @@ POST /api/v1/orgs/:org/repos/imports

36 37
GET    /api/v1/repository-imports/:id
37 38
```
38 39
40
`POST /api/v1/repos` takes an `owner` field and resolves it here, so a caller
41
that was handed `OWNER/NAME` does not have to decide whether `OWNER` is a
42
person or an organization before it picks a route. Omit `owner` and the
43
repository lands in your own namespace. `POST /api/v1/user/repos` and
44
`POST /api/v1/orgs/:org/repos` stay as the GitHub-compatible spellings.
45
39 46
Repository writes require an `Idempotency-Key` header. The published
40 47
[`openagents.repositories.v1` contract](/api/contracts/repositories-v1.json)
41 48
defines request authority, lifecycle states, pagination, and stable error
test/openagents_web/controllers/project_controller_test.exs modified +42

@@ -1333,6 +1333,48 @@ defmodule OpenAgentsWeb.ProjectControllerTest do

1333 1333
      assert Projects.get_project_by_number!(repository(), project.number)
1334 1334
    end
1335 1335
1336
    test "a public repository's board refuses a non-member's PATCH and DELETE" do
1337
      # A public repository is the case the private-repository tests cannot
1338
      # reach: the outsider can see the board, so a 404 here has to come from
1339
      # write authority rather than from visibility. Without that separation, a
1340
      # delete route with no authorization check would still pass the private
1341
      # tests.
1342
      owner = github_user("project-public-owner", "publius")
1343
1344
      repository =
1345
        repository_with_member_fixture(owner, %{
1346
          owner: "PublicProjectOrg",
1347
          name: "public-projects",
1348
          visibility: "public"
1349
        })
1350
1351
      {:ok, project} =
1352
        Projects.create_project(repository, %{"title" => "Public roadmap"}, owner)
1353
1354
      {:ok, project} = Projects.update_project(project, %{"archived" => true}, owner)
1355
1356
      path = ~p"/api/v1/repos/PublicProjectOrg/public-projects/projectsV2/#{project.number}"
1357
      list_path = ~p"/api/v1/repos/PublicProjectOrg/public-projects/projectsV2?archived=true"
1358
      mallory = put_forge_api_token(build_conn(), "project-public-outsider", "mallory")
1359
1360
      # The outsider really can read the board. The refusal below is therefore
1361
      # about writing, not about seeing.
1362
      assert %{"projects" => before_write} =
1363
               mallory |> get(list_path) |> json_response(200)
1364
1365
      assert Enum.map(before_write, & &1["number"]) == [project.number]
1366
      assert json_response(get(mallory, path), 200)["title"] == "Public roadmap"
1367
1368
      assert mallory |> patch(path, %{title: "Mine now"}) |> api_error_code(404) == "not_found"
1369
      assert mallory |> delete(path) |> api_error_code(404) == "not_found"
1370
1371
      assert %{"projects" => after_write} =
1372
               mallory |> get(list_path) |> json_response(200)
1373
1374
      assert Enum.map(after_write, & &1["number"]) == [project.number]
1375
      assert Projects.get_project_by_number!(repository, project.number).title == "Public roadmap"
1376
    end
1377
1336 1378
    test "an archived project stays out of the default list and returns on request", %{
1337 1379
      conn: conn
1338 1380
    } do
test/openagents_web/controllers/repository_controller_test.exs modified +78

@@ -202,6 +202,84 @@ defmodule OpenAgentsWeb.RepositoryControllerTest do

202 202
             |> json_response(422)
203 203
  end
204 204
205
  test "POST /api/v1/repos creates under a named personal owner", %{conn: conn} do
206
    user = github_user("repository-api-owner-route-self", "namedowner")
207
208
    response =
209
      conn
210
      |> authorize(user)
211
      |> put_req_header("idempotency-key", "owner-route-self")
212
      |> post(~p"/api/v1/repos", %{owner: "namedowner", name: "Named-Thing", private: true})
213
214
    assert %{"full_name" => "namedowner/named-thing", "owner" => owner} =
215
             json_response(response, 202)
216
217
    assert owner["login"] == "namedowner"
218
    assert Repositories.get_by_path!("namedowner", "named-thing")
219
  end
220
221
  test "POST /api/v1/repos matches a named personal owner without regard to case", %{conn: conn} do
222
    user = github_user("repository-api-owner-route-case", "MixedCaseOwner")
223
224
    assert %{"full_name" => "MixedCaseOwner/cased"} =
225
             conn
226
             |> authorize(user)
227
             |> put_req_header("idempotency-key", "owner-route-case")
228
             |> post(~p"/api/v1/repos", %{owner: "mixedcaseowner", name: "cased"})
229
             |> json_response(202)
230
231
    assert Repositories.get_by_path!("mixedcaseowner", "cased")
232
  end
233
234
  test "POST /api/v1/repos without an owner uses the caller's own namespace", %{conn: conn} do
235
    user = github_user("repository-api-owner-route-default", "defaultowner")
236
237
    assert %{"full_name" => "defaultowner/implied"} =
238
             conn
239
             |> authorize(user)
240
             |> put_req_header("idempotency-key", "owner-route-default")
241
             |> post(~p"/api/v1/repos", %{name: "implied"})
242
             |> json_response(202)
243
  end
244
245
  test "POST /api/v1/repos refuses another person's namespace", %{conn: conn} do
246
    caller = github_user("repository-api-owner-route-caller", "intruder")
247
    stranger = github_user("repository-api-owner-route-stranger", "bystander")
248
249
    # The stranger's namespace has to exist for the refusal to be about
250
    # authority rather than about an unknown name.
251
    assert {:ok, _namespace} = Repositories.ensure_user_namespace(stranger)
252
253
    assert %{"code" => "namespace_not_allowed"} =
254
             conn
255
             |> authorize(caller)
256
             |> put_req_header("idempotency-key", "owner-route-stranger")
257
             |> post(~p"/api/v1/repos", %{owner: "bystander", name: "not-yours"})
258
             |> json_response(403)
259
260
    assert_raise Ecto.NoResultsError, fn ->
261
      Repositories.get_by_path!("bystander", "not-yours")
262
    end
263
  end
264
265
  test "POST /api/v1/repos rejects an owner that is not a namespace name", %{conn: conn} do
266
    user = github_user("repository-api-owner-route-invalid", "shapeowner")
267
268
    assert %{"code" => "invalid_repository"} =
269
             conn
270
             |> authorize(user)
271
             |> put_req_header("idempotency-key", "owner-route-invalid")
272
             |> post(~p"/api/v1/repos", %{owner: "shape owner/nested", name: "wrong"})
273
             |> json_response(422)
274
  end
275
276
  test "POST /api/v1/repos requires a bearer token" do
277
    assert build_conn()
278
           |> put_req_header("idempotency-key", "owner-route-anonymous")
279
           |> post(~p"/api/v1/repos", %{owner: "anyone", name: "anything"})
280
           |> response(401)
281
  end
282
205 283
  test "DELETE /api/v1/repos/:owner/:repo removes an owned repository and its storage", %{
206 284
    conn: conn
207 285
  } do

This page updates live while a promote is in flight · changelog