fix(forge): read an assignment credential by the id its token carries

edec7225b8ad · AtlantisPleb · · parent 4f14c5d8f9eb

fix(forge): read an assignment credential by the id its token carries

No assignment credential could authenticate. `persist_assignment/7` generates
one id, uses it as the assignment's primary key, and embeds it in the
plaintext token. The credential row is inserted through a changeset that never
casts `:id`, against a schema that autogenerates one, so it takes an unrelated
key and is reachable only through `assignment_id`. `authenticate/1` then read
the uuid out of the token and looked the credential up by its own primary key,
asking for a row that cannot exist.

Every forge Git request from a Box run was refused `401` as a result — both
`git-receive-pack` and `git-upload-pack`, before any branch policy ran.
Retarget the lookup to `assignment_id`, which is what `credential/1` has always
used and what `forge_assignment_credentials` carries a unique index on, so
`Repo.one` still cannot see two rows.

The bug survived because nothing exercised the real minting path.
`assignment_git_push_test.exs` built its own credential with
`%AssignmentCredential{id: credential_id}` and embedded that credential id in
the token — the shape `authenticate/1` read, not the shape production writes —
so five branch-policy tests passed against a credential no assignment could
produce. That helper now mints the way production mints, and those tests fail
without this fix.

Add the missing case directly: `assignment_credential_auth_test.exs` goes
through `Assignments.create/1`, presents the plaintext it returns, and asserts
the principal resolves the right assignment, repository, and branch. It also
holds the check where it was — a malformed token, a well-formed token naming
another assignment, a revoked credential, and an expired one are each still
refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents/forge/assignments.ex
  • added test/openagents/forge/assignment_credential_auth_test.exs
  • modified test/openagents/forge/assignment_git_push_test.exs

Diff

3 files changed, +234 -4

lib/openagents/forge/assignments.ex modified +9 -1

@@ -311,10 +311,18 @@ defmodule OpenAgents.Forge.Assignments do

311 311
    with [id, secret] <- String.split(rest, ".", parts: 2),
312 312
         {:ok, uuid} <- Ecto.UUID.cast(id),
313 313
         true <- byte_size(secret) in 40..100,
314
         # The uuid in the token is the *assignment* id: `persist_assignment/7`
315
         # generates one id, uses it as the assignment's primary key, and
316
         # embeds it in the plaintext. The credential row carries its own
317
         # autogenerated key and is reached through `assignment_id`, which is
318
         # what `credential/1` has always done. Reading it by `c.id` asked for
319
         # a row that cannot exist, so no minted credential ever
320
         # authenticated. `forge_assignment_credentials` has a unique index on
321
         # `assignment_id`, so `Repo.one` here cannot see two rows.
314 322
         %AssignmentCredential{} = credential <-
315 323
           Repo.one(
316 324
             from c in AssignmentCredential,
317
               where: c.id == ^uuid,
325
               where: c.assignment_id == ^uuid,
318 326
               preload: [assignment: [:repository]]
319 327
           ),
320 328
         true <- Plug.Crypto.secure_compare(credential.token_digest, digest(plaintext)),
test/openagents/forge/assignment_credential_auth_test.exs added +215

@@ -0,0 +1,215 @@

1
defmodule OpenAgents.Forge.AssignmentCredentialAuthTest do
2
  @moduledoc """
3
  The credential an assignment mints must authenticate against the forge.
4
5
  This case exists because that had no coverage. The two existing tests of
6
  `Assignments.authenticate/1` assert only that an *invalid* token is refused,
7
  and every test that reaches `Assignments.create/1` wraps it in a `try` that
8
  tolerates the run failing to start, so none of them ever held a real
9
  plaintext. The branch-policy tests in `assignment_git_push_test.exs` built
10
  their own credential rows in the shape `authenticate/1` read rather than the
11
  shape `persist_assignment/7` writes.
12
13
  Between them, every test asserted something true about a credential no
14
  production assignment could produce, and a lookup against the wrong column
15
  shipped: the token carries the assignment id, the credential row carries its
16
  own autogenerated key, and `authenticate/1` asked for a credential whose
17
  primary key was the assignment id. That row cannot exist, so no assignment
18
  credential ever authenticated and every forge Git request from a Box run was
19
  refused `401`.
20
21
  The tests below go through the real minting path, so a regression puts that
22
  back.
23
  """
24
25
  use OpenAgents.DataCase, async: false
26
27
  import OpenAgents.AccountsFixtures
28
29
  alias OpenAgents.Box.ConversationBox
30
  alias OpenAgents.Conversations
31
  alias OpenAgents.Forge.{AssignmentCredential, Assignments}
32
  alias OpenAgents.Issues
33
  alias OpenAgents.Repo
34
35
  setup do
36
    Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
37
38
    original_api = Application.get_env(:openagents, :box_api)
39
    original_key = Application.get_env(:openagents, :box_api_key)
40
41
    # The provider is stubbed so `start_target/7` succeeds and `create/1`
42
    # returns the plaintext. A poll has to answer "still alive": a run that
43
    # reaches a terminal state finalizes its assignment, which revokes the
44
    # credential, and this case would then be racing its own fixture rather
45
    # than testing the lookup.
46
    Application.put_env(:openagents, :box_api,
47
      base_url: "https://box-api.internal",
48
      request_options: [plug: &stub_provider/1]
49
    )
50
51
    Application.put_env(:openagents, :box_api_key, "assignment-credential-auth-test")
52
53
    on_exit(fn ->
54
      restore(:box_api, original_api)
55
      restore(:box_api_key, original_key)
56
    end)
57
58
    :ok
59
  end
60
61
  describe "a credential that an assignment minted" do
62
    test "authenticates and resolves its own assignment, repository, and branch" do
63
      %{assignment: assignment, plaintext: plaintext, repository: repository, branch: branch} =
64
        admit("credential-auth-owner", "bx_credaut2")
65
66
      assert {:ok, principal} = Assignments.authenticate(plaintext)
67
68
      assert principal.kind == :assignment
69
      assert principal.assignment_id == assignment.id
70
      assert principal.id == assignment.id
71
      assert principal.repository_id == repository.id
72
      assert principal.branch == branch
73
    end
74
75
    test "is reached through assignment_id, not through the credential's own key" do
76
      %{assignment: assignment, plaintext: plaintext} =
77
        admit("credential-auth-shape", "bx_credaut3")
78
79
      "oa_assignment_" <> rest = plaintext
80
      [token_uuid, _secret] = String.split(rest, ".", parts: 2)
81
82
      # The token names the assignment. The credential row has an unrelated
83
      # primary key. Both facts have to hold for the lookup under test to be
84
      # the right one.
85
      assert token_uuid == assignment.id
86
      assert %AssignmentCredential{} = credential = Assignments.credential(assignment)
87
      assert credential.assignment_id == assignment.id
88
      refute credential.id == assignment.id
89
90
      assert {:ok, principal} = Assignments.authenticate(plaintext)
91
      assert principal.credential_id == credential.id
92
    end
93
  end
94
95
  describe "the check is no looser than it was" do
96
    test "a malformed token is refused" do
97
      assert {:error, :invalid_assignment_credential} =
98
               Assignments.authenticate("oa_assignment_not-a-credential")
99
100
      assert {:error, :invalid_assignment_credential} = Assignments.authenticate("oa_assignment_")
101
      assert {:error, :invalid_assignment_credential} = Assignments.authenticate("nonsense")
102
    end
103
104
    test "a well-formed token naming another assignment is refused" do
105
      %{assignment: assignment} = admit("credential-auth-foreign-a", "bx_credaut4")
106
      %{plaintext: other_plaintext} = admit("credential-auth-foreign-b", "bx_credaut5")
107
108
      "oa_assignment_" <> rest = other_plaintext
109
      [_other_uuid, other_secret] = String.split(rest, ".", parts: 2)
110
111
      # This assignment's id, another assignment's secret. The row is found and
112
      # the digest comparison is what refuses it.
113
      forged = "oa_assignment_" <> assignment.id <> "." <> other_secret
114
115
      assert {:error, :invalid_assignment_credential} = Assignments.authenticate(forged)
116
    end
117
118
    test "a revoked credential is refused" do
119
      %{assignment: assignment, plaintext: plaintext} =
120
        admit("credential-auth-revoked", "bx_credaut6")
121
122
      assert {:ok, _principal} = Assignments.authenticate(plaintext)
123
124
      assignment
125
      |> Assignments.credential()
126
      |> AssignmentCredential.changeset(%{revoked_at: DateTime.utc_now()})
127
      |> Repo.update!()
128
129
      assert {:error, :invalid_assignment_credential} = Assignments.authenticate(plaintext)
130
    end
131
132
    test "an expired credential is refused" do
133
      %{assignment: assignment, plaintext: plaintext} =
134
        admit("credential-auth-expired", "bx_credaut7")
135
136
      assignment
137
      |> Assignments.credential()
138
      |> AssignmentCredential.changeset(%{
139
        expires_at: DateTime.add(DateTime.utc_now(), -1, :second)
140
      })
141
      |> Repo.update!()
142
143
      assert {:error, :invalid_assignment_credential} = Assignments.authenticate(plaintext)
144
    end
145
  end
146
147
  defp admit(owner_slug, box_id) do
148
    owner = repository_user_fixture(owner_slug)
149
    repository = repository_with_member_fixture(owner, %{visibility: "public"}, "owner")
150
151
    {:ok, issue} =
152
      Issues.create_issue(repository, %{title: "Prove the credential", body: "A body."})
153
154
    {:ok, conversation} = Conversations.ensure_conversation(owner)
155
156
    {:ok, box} =
157
      %ConversationBox{}
158
      |> ConversationBox.changeset(%{
159
        conversation_id: conversation.id,
160
        box_id: box_id,
161
        state: "ready",
162
        setup_status: "done"
163
      })
164
      |> Repo.insert()
165
166
    branch = "agent/issue-#{issue.number}"
167
168
    assert {:ok, assignment, plaintext} =
169
             Assignments.create(%{
170
               "target_kind" => "box",
171
               "box_id" => box.box_id,
172
               "conversation_id" => conversation.id,
173
               "repository_id" => repository.id,
174
               "issue_number" => issue.number,
175
               "branch" => branch,
176
               "requesting_user" => owner,
177
               "requesting_principal" => owner
178
             })
179
180
    %{
181
      assignment: assignment,
182
      plaintext: plaintext,
183
      repository: repository,
184
      branch: branch,
185
      owner: owner
186
    }
187
  end
188
189
  # The run exists only so `create/1` reaches its return. Any command POST
190
  # parks the worker before it can transition:  a run that reaches a terminal
191
  # state finalizes its assignment, which revokes the credential, and
192
  # `start_target/7` then writes `state: "running"` back over the terminal
193
  # state it read a moment earlier — leaving a live-looking assignment holding
194
  # a revoked credential. A real run takes seconds and never lands in that
195
  # window; a stubbed one closes it in microseconds. `start_run/6` returns as
196
  # soon as the worker's `init/1` does, so parking here does not block
197
  # `create/1`.
198
  defp stub_provider(conn) do
199
    {:ok, raw, conn} = Plug.Conn.read_body(conn)
200
201
    case Jason.decode(raw) do
202
      {:ok, %{"command" => command}} when is_binary(command) ->
203
        Process.sleep(30_000)
204
        Req.Test.json(conn, %{"stdout" => "4242\n"})
205
206
      _other ->
207
        Req.Test.json(conn, %{
208
          "box" => %{"id" => "bx_credaut2", "state" => "ready", "setupStatus" => "done"}
209
        })
210
    end
211
  end
212
213
  defp restore(key, nil), do: Application.delete_env(:openagents, key)
214
  defp restore(key, value), do: Application.put_env(:openagents, key, value)
215
end
test/openagents/forge/assignment_git_push_test.exs modified +10 -3

@@ -230,14 +230,21 @@ defmodule OpenAgents.Forge.AssignmentGitPushTest do

230 230
    end
231 231
  end
232 232
233
  # Mints the way `Assignments.persist_assignment/7` mints: the token carries
234
  # the *assignment* id and the credential row takes its own autogenerated key.
235
  #
236
  # This helper used to set `%AssignmentCredential{id: credential_id}` and
237
  # embed that credential id in the token, which is the shape `authenticate/1`
238
  # read but not the shape production wrote. The branch-policy tests below
239
  # therefore passed against a credential no real assignment could produce,
240
  # and a credential that could never authenticate shipped underneath them.
233 241
  defp create_credential(assignment, repository, branch) do
234 242
    secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
235
    credential_id = Ecto.UUID.generate()
236
    plaintext = "oa_assignment_" <> credential_id <> "." <> secret
243
    plaintext = "oa_assignment_" <> assignment.id <> "." <> secret
237 244
    digest = :crypto.hash(:sha256, plaintext)
238 245
239 246
    credential =
240
      %AssignmentCredential{id: credential_id}
247
      %AssignmentCredential{}
241 248
      |> AssignmentCredential.changeset(%{
242 249
        assignment_id: assignment.id,
243 250
        token_digest: digest,

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