Give a computer's repository grant the surface that creates it

773ad6806030 · AtlantisPleb · · parent bd7939c6f6e3

Give a computer's repository grant the surface that creates it

`repository_machine_grants` was a complete, enforced authorization input with
no writer. `OpenAgentsWeb.Plugs.ForgeGitAuth` accepted a paired computer's
`smct_` token and assigned `%{kind: :machine, id: machine.id}`,
`OpenAgents.Forge.GitHTTP` routed that principal to
`OpenAgents.Repositories.machine_access?/3` for both read and write, and that
predicate required a row that no controller, LiveView, route, or API could
create. `grant_machine/4` had no caller in `lib/` — only two in
`git_http_test.exs`. So every Git request a paired computer made answered
`404 unknown repository`, indistinguishable from an unauthorized one, and the
tests passed because they inserted the row themselves. There was no withdrawal
path at all.

The mechanism is kept and given its surface rather than removed. IDENTITY-010's
assignment credential is repository-and-branch scoped and lives as long as one
assignment; a computer that clones or fetches outside an assignment has no
other path, and narrowing the Git plane's principal set would have taken that
away.

`grant_machine_access/4` and `revoke_machine_access/3` are the write and the
withdrawal, and `/computers` is the surface — the page the owner already uses
to approve and revoke a computer. No route is added, because the authority
belongs in the handler, which is where a route table cannot see it. Neither
identifier the event carries selects anything on its own: the computer resolves
through `OpenAgents.Machines.get_machine/2` against the acting account, and the
repository through that account's own `owner` or `maintainer` membership, so a
foreign computer, a repository this account does not administer, and an
identifier that names nothing are one refusal. `grant_machine/4` is now private
behind that entry point, and the LiveView reaches no `OpenAgents.Repo`.

The withdrawal deletes the row and audits it as
`repository.machine_grant.revoked`, carrying the account that created the
grant — `repository_machine_grants.created_by_user_id` had no reader, and the
audit is where it survives the row. `repository_machine_grants_machine_id_index`
gains one too, from the per-computer grant listing. Revoking the computer still
ends every grant's effect without deleting any, because `machine_access?/3`
joins the computer's status and token expiry.

`OpenAgents.Forge.GitHTTPTest` now obtains its grant through the same entry
point, so the real `git clone` and `git push` over HTTP are authorized by a
grant an owner could actually make. REPOSITORY-001 is amended with the
reachable principal.

Closes #182.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTmy4SEXrHXouw5sZbs3f4
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes
#182

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 INVARIANTS.md
  • modified assets/css/openagents.css
  • modified lib/openagents/repositories.ex
  • modified lib/openagents_web/live/computers_live.ex
  • modified test/openagents/forge/git_http_test.exs
  • added test/openagents_web/live/computers_repository_access_test.exs

Diff

6 files changed, +704 -7

INVARIANTS.md modified +50 -1

@@ -3229,6 +3229,55 @@ model, each named below.

3229 3229
  and `OpenAgentsWeb.RepositoryAccess` (a narrower file-level allowlist
3230 3230
  layered above row admission it takes from `get_visible_by_path!/3`).
3231 3231
3232
Amended 2026-08-24 (issue #182): the `:machine` principal the Git transport
3233
admits is now reachable.
3234
3235
`repository_machine_grants` was a complete, enforced authorization input with
3236
no writer. `OpenAgentsWeb.Plugs.ForgeGitAuth` accepted a paired computer's
3237
`smct_` token and assigned `%{kind: :machine, id: machine.id}`,
3238
`OpenAgents.Forge.GitHTTP` routed that principal to
3239
`OpenAgents.Repositories.machine_access?/3` for both read and write, and that
3240
predicate required a `repository_machine_grants` row that no controller,
3241
LiveView, route, or API could create. So every Git request a paired computer
3242
made answered `404 unknown repository`, indistinguishable from an unauthorized
3243
one, and the tests passed because they inserted the row themselves.
3244
3245
The mechanism is kept and given its surface rather than removed. IDENTITY-010's
3246
assignment credential is repository-and-branch scoped and lives as long as one
3247
assignment; a computer that clones or fetches outside an assignment has no
3248
other path, and narrowing the principal set would have taken that away.
3249
3250
`OpenAgents.Repositories.grant_machine_access/4` and
3251
`revoke_machine_access/3` are the write and the withdrawal, and
3252
`OpenAgentsWeb.ComputersLive` is the surface, on the `/computers` page the
3253
owner already uses to approve and revoke a computer. No route is added: the
3254
authority is in the handler, which is where a route table cannot see it, so
3255
neither identifier the event carries selects anything on its own. The computer
3256
resolves through `OpenAgents.Machines.get_machine/2` against the acting
3257
account, and the repository through that account's own `owner` or `maintainer`
3258
membership, so a foreign computer, a repository this account does not
3259
administer, and an identifier that names nothing are one refusal (IDENTITY-002).
3260
`grant_machine/4` is private behind that entry point, so there is no way to
3261
reach the write without passing an acting account for both halves. The
3262
LiveView reaches no `OpenAgents.Repo`.
3263
3264
The grant is standing authority, so it owes a withdrawal, and there was none:
3265
`revoke_machine_access/3` deletes the row and audits it as
3266
`repository.machine_grant.revoked` beside the existing
3267
`repository.machine_grant.updated`. The audit carries the account that created
3268
the grant, which is otherwise lost with the row. Revoking the computer itself
3269
still ends every grant's effect without deleting any — `machine_access?/3`
3270
joins the computer's status and token expiry — and that remains the all-or-
3271
nothing path.
3272
3273
Evidence for this clause: `OpenAgents.Repositories.grant_machine_access/4`,
3274
`OpenAgents.Repositories.revoke_machine_access/3`,
3275
`OpenAgentsWeb.ComputersLive`,
3276
`test/openagents_web/live/computers_repository_access_test.exs`, and
3277
`OpenAgents.Forge.GitHTTPTest`, which obtains its grant through that entry
3278
point and then clones and pushes with the computer's own credential over real
3279
HTTP.
3280
3232 3281
Three restatements were removed rather than declared, and two were wrong.
3233 3282
`OpenAgents.Issues.get_issue_by_path!/3` and
3234 3283
`OpenAgents.Projects.get_project_by_path!/3` each carried a copy of the public

@@ -4447,7 +4496,7 @@ contract; the invariant prose above defines the assertion, not the filename.

4447 4496
| STATUS-001 | `test/openagents/network_status_test.exs`, `test/openagents_web/live/network_status_live_test.exs` |
4448 4497
| CAPACITY-001 | `test/openagents/capacity_test.exs` |
4449 4498
| TRANSPARENCY-001 | `test/openagents/forge/visibility_test.exs`, `test/openagents/forge/browse_test.exs`, `test/openagents_web/live/code_live_test.exs`, `test/openagents_web/transparency_surface_test.exs` |
4450
| REPOSITORY-001 | `test/openagents/repositories/visibility_join_test.exs`, `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
4499
| REPOSITORY-001 | `test/openagents/repositories/visibility_join_test.exs`, `test/openagents_web/live/computers_repository_access_test.exs`, `test/openagents/repository_lifecycle_test.exs`, `test/openagents/repositories/provisioner_test.exs`, `test/openagents_web/controllers/repository_controller_test.exs`, `test/openagents/issues_workspace_test.exs`, `test/openagents_web/live/issue_workspace_live_test.exs`, `test/openagents_web/live/project_workspace_live_test.exs`, `test/openagents/forge/git_http_test.exs` |
4451 4500
| API-001 | `test/openagents_web/controllers/api_extension_governance_test.exs`, `test/openagents/issue_progress_test.exs` |
4452 4501
| CONTRIBUTION-001 | `test/openagents_web/contribution_contract_test.exs` |
4453 4502
| REPOSITORY-002 | `ops/ci/push-remote-check.sh`, `ops/dev/install-push-guard.sh`, `test/openagents/push_remote_contract_test.exs` |
assets/css/openagents.css modified +63

@@ -484,6 +484,69 @@

484 484
    margin: 0;
485 485
  }
486 486
487
  .computer-card__repositories {
488
    display: grid;
489
    gap: 10px;
490
    padding: 12px;
491
    border: 1px solid var(--line-faint);
492
    border-radius: var(--radius-sm);
493
    background: var(--wash-hover);
494
  }
495
496
  .computer-card__repositories h4 {
497
    margin: 0;
498
    color: var(--text-dim);
499
    font-family: var(--font-mono);
500
    font-size: 0.625rem;
501
    letter-spacing: 0.14em;
502
    text-transform: uppercase;
503
  }
504
505
  .computer-card__repositories p {
506
    margin: 0;
507
    color: var(--text-muted);
508
    font-size: 0.75rem;
509
  }
510
511
  .computer-card__grants {
512
    display: grid;
513
    gap: 6px;
514
    margin: 0;
515
    padding: 0;
516
    list-style: none;
517
  }
518
519
  .computer-card__grants li {
520
    display: flex;
521
    align-items: center;
522
    flex-wrap: wrap;
523
    gap: 8px;
524
  }
525
526
  .computer-card__grant-name {
527
    flex: 1 1 auto;
528
    min-width: 0;
529
    overflow: hidden;
530
    color: var(--text-primary);
531
    font-family: var(--font-mono);
532
    font-size: 0.75rem;
533
    text-overflow: ellipsis;
534
    white-space: nowrap;
535
  }
536
537
  .computer-card__grant-form {
538
    display: grid;
539
    grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
540
    align-items: end;
541
    gap: 10px;
542
  }
543
544
  @media (max-width: 720px) {
545
    .computer-card__grant-form {
546
      grid-template-columns: minmax(0, 1fr);
547
    }
548
  }
549
487 550
  .computer-card__facts > div {
488 551
    display: grid;
489 552
    min-width: 0;
lib/openagents/repositories.ex modified +151 -3

@@ -22,6 +22,9 @@ defmodule OpenAgents.Repositories do

22 22
23 23
  @writable_roles ~w(owner maintainer contributor)
24 24
  @all_roles ~w(owner maintainer contributor viewer)
25
  # A computer grant hands a long-lived `smct_` token standing access to a
26
  # repository, so it takes the two roles that already administer one.
27
  @machine_grant_roles ~w(owner maintainer)
25 28
  @repository_namespace_limit 100
26 29
27 30
  # GitHub's default label set. Every created or imported repository starts

@@ -758,10 +761,155 @@ defmodule OpenAgents.Repositories do

758 761
    )
759 762
  end
760 763
761
  def grant_machine(%Repository{} = repository, %User{} = actor, %Machine{} = machine, operations)
762
      when is_list(operations) do
764
  @doc """
765
  Repositories this account administers, and can therefore hand to one of its
766
  computers.
767
768
  This is a membership question, not a visibility one: it starts from the
769
  actor's own `repository_memberships` rows in the two roles that may
770
  administer a repository, so it neither composes nor restates
771
  `readable_by/2` (REPOSITORY-001).
772
  """
773
  @spec list_grantable_repositories(User.t() | nil) :: [Repository.t()]
774
  def list_grantable_repositories(%User{id: user_id}) do
775
    Repo.all(
776
      from repository in Repository,
777
        join: membership in Membership,
778
        on: membership.repository_id == repository.id,
779
        where: membership.user_id == ^user_id and membership.role in @machine_grant_roles,
780
        order_by: [asc: repository.owner, asc: repository.name]
781
    )
782
  end
783
784
  def list_grantable_repositories(_actor), do: []
785
786
  @doc """
787
  The repository grants one of this account's computers holds.
788
789
  A computer this account does not own is indistinguishable from one that holds
790
  nothing.
791
  """
792
  @spec list_machine_grants(User.t(), String.t()) :: [MachineGrant.t()]
793
  def list_machine_grants(%User{} = actor, machine_id) when is_binary(machine_id) do
794
    case owned_machine(actor, machine_id) do
795
      {:ok, machine} ->
796
        Repo.all(
797
          from grant in MachineGrant,
798
            join: repository in Repository,
799
            on: repository.id == grant.repository_id,
800
            where: grant.machine_id == ^machine.id,
801
            order_by: [asc: repository.owner, asc: repository.name],
802
            preload: [repository: repository]
803
        )
804
805
      {:error, _reason} ->
806
        []
807
    end
808
  end
809
810
  def list_machine_grants(_actor, _machine_id), do: []
811
812
  @doc """
813
  Grant one of this account's computers access to one repository it
814
  administers.
815
816
  This is the entry point the `/computers` surface calls, and the only one:
817
  `grant_machine/4` below is private, so a caller cannot reach the write
818
  without passing an acting account for both halves. The grant was previously
819
  unreachable — the table was read by `OpenAgents.Forge.GitHTTP` on every
820
  request a paired computer made and written by nothing outside tests, so every
821
  such request answered `404 unknown repository`.
822
823
  Both halves are resolved from the acting account rather than from the
824
  caller's identifiers: a computer another account owns, a repository this
825
  account does not administer, and an identifier that names nothing are all the
826
  same refusal.
827
  """
828
  @spec grant_machine_access(User.t(), String.t(), String.t(), [String.t()]) ::
829
          {:ok, MachineGrant.t()} | {:error, atom() | Ecto.Changeset.t()}
830
  def grant_machine_access(%User{} = actor, machine_id, repository_id, operations)
831
      when is_binary(machine_id) and is_binary(repository_id) and is_list(operations) do
832
    with {:ok, machine} <- owned_machine(actor, machine_id),
833
         {:ok, repository} <- administered_repository(actor, repository_id) do
834
      grant_machine(repository, actor, machine, operations)
835
    end
836
  end
837
838
  def grant_machine_access(_actor, _machine_id, _repository_id, _operations),
839
    do: {:error, :machine_not_owned}
840
841
  @doc """
842
  Withdraw a computer's access to one repository.
843
844
  A grant outlives the delegation that needed it, so the surface that creates
845
  one owes a way to take it back. Revoking the computer itself already ends the
846
  grant's effect — `machine_access?/3` joins the computer's status and token
847
  expiry — but that is all or nothing, and this is not.
848
849
  The audit record carries the account that created the grant, which is the
850
  only place that survives the row.
851
  """
852
  @spec revoke_machine_access(User.t(), String.t(), String.t()) ::
853
          {:ok, MachineGrant.t()} | {:error, atom()}
854
  def revoke_machine_access(%User{} = actor, machine_id, repository_id)
855
      when is_binary(machine_id) and is_binary(repository_id) do
856
    with {:ok, machine} <- owned_machine(actor, machine_id),
857
         {:ok, repository} <- administered_repository(actor, repository_id),
858
         %MachineGrant{} = grant <-
859
           Repo.get_by(MachineGrant, repository_id: repository.id, machine_id: machine.id) do
860
      Repo.transaction(fn ->
861
        Repo.delete!(grant)
862
863
        Audit.record!(
864
          "repository.machine_grant.revoked",
865
          {:user, actor.id},
866
          "machine_grant",
867
          grant.id,
868
          repository_id: repository.id,
869
          metadata: %{
870
            "machine_id" => machine.id,
871
            "operations" => grant.operations,
872
            "granted_by_user_id" => grant.created_by_user_id
873
          }
874
        )
875
876
        grant
877
      end)
878
    else
879
      nil -> {:error, :grant_not_found}
880
      {:error, reason} -> {:error, reason}
881
    end
882
  end
883
884
  def revoke_machine_access(_actor, _machine_id, _repository_id),
885
    do: {:error, :machine_not_owned}
886
887
  defp owned_machine(%User{id: user_id}, machine_id) do
888
    case OpenAgents.Machines.get_machine(user_id, machine_id) do
889
      {:ok, machine} -> {:ok, machine}
890
      {:error, _reason} -> {:error, :machine_not_owned}
891
    end
892
  end
893
894
  defp administered_repository(%User{} = actor, repository_id) do
895
    with {:ok, id} <- Ecto.UUID.cast(repository_id),
896
         %Repository{} = repository <- Repo.get(Repository, id),
897
         role when role in @machine_grant_roles <- membership_role(repository, actor) do
898
      {:ok, repository}
899
    else
900
      _absent_or_foreign -> {:error, :repository_not_allowed}
901
    end
902
  end
903
904
  defp grant_machine(
905
         %Repository{} = repository,
906
         %User{} = actor,
907
         %Machine{} = machine,
908
         operations
909
       )
910
       when is_list(operations) do
763 911
    with true <- machine.user_id == actor.id or {:error, :machine_not_owned},
764
         role when role in ~w(owner maintainer) <- membership_role(repository, actor) do
912
         role when role in @machine_grant_roles <- membership_role(repository, actor) do
765 913
      Repo.transaction(fn ->
766 914
        grant =
767 915
          %MachineGrant{}
lib/openagents_web/live/computers_live.ex modified +181 -1

@@ -8,6 +8,7 @@ defmodule OpenAgentsWeb.ComputersLive do

8 8
  alias OpenAgents.Computer
9 9
  alias OpenAgents.Machines
10 10
  alias OpenAgents.Machines.Machine
11
  alias OpenAgents.Repositories
11 12
12 13
  @presence_refresh_ms 15_000
13 14

@@ -24,6 +25,9 @@ defmodule OpenAgentsWeb.ComputersLive do

24 25
      |> assign(:subscribed_computer_ids, MapSet.new())
25 26
      |> assign(:presence, %{})
26 27
      |> assign(:computer_count, 0)
28
      |> assign(:grant_form, to_form(%{}, as: :grant))
29
      |> assign(:grantable_repositories, [])
30
      |> assign(:repository_grants, %{})
27 31
      |> load_computers()
28 32
29 33
    if connected?(socket), do: schedule_presence_refresh()

@@ -102,6 +106,78 @@ defmodule OpenAgentsWeb.ComputersLive do

102 106
    end
103 107
  end
104 108
109
  # Repository access for a computer. `repository_machine_grants` is the only
110
  # thing `OpenAgents.Forge.GitHTTP` consults for a `{:machine, id}` principal,
111
  # and until this event existed nothing wrote a row, so every Git request a
112
  # paired computer made answered `404 unknown repository` (#182).
113
  #
114
  # Neither identifier selects anything on its own: `OpenAgents.Repositories`
115
  # resolves the computer through the acting account and the repository through
116
  # that account's administering membership, so a foreign computer, a
117
  # repository this account does not administer, and an identifier that names
118
  # nothing are one refusal (IDENTITY-002).
119
  def handle_event(
120
        "grant_repository_access",
121
        %{"grant" => %{"machine_id" => machine_id, "repository_id" => repository_id} = params},
122
        socket
123
      ) do
124
    operations = if params["operations"] == "write", do: ~w(read write), else: ~w(read)
125
126
    case Repositories.grant_machine_access(
127
           socket.assigns.current_user,
128
           machine_id,
129
           repository_id,
130
           operations
131
         ) do
132
      {:ok, grant} ->
133
        {:noreply,
134
         socket
135
         |> assign(:pairing_error, nil)
136
         |> assign(:operation_success, %{
137
           id: "repository-grant-success",
138
           label: "REPOSITORY GRANTED",
139
           message:
140
             "The computer can now #{Enum.join(grant.operations, " and ")} that repository over Git."
141
         })
142
         |> load_computers()}
143
144
      {:error, reason} ->
145
        {:noreply,
146
         socket
147
         |> assign(:operation_success, nil)
148
         |> assign(:pairing_error, grant_error(reason))}
149
    end
150
  end
151
152
  def handle_event(
153
        "revoke_repository_access",
154
        %{"id" => machine_id, "repository-id" => repository_id},
155
        socket
156
      ) do
157
    case Repositories.revoke_machine_access(
158
           socket.assigns.current_user,
159
           machine_id,
160
           repository_id
161
         ) do
162
      {:ok, _grant} ->
163
        {:noreply,
164
         socket
165
         |> assign(:pairing_error, nil)
166
         |> assign(:operation_success, %{
167
           id: "repository-grant-revoked",
168
           label: "REPOSITORY WITHDRAWN",
169
           message: "That computer can no longer reach the repository over Git."
170
         })
171
         |> load_computers()}
172
173
      {:error, reason} ->
174
        {:noreply,
175
         socket
176
         |> assign(:operation_success, nil)
177
         |> assign(:pairing_error, grant_error(reason))}
178
    end
179
  end
180
105 181
  def handle_event("revoke_computer", %{"id" => machine_id}, socket) do
106 182
    case Machines.revoke_machine(socket.assigns.current_user, machine_id) do
107 183
      {:ok, machine} ->

@@ -178,7 +254,8 @@ defmodule OpenAgentsWeb.ComputersLive do

178 254
  def handle_info(_message, socket), do: {:noreply, socket}
179 255
180 256
  defp load_computers(socket) do
181
    machines = Machines.list_machines(socket.assigns.current_user.id)
257
    user = socket.assigns.current_user
258
    machines = Machines.list_machines(user.id)
182 259
    socket = subscribe_to_computers(socket, machines)
183 260
184 261
    presence =

@@ -186,9 +263,16 @@ defmodule OpenAgentsWeb.ComputersLive do

186 263
        {machine.id, machine.status == "active" and Computer.online?(machine.id)}
187 264
      end)
188 265
266
    # The grants render inside the stream, so they are reloaded and the stream
267
    # is reset together — an assign that changes streamed content and is not
268
    # re-streamed with it goes stale on the client.
269
    grants = Map.new(machines, &{&1.id, Repositories.list_machine_grants(user, &1.id)})
270
189 271
    socket
190 272
    |> assign(:presence, presence)
191 273
    |> assign(:computer_count, length(machines))
274
    |> assign(:grantable_repositories, Repositories.list_grantable_repositories(user))
275
    |> assign(:repository_grants, grants)
192 276
    |> stream(:computers, machines, reset: true)
193 277
  end
194 278

@@ -217,6 +301,17 @@ defmodule OpenAgentsWeb.ComputersLive do

217 301
  defp pairing_error(:too_many_machines), do: "Computer limit reached. Revoke one to free a slot."
218 302
  defp pairing_error(_reason), do: "Pairing failed."
219 303
304
  defp grant_error(:machine_not_owned), do: "Computer not found."
305
  defp grant_error(:repository_not_allowed), do: "You do not administer that repository."
306
  defp grant_error(:grant_not_found), do: "That computer has no access to that repository."
307
  defp grant_error(_reason), do: "Repository access could not be changed."
308
309
  defp grants_for(grants, machine), do: Map.get(grants, machine.id, [])
310
311
  defp operations_label(operations) do
312
    if "write" in operations, do: "Read and write", else: "Read only"
313
  end
314
220 315
  defp online?(machine, presence) do
221 316
    machine.status == "active" and Map.get(presence, machine.id, false)
222 317
  end

@@ -414,6 +509,91 @@ defmodule OpenAgentsWeb.ComputersLive do

414 509
                    </div>
415 510
                  </dl>
416 511
512
                  <section
513
                    :if={machine.status == "active"}
514
                    id={"repository-access-#{machine.id}"}
515
                    class="computer-card__repositories"
516
                    aria-label={"Repository access for #{machine.name}"}
517
                  >
518
                    <h4>Repository access</h4>
519
                    <p>
520
                      A computer reaches the forge over Git with its own credential. It can read
521
                      or write only the repositories granted here.
522
                    </p>
523
524
                    <ul
525
                      :if={grants_for(@repository_grants, machine) != []}
526
                      class="computer-card__grants"
527
                    >
528
                      <li
529
                        :for={grant <- grants_for(@repository_grants, machine)}
530
                        id={"grant-#{machine.id}-#{grant.repository_id}"}
531
                      >
532
                        <span class="computer-card__grant-name">
533
                          {grant.repository.owner}/{grant.repository.name}
534
                        </span>
535
                        <.badge variant={:dim}>{operations_label(grant.operations)}</.badge>
536
                        <.text_button
537
                          id={"revoke-grant-#{machine.id}-#{grant.repository_id}"}
538
                          tone={:danger}
539
                          phx-click="revoke_repository_access"
540
                          phx-value-id={machine.id}
541
                          phx-value-repository-id={grant.repository_id}
542
                          phx-disable-with="Withdrawing…"
543
                        >
544
                          Withdraw
545
                        </.text_button>
546
                      </li>
547
                    </ul>
548
549
                    <p
550
                      :if={grants_for(@repository_grants, machine) == []}
551
                      class="computer-card__grants-empty"
552
                    >
553
                      No repositories granted. Git requests from this computer are refused.
554
                    </p>
555
556
                    <.form
557
                      :if={@grantable_repositories != []}
558
                      for={@grant_form}
559
                      id={"repository-grant-form-#{machine.id}"}
560
                      class="computer-card__grant-form"
561
                      phx-submit="grant_repository_access"
562
                    >
563
                      <input type="hidden" name="grant[machine_id]" value={machine.id} />
564
                      <.input
565
                        id={"grant-repository-#{machine.id}"}
566
                        name="grant[repository_id]"
567
                        value=""
568
                        type="select"
569
                        label="Repository"
570
                        prompt="Choose a repository"
571
                        options={
572
                          Enum.map(
573
                            @grantable_repositories,
574
                            &{"#{&1.owner}/#{&1.name}", &1.id}
575
                          )
576
                        }
577
                        required
578
                      />
579
                      <.input
580
                        id={"grant-operations-#{machine.id}"}
581
                        name="grant[operations]"
582
                        value="read"
583
                        type="select"
584
                        label="Access"
585
                        options={[{"Read only", "read"}, {"Read and write", "write"}]}
586
                      />
587
                      <.button
588
                        id={"grant-repository-submit-#{machine.id}"}
589
                        type="submit"
590
                        phx-disable-with="Granting…"
591
                      >
592
                        Grant access
593
                      </.button>
594
                    </.form>
595
                  </section>
596
417 597
                  <footer :if={machine.status == "active"} class="computer-card__actions">
418 598
                    <p>Revoking closes its connection and can stop work currently running there.</p>
419 599
                    <.text_button
test/openagents/forge/git_http_test.exs modified +6 -2

@@ -432,7 +432,11 @@ defmodule OpenAgents.Forge.GitHTTPTest do

432 432
    assert ungranted_status != 0
433 433
    assert ungranted_output =~ "404" or ungranted_output =~ "not found"
434 434
435
    assert {:ok, _grant} = Repositories.grant_machine(repository, user, machine, ["read"])
435
    # Through the surface the owner actually has, not through the context
436
    # function that used to have no caller in `lib/` (#182).
437
    assert {:ok, _grant} =
438
             Repositories.grant_machine_access(user, machine.id, repository.id, ["read"])
439
436 440
    machine_clone = seed_clone!(base, machine_url)
437 441
    File.write!(Path.join(machine_clone, "machine.txt"), "machine\n")
438 442
    sh!(machine_clone, "git", ["add", "machine.txt"])

@@ -447,7 +451,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

447 451
    assert read_only_status != 0
448 452
449 453
    assert {:ok, _grant} =
450
             Repositories.grant_machine(repository, user, machine, ["read", "write"])
454
             Repositories.grant_machine_access(user, machine.id, repository.id, ["read", "write"])
451 455
452 456
    sh!(machine_clone, "git", ["push", "origin", "HEAD:main"])
453 457
  end
test/openagents_web/live/computers_repository_access_test.exs added +253

@@ -0,0 +1,253 @@

1
defmodule OpenAgentsWeb.ComputersRepositoryAccessTest do
2
  @moduledoc """
3
  REPOSITORY-001's computer principal, from the surface that grants it.
4
5
  `repository_machine_grants` was a complete, enforced authorization input with
6
  no writer outside tests: `OpenAgentsWeb.Plugs.ForgeGitAuth` accepted an
7
  `smct_` token, `OpenAgents.Forge.GitHTTP` routed it to
8
  `OpenAgents.Repositories.machine_access?/3`, and that predicate needed a row
9
  no route could create. So every Git request a paired computer made answered
10
  `404 unknown repository`, indistinguishable from an unauthorized one, and the
11
  tests passed because they inserted the row themselves.
12
13
  These prove the grant is reachable and scoped. The enforcement itself — a
14
  real `git clone` and `git push` over HTTP with the computer's own credential,
15
  admitted only for the operations granted — is
16
  `OpenAgents.Forge.GitHTTPTest`, which now obtains its grant through this same
17
  entry point rather than through a context function nothing called.
18
  """
19
20
  use OpenAgentsWeb.ConnCase, async: false
21
22
  import Ecto.Query
23
  import Phoenix.LiveViewTest
24
25
  alias OpenAgents.Machines
26
  alias OpenAgents.Repositories
27
28
  defp owner(login), do: repository_user_fixture(login)
29
30
  defp repository!(user, name) do
31
    {:ok, repository, :created} =
32
      Repositories.create_user_repository(user, %{name: name}, "grant-#{name}-#{user.id}")
33
34
    repository
35
    |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
36
    |> OpenAgents.Repo.update!()
37
  end
38
39
  defp computer!(user, name) do
40
    {:ok, pairing} =
41
      Machines.start_pairing(%{
42
        "name" => name,
43
        "tier" => "curated",
44
        "platform" => "linux-x64",
45
        "agent_version" => "0.4.0",
46
        "roots" => ["/home/test/work"]
47
      })
48
49
    {:ok, machine} = Machines.approve_pairing(user, pairing.code)
50
    machine
51
  end
52
53
  defp session(conn, user), do: Plug.Test.init_test_session(conn, %{"user_id" => user.id})
54
55
  test "the owner grants and withdraws a computer's repository access", %{conn: conn} do
56
    user = owner("grant-owner")
57
    repository = repository!(user, "granted")
58
    machine = computer!(user, "grant-box")
59
60
    refute Repositories.machine_access?(repository, machine.id, "read")
61
62
    {:ok, view, _html} = live(session(conn, user), ~p"/computers")
63
64
    assert has_element?(view, "#repository-access-#{machine.id}", "No repositories granted")
65
66
    view
67
    |> form("#repository-grant-form-#{machine.id}",
68
      grant: %{machine_id: machine.id, repository_id: repository.id, operations: "read"}
69
    )
70
    |> render_submit()
71
72
    assert has_element?(view, "#repository-grant-success", "read")
73
74
    assert has_element?(
75
             view,
76
             "#grant-#{machine.id}-#{repository.id}",
77
             "#{repository.owner}/granted"
78
           )
79
80
    # The predicate `OpenAgents.Forge.GitHTTP` calls for a `{:machine, id}`
81
    # principal, which nothing could satisfy before.
82
    assert Repositories.machine_access?(repository, machine.id, "read")
83
    refute Repositories.machine_access?(repository, machine.id, "write")
84
85
    view
86
    |> form("#repository-grant-form-#{machine.id}",
87
      grant: %{machine_id: machine.id, repository_id: repository.id, operations: "write"}
88
    )
89
    |> render_submit()
90
91
    assert Repositories.machine_access?(repository, machine.id, "write")
92
93
    view |> element("#revoke-grant-#{machine.id}-#{repository.id}") |> render_click()
94
95
    assert has_element?(view, "#repository-grant-revoked")
96
    refute Repositories.machine_access?(repository, machine.id, "read")
97
    refute has_element?(view, "#grant-#{machine.id}-#{repository.id}")
98
  end
99
100
  test "the withdrawal is audited with the account that created the grant", %{conn: conn} do
101
    user = owner("grant-audit")
102
    repository = repository!(user, "audited")
103
    machine = computer!(user, "audit-box")
104
105
    {:ok, view, _html} = live(session(conn, user), ~p"/computers")
106
107
    view
108
    |> form("#repository-grant-form-#{machine.id}",
109
      grant: %{machine_id: machine.id, repository_id: repository.id, operations: "read"}
110
    )
111
    |> render_submit()
112
113
    view |> element("#revoke-grant-#{machine.id}-#{repository.id}") |> render_click()
114
115
    events =
116
      OpenAgents.Repo.all(
117
        from event in OpenAgents.AuditEvent,
118
          where: event.repository_id == ^repository.id,
119
          order_by: [asc: event.inserted_at]
120
      )
121
122
    types = Enum.map(events, & &1.event_type)
123
    assert "repository.machine_grant.updated" in types
124
    assert "repository.machine_grant.revoked" in types
125
126
    revoked = Enum.find(events, &(&1.event_type == "repository.machine_grant.revoked"))
127
    assert revoked.metadata["machine_id"] == machine.id
128
    assert revoked.metadata["granted_by_user_id"] == user.id
129
  end
130
131
  test "a computer another account owns is not selectable and not grantable", %{conn: conn} do
132
    user = owner("grant-scope-owner")
133
    stranger = owner("grant-scope-stranger")
134
    repository = repository!(user, "scoped")
135
    foreign = computer!(stranger, "foreign-box")
136
    own = computer!(user, "own-box")
137
138
    {:ok, view, _html} = live(session(conn, user), ~p"/computers")
139
140
    refute has_element?(view, "#repository-access-#{foreign.id}")
141
142
    # A LiveView event carries whatever the client sends. The computer is
143
    # resolved through the acting account, so a foreign identifier refuses
144
    # exactly as an absent one does (IDENTITY-002).
145
    render_submit(view, "grant_repository_access", %{
146
      "grant" => %{
147
        "machine_id" => foreign.id,
148
        "repository_id" => repository.id,
149
        "operations" => "write"
150
      }
151
    })
152
153
    assert has_element?(view, "#pairing-error", "Computer not found")
154
    refute Repositories.machine_access?(repository, foreign.id, "read")
155
    refute Repositories.machine_access?(repository, own.id, "read")
156
  end
157
158
  test "a repository this account does not administer refuses the same way", %{conn: conn} do
159
    user = owner("grant-repo-scope")
160
    stranger = owner("grant-repo-stranger")
161
    foreign_repository = repository!(stranger, "not-mine")
162
    machine = computer!(user, "scoped-box")
163
164
    {:ok, repository, :created} =
165
      Repositories.create_user_repository(user, %{name: "mine"}, "grant-mine-#{user.id}")
166
167
    {:ok, view, _html} = live(session(conn, user), ~p"/computers")
168
169
    # The picker offers only repositories this account administers.
170
    assert has_element?(view, "#grant-repository-#{machine.id} option[value='#{repository.id}']")
171
172
    refute has_element?(
173
             view,
174
             "#grant-repository-#{machine.id} option[value='#{foreign_repository.id}']"
175
           )
176
177
    render_submit(view, "grant_repository_access", %{
178
      "grant" => %{
179
        "machine_id" => machine.id,
180
        "repository_id" => foreign_repository.id,
181
        "operations" => "write"
182
      }
183
    })
184
185
    assert has_element?(view, "#pairing-error", "do not administer")
186
    refute Repositories.machine_access?(foreign_repository, machine.id, "read")
187
  end
188
189
  test "a viewer membership cannot hand a repository to a computer", %{conn: conn} do
190
    holder = owner("grant-role-owner")
191
    viewer = owner("grant-role-viewer")
192
    repository = repository!(holder, "role-scoped")
193
    {:ok, _membership} = Repositories.add_member(repository, viewer, "viewer")
194
    machine = computer!(viewer, "viewer-box")
195
196
    assert {:error, :repository_not_allowed} =
197
             Repositories.grant_machine_access(viewer, machine.id, repository.id, ["read"])
198
199
    {:ok, view, _html} = live(session(conn, viewer), ~p"/computers")
200
201
    refute has_element?(view, "#grant-repository-#{machine.id} option[value='#{repository.id}']")
202
    refute Repositories.machine_access?(repository, machine.id, "read")
203
  end
204
205
  test "reading and withdrawing a grant are scoped to the account that owns the computer" do
206
    holder = owner("grant-cross-holder")
207
    admin = owner("grant-cross-admin")
208
209
    # One repository both accounts administer, and a computer only `holder`
210
    # owns. The repository membership is not authority over someone else's
211
    # computer, in either direction.
212
    repository = repository!(holder, "shared-admin")
213
    {:ok, _membership} = Repositories.add_member(repository, admin, "maintainer")
214
    machine = computer!(holder, "cross-box")
215
216
    assert {:ok, _grant} =
217
             Repositories.grant_machine_access(holder, machine.id, repository.id, ["read"])
218
219
    assert [_one] = Repositories.list_machine_grants(holder, machine.id)
220
    assert Repositories.list_machine_grants(admin, machine.id) == []
221
222
    assert {:error, :machine_not_owned} =
223
             Repositories.revoke_machine_access(admin, machine.id, repository.id)
224
225
    assert Repositories.machine_access?(repository, machine.id, "read")
226
227
    assert {:ok, _revoked} =
228
             Repositories.revoke_machine_access(holder, machine.id, repository.id)
229
230
    refute Repositories.machine_access?(repository, machine.id, "read")
231
  end
232
233
  test "revoking the computer ends every grant it holds without deleting them", %{conn: conn} do
234
    user = owner("grant-revoked-computer")
235
    repository = repository!(user, "still-granted")
236
    machine = computer!(user, "revoked-grant-box")
237
238
    {:ok, view, _html} = live(session(conn, user), ~p"/computers")
239
240
    view
241
    |> form("#repository-grant-form-#{machine.id}",
242
      grant: %{machine_id: machine.id, repository_id: repository.id, operations: "write"}
243
    )
244
    |> render_submit()
245
246
    assert Repositories.machine_access?(repository, machine.id, "write")
247
248
    view |> element("#revoke-#{machine.id}") |> render_click()
249
250
    refute Repositories.machine_access?(repository, machine.id, "read")
251
    assert [_grant] = Repositories.list_machine_grants(user, machine.id)
252
  end
253
end

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