Let the reader choose an API token's scopes

6fd8b921949f · AtlantisPleb · · parent 47f7a3478ffe

Let the reader choose an API token's scopes

The form issued `chat:account` and `forge:write` whatever the credential was
for, so a token for the box fleet could not be made here at all: the only way
to get one was to mint it on a node through `rpc`. The scopes are now
checkboxes over the selectable set, which is every allowed scope less the
privileged ones — offering a control the server will always refuse is worse
than offering none.

The selection is read back against that list rather than trusted from the
parameters, so a key naming a scope the form never rendered cannot become a
scope on the token. An empty selection is refused rather than issued.

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_web/live/api_tokens_live.ex
  • modified test/openagents_web/live/api_tokens_live_test.exs

Diff

2 files changed, +158 -7

lib/openagents_web/live/api_tokens_live.ex modified +54 -6

@@ -14,6 +14,7 @@ defmodule OpenAgentsWeb.ApiTokensLive do

14 14
     |> assign(:page_title, "API tokens · OpenAgents")
15 15
     |> assign(:issued_token, nil)
16 16
     |> assign(:form, token_form())
17
     |> assign(:selectable_scopes, selectable_scopes())
17 18
     |> stream(:tokens, tokens)}
18 19
  end
19 20

@@ -21,7 +22,7 @@ defmodule OpenAgentsWeb.ApiTokensLive do

21 22
  def handle_event("create", %{"api_token" => params}, socket) do
22 23
    case ApiTokens.create(socket.assigns.current_user, %{
23 24
           "name" => params["name"],
24
           "scopes" => ["chat:account", "forge:write"],
25
           "scopes" => chosen_scopes(params),
25 26
           "lifetime_days" => params["lifetime_days"]
26 27
         }) do
27 28
      {:ok, token, plaintext} ->

@@ -32,7 +33,12 @@ defmodule OpenAgentsWeb.ApiTokensLive do

32 33
         |> stream_insert(:tokens, token, at: 0)}
33 34
34 35
      {:error, _invalid} ->
35
        {:noreply, put_flash(socket, :error, "Choose a name and a lifetime from 1 to 90 days.")}
36
        {:noreply,
37
         put_flash(
38
           socket,
39
           :error,
40
           "Choose a name, at least one scope, and a lifetime from 1 to 90 days."
41
         )}
36 42
    end
37 43
  end
38 44

@@ -55,9 +61,8 @@ defmodule OpenAgentsWeb.ApiTokensLive do

55 61
        <header class="space-y-2">
56 62
          <h1 class="text-3xl font-semibold tracking-tight">API tokens</h1>
57 63
          <p class="text-muted-foreground">
58
            Create an expiring credential for account chat and Forge operations. Tokens carry
59
            <code>chat:account</code>
60
            and <code>forge:write</code>, are stored as digests, and are shown once.
64
            Create an expiring credential and choose what it may do. Scopes are stored on the
65
            token, tokens are stored as digests, and the value is shown once.
61 66
          </p>
62 67
        </header>
63 68

@@ -79,6 +84,20 @@ defmodule OpenAgentsWeb.ApiTokensLive do

79 84
              <.label for={@form[:name].id}>Name</.label>
80 85
              <.input field={@form[:name]} placeholder="Release CLI" required />
81 86
            </.field>
87
            <.field>
88
              <.label>Scopes</.label>
89
              <p class="text-sm text-muted-foreground">
90
                A token carries exactly what you select here. Selecting less is the safer
91
                default: a credential that cannot reach a surface cannot be spent on it.
92
              </p>
93
              <.input
94
                :for={scope <- @selectable_scopes}
95
                field={@form[:"scope_#{scope}"]}
96
                type="checkbox"
97
                checked={@form[:"scope_#{scope}"].value == "true"}
98
                label={scope_label(scope)}
99
              />
100
            </.field>
82 101
            <.field>
83 102
              <.label for={@form[:lifetime_days].id}>Lifetime in days</.label>
84 103
              <.input

@@ -129,8 +148,37 @@ defmodule OpenAgentsWeb.ApiTokensLive do

129 148
    """
130 149
  end
131 150
151
  # The scopes an ordinary account may select. Privileged scopes are excluded
152
  # here as well as refused by `ApiTokens.create/2`: a control this account can
153
  # tick and the server will always reject is worse than no control at all.
154
  defp selectable_scopes do
155
    ApiTokens.allowed_scopes() -- ApiTokens.privileged_scopes()
156
  end
157
158
  # Checkboxes arrive one parameter per scope, so the selection is read back
159
  # rather than trusted from a list the form could have named freely. An
160
  # unknown key cannot become a scope this way, and `ApiTokens.create/2`
161
  # refuses an empty selection.
162
  defp chosen_scopes(params) do
163
    Enum.filter(selectable_scopes(), fn scope -> params["scope_#{scope}"] == "true" end)
164
  end
165
166
  defp scope_label("chat:account"), do: "chat:account — account chat and threads"
167
  defp scope_label("forge:write"), do: "forge:write — push and forge writes"
168
  defp scope_label("deployments:write"), do: "deployments:write — deployment records"
169
  defp scope_label("box:control"), do: "box:control — cloud box fleet"
170
  defp scope_label("computer:control"), do: "computer:control — paired computers"
171
  defp scope_label(scope), do: scope
172
173
  # The two scopes signing in already grants are ticked, so the common case is
174
  # one click and the rest are a deliberate addition.
132 175
  defp token_form do
133
    to_form(%{"name" => "", "lifetime_days" => "30"}, as: :api_token)
176
    defaults =
177
      Map.new(selectable_scopes(), fn scope ->
178
        {"scope_#{scope}", to_string(scope in ["chat:account", "forge:write"])}
179
      end)
180
181
    to_form(Map.merge(defaults, %{"name" => "", "lifetime_days" => "30"}), as: :api_token)
134 182
  end
135 183
136 184
  defp format_time(%DateTime{} = value),
test/openagents_web/live/api_tokens_live_test.exs modified +104 -1

@@ -13,14 +13,37 @@ defmodule OpenAgentsWeb.ApiTokensLiveTest do

13 13
    assert has_element?(view, "#api-token-form")
14 14
    assert has_element?(view, "#api-tokens-empty")
15 15
16
    # The two scopes signing in already grants arrive ticked, so the common
17
    # case stays one click.
18
    assert has_element?(
19
             view,
20
             ~s(input[type="checkbox"][name="api_token[scope_chat:account]"][checked])
21
           )
22
23
    assert has_element?(
24
             view,
25
             ~s(input[type="checkbox"][name="api_token[scope_forge:write]"][checked])
26
           )
27
28
    refute has_element?(
29
             view,
30
             ~s(input[type="checkbox"][name="api_token[scope_box:control]"][checked])
31
           )
32
16 33
    view
17 34
    |> form("#api-token-form", %{
18
      "api_token" => %{"name" => "Local release", "lifetime_days" => "7"}
35
      "api_token" => %{
36
        "name" => "Local release",
37
        "lifetime_days" => "7",
38
        "scope_chat:account" => "true",
39
        "scope_forge:write" => "true"
40
      }
19 41
    })
20 42
    |> render_submit()
21 43
22 44
    assert has_element?(view, "#issued-api-token")
23 45
    assert [token] = ApiTokens.list(user)
46
    assert Enum.sort(token.scopes) == ["chat:account", "forge:write"]
24 47
    assert has_element?(view, "#revoke-api-token-#{token.id}")
25 48
26 49
    view |> element("#revoke-api-token-#{token.id}") |> render_click()

@@ -29,6 +52,86 @@ defmodule OpenAgentsWeb.ApiTokensLiveTest do

29 52
    assert Repo.reload!(token).revoked_at
30 53
  end
31 54
55
  test "the reader chooses the scopes, and gets exactly what was ticked", %{conn: conn} do
56
    user = github_user("api-token-scopes")
57
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
58
    {:ok, view, _html} = live(conn, ~p"/settings/api-tokens")
59
60
    # The gap this closes: the form issued `chat:account` and `forge:write`
61
    # whatever the reader wanted, so a token for the box fleet could not be
62
    # made here at all and had to be minted on a node.
63
    view
64
    |> form("#api-token-form", %{
65
      "api_token" => %{
66
        "name" => "Box fleet",
67
        "lifetime_days" => "7",
68
        "scope_box:control" => "true",
69
        "scope_chat:account" => "true",
70
        "scope_forge:write" => "false",
71
        "scope_deployments:write" => "false",
72
        "scope_computer:control" => "false"
73
      }
74
    })
75
    |> render_submit()
76
77
    assert [token] = ApiTokens.list(user)
78
    assert Enum.sort(token.scopes) == ["box:control", "chat:account"]
79
    refute "forge:write" in token.scopes
80
  end
81
82
  test "a privileged scope is not offered, and a forged one is not honored", %{conn: conn} do
83
    user = github_user("api-token-privileged")
84
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
85
    {:ok, view, html} = live(conn, ~p"/settings/api-tokens")
86
87
    refute html =~ "deployments:promote"
88
89
    # A parameter naming a scope the form never offered is read back against
90
    # the selectable list, so it cannot become a scope on the token. It is
91
    # submitted as an extra parameter because the form carries no such input —
92
    # which is the first half of the claim.
93
    view
94
    |> form("#api-token-form", %{
95
      "api_token" => %{
96
        "name" => "Forged",
97
        "lifetime_days" => "7",
98
        "scope_chat:account" => "true",
99
        "scope_forge:write" => "false",
100
        "scope_deployments:write" => "false",
101
        "scope_box:control" => "false",
102
        "scope_computer:control" => "false"
103
      }
104
    })
105
    |> render_submit(%{"api_token" => %{"scope_deployments:promote" => "true"}})
106
107
    assert [token] = ApiTokens.list(user)
108
    assert token.scopes == ["chat:account"]
109
  end
110
111
  test "a credential with no scope is refused rather than issued empty", %{conn: conn} do
112
    user = github_user("api-token-no-scope")
113
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
114
    {:ok, view, _html} = live(conn, ~p"/settings/api-tokens")
115
116
    html =
117
      view
118
      |> form("#api-token-form", %{
119
        "api_token" => %{
120
          "name" => "Nothing",
121
          "lifetime_days" => "7",
122
          "scope_chat:account" => "false",
123
          "scope_forge:write" => "false",
124
          "scope_deployments:write" => "false",
125
          "scope_box:control" => "false",
126
          "scope_computer:control" => "false"
127
        }
128
      })
129
      |> render_submit()
130
131
    assert html =~ "at least one scope"
132
    assert ApiTokens.list(user) == []
133
  end
134
32 135
  test "anonymous browser is redirected without revealing the settings surface", %{conn: conn} do
33 136
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/settings/api-tokens")
34 137
  end

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