Harden repository creation controls

f8a7822af2e0 · AtlantisPleb · · parent ca2680a21a98

Harden repository creation controls

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 docs/repository-creation-cli-implementation-roadmap.md
  • modified lib/openagents/api_tokens.ex
  • added lib/openagents/audit.ex
  • added lib/openagents/audit_event.ex
  • modified lib/openagents/forge/git_http.ex
  • modified lib/openagents/forge/supervisor.ex
  • modified lib/openagents/repositories.ex
  • added lib/openagents/repositories/import_workspace_janitor.ex
  • modified lib/openagents/repositories/importer.ex
  • added lib/openagents/repositories/machine_grant.ex
  • modified lib/openagents/repositories/provisioner.ex
  • modified lib/openagents_web/controllers/repository_controller.ex
  • modified lib/openagents_web/controllers/repository_import_controller.ex
  • modified lib/openagents_web/live/repository_index_live.ex
  • modified priv/api-contracts/repositories-v1.json
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260820220625_create_repository_security_controls.exs
  • modified test/openagents/api_tokens_test.exs
  • modified test/openagents/forge/git_http_test.exs
  • added test/openagents/repositories/import_workspace_janitor_test.exs
  • modified test/openagents/repositories/provisioner_test.exs
  • modified test/openagents_web/controllers/repository_controller_test.exs
  • modified test/openagents_web/live/repository_live_test.exs

Diff

23 files changed, +961 -95

docs/repository-creation-cli-implementation-roadmap.md modified +5 -5

@@ -458,15 +458,15 @@ result. Do not store tokens, local absolute paths, or repository content.

458 458
| 0. Contract and safety boundaries | Complete | `INVARIANTS.md`, Phoenix contract artifact, CLI behavior contract |
459 459
| 1. Namespace and lifecycle persistence | Complete | Lifecycle migrations and `repository_lifecycle_test.exs` |
460 460
| 2. GitHub namespace projection | Complete | GitHub adapter, exact `repo` and `read:org` scopes, projection tests |
461
| 3. Repository REST API | Complete | Repository controllers, JSON contract, idempotency and pagination tests |
462
| 4. Durable provisioning | Complete | Provisioning outbox, reclaimable worker, and cache reconstruction tests |
461
| 3. Repository REST API | Complete | Repository controllers, JSON contract, namespace quota, idempotency, and pagination tests |
462
| 4. Durable provisioning | Complete | Provisioning outbox, reclaimable worker, bounded import timeout, crash-workspace recovery, audit events, and cache reconstruction tests |
463 463
| 5. One-time GitHub import | Complete | Frozen refs, Git bundle WAL entry, cache-loss and no-later-sync tests |
464
| 6. Repository Git HTTP authorization | Complete | Public reads, PAT writes, role refusal, and legacy-route tests |
464
| 6. Repository Git HTTP authorization | Complete | Public reads, PAT writes, token reauthentication, role refusal, explicit machine grants, audited writes, and legacy-route tests |
465 465
| 7. CLI device authorization | Complete | One-time device-code context, API, browser approval, and polling tests |
466
| 8. Repository browser interface | Complete | List, create, import, empty, failed, private, and code-route LiveView tests |
466
| 8. Repository browser interface | Complete | Paginated list, create, import, empty, failed, private, and code-route LiveView tests |
467 467
| 9. CLI package scaffold | Complete | `@openagentsinc/cli`, Effect 4 command graph, build, and package inspection |
468 468
| 10. CLI configuration and authentication | Complete | Profiles, persisted configuration precedence, custom origins, OS credential store, headless refusal, stable errors, and origin-isolated Git helper tests |
469
| 11. CLI repository commands | Complete | Create, matching-namespace import, cursor list, view, clone, source remote, inference, and refusal tests |
469
| 11. CLI repository commands | Complete | Create, matching-namespace import, idempotent transport retry, cursor list, view, clone, source remote, inference, signal cancellation, and refusal tests |
470 470
| 12. Local cross-repository verification | In progress | The default-`4000` disposable suite passes on an alternate loopback port for create, push, clone, anonymous read, private concealment, viewer push refusal, cache reconstruction, exact import refs, and no later sync. See [`cf04c00-de8015d1e5.json`](evidence/repository-cli-local-e2e/cf04c00-de8015d1e5.json). Record the exact `localhost:4000` receipt when that port is available. |
471 471
| 13. Staging and release | Not started | — |
472 472
lib/openagents/api_tokens.ex modified +18 -8

@@ -4,6 +4,7 @@ defmodule OpenAgents.ApiTokens do

4 4
  import Ecto.Query
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Audit
7 8
  alias OpenAgents.ApiTokens.ApiToken
8 9
  alias OpenAgents.Repo
9 10

@@ -21,16 +22,25 @@ defmodule OpenAgents.ApiTokens do

21 22
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
22 23
      plaintext = @prefix <> id <> "." <> secret
23 24
24
      %ApiToken{id: id, user_id: user_id, token_digest: digest(plaintext)}
25
      |> ApiToken.create_changeset(%{
26
        name: name,
27
        scopes: scopes,
28
        expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
29
      })
30
      |> Repo.insert()
25
      Repo.transaction(fn ->
26
        token =
27
          %ApiToken{id: id, user_id: user_id, token_digest: digest(plaintext)}
28
          |> ApiToken.create_changeset(%{
29
            name: name,
30
            scopes: scopes,
31
            expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
32
          })
33
          |> Repo.insert!()
34
35
        Audit.record!("api_token.created", {:user, user_id}, "api_token", token.id,
36
          metadata: %{"scopes" => scopes}
37
        )
38
39
        token
40
      end)
31 41
      |> case do
32 42
        {:ok, token} -> {:ok, token, plaintext}
33
        {:error, changeset} -> {:error, changeset}
43
        {:error, reason} -> {:error, reason}
34 44
      end
35 45
    end
36 46
  end
lib/openagents/audit.ex added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgents.Audit do
2
  @moduledoc "Records bounded security events without repository content or credentials."
3
4
  alias OpenAgents.{AuditEvent, Repo}
5
6
  @maximum_metadata_bytes 8_192
7
8
  def record!(event_type, actor, subject_type, subject_id, options \\ []) do
9
    metadata = Keyword.get(options, :metadata, %{})
10
11
    if is_map(metadata) and byte_size(Jason.encode!(metadata)) <= @maximum_metadata_bytes do
12
      %AuditEvent{}
13
      |> AuditEvent.changeset(%{
14
        event_type: event_type,
15
        actor_type: actor_type(actor),
16
        actor_id: actor_id(actor),
17
        subject_type: subject_type,
18
        subject_id: to_string(subject_id),
19
        repository_id: Keyword.get(options, :repository_id),
20
        metadata: metadata
21
      })
22
      |> Repo.insert!()
23
    else
24
      raise ArgumentError, "audit metadata is invalid"
25
    end
26
  end
27
28
  defp actor_type({type, _id}) when type in ~w(user machine operator system), do: type
29
30
  defp actor_type({type, _id}) when type in [:user, :machine, :operator, :system],
31
    do: to_string(type)
32
33
  defp actor_type(:system), do: "system"
34
35
  defp actor_id({_type, nil}), do: nil
36
  defp actor_id({_type, id}), do: to_string(id)
37
  defp actor_id(:system), do: nil
38
end
lib/openagents/audit_event.ex added +44

@@ -0,0 +1,44 @@

1
defmodule OpenAgents.AuditEvent do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "audit_events" do
12
    field :event_type, :string
13
    field :actor_type, :string
14
    field :actor_id, :string
15
    field :subject_type, :string
16
    field :subject_id, :string
17
    field :metadata, :map, default: %{}
18
    belongs_to :repository, OpenAgents.Repositories.Repository
19
20
    timestamps(updated_at: false)
21
  end
22
23
  def changeset(event, attrs) do
24
    event
25
    |> cast(attrs, [
26
      :event_type,
27
      :actor_type,
28
      :actor_id,
29
      :subject_type,
30
      :subject_id,
31
      :repository_id,
32
      :metadata
33
    ])
34
    |> validate_required([:event_type, :actor_type, :subject_type, :subject_id, :metadata])
35
    |> validate_inclusion(:actor_type, ~w(user machine operator system))
36
    |> validate_length(:event_type, min: 1, max: 100)
37
    |> validate_length(:actor_id, max: 200)
38
    |> validate_length(:subject_type, min: 1, max: 80)
39
    |> validate_length(:subject_id, min: 1, max: 200)
40
    |> check_constraint(:actor_type, name: :audit_events_actor_type_allowed)
41
    |> check_constraint(:metadata, name: :audit_events_metadata_object)
42
    |> foreign_key_constraint(:repository_id)
43
  end
44
end
lib/openagents/forge/git_http.ex modified +32 -3

@@ -24,8 +24,8 @@ defmodule OpenAgents.Forge.GitHTTP do

24 24
  import Ecto.Query
25 25
  import Plug.Conn
26 26
27
  alias OpenAgents.{Audit, Repositories}
27 28
  alias OpenAgents.Forge.{Pushes, Repos, Sync}
28
  alias OpenAgents.Repositories
29 29
30 30
  @max_body_bytes 512 * 1024 * 1024
31 31
  @read_chunk 8 * 1024 * 1024

@@ -109,6 +109,15 @@ defmodule OpenAgents.Forge.GitHTTP do

109 109
             git_protocol(conn)
110 110
           ) do
111 111
        {:ok, output} ->
112
          Audit.record!(
113
            "repository.git.write",
114
            audit_actor(conn),
115
            "repository",
116
            repository.id || repository.storage_key,
117
            repository_id: repository.id,
118
            metadata: %{"operation" => "receive_pack"}
119
          )
120
112 121
          conn
113 122
          |> put_resp_content_type("application/x-git-receive-pack-result")
114 123
          |> put_resp_header("cache-control", "no-cache")

@@ -205,7 +214,8 @@ defmodule OpenAgents.Forge.GitHTTP do

205 214
    case conn.assigns[:forge_principal] do
206 215
      nil -> authentication_required()
207 216
      %{kind: :user, user: user} -> member_read(repository, user)
208
      %{kind: kind} when kind in [:operator, :machine] -> operational_access(repository)
217
      %{kind: :operator} -> operational_access(repository)
218
      %{kind: :machine, id: machine_id} -> machine_access(repository, machine_id, "read")
209 219
    end
210 220
  end
211 221

@@ -224,8 +234,11 @@ defmodule OpenAgents.Forge.GitHTTP do

224 234
          end
225 235
        end
226 236
227
      %{kind: kind} when kind in [:operator, :machine] ->
237
      %{kind: :operator} ->
228 238
        operational_access(repository)
239
240
      %{kind: :machine, id: machine_id} ->
241
        machine_access(repository, machine_id, "write")
229 242
    end
230 243
  end
231 244

@@ -241,6 +254,15 @@ defmodule OpenAgents.Forge.GitHTTP do

241 254
      else: {:error, 404, "unknown repository"}
242 255
  end
243 256
257
  defp machine_access(%{id: nil}, _machine_id, _operation),
258
    do: {:error, 404, "unknown repository"}
259
260
  defp machine_access(repository, machine_id, operation) do
261
    if Repositories.machine_access?(repository, machine_id, operation),
262
      do: :ok,
263
      else: {:error, 404, "unknown repository"}
264
  end
265
244 266
  defp repository_not_found(conn) do
245 267
    if conn.assigns[:forge_principal],
246 268
      do: {:error, 404, "unknown repository"},

@@ -271,6 +293,13 @@ defmodule OpenAgents.Forge.GitHTTP do

271 293
    end
272 294
  end
273 295
296
  defp audit_actor(conn) do
297
    case conn.assigns[:forge_principal] do
298
      %{kind: kind, id: id} when kind in [:user, :machine, :operator] -> {kind, id}
299
      _principal -> :system
300
    end
301
  end
302
274 303
  defp git_protocol(conn) do
275 304
    case get_req_header(conn, "git-protocol") do
276 305
      [value | _] -> value
lib/openagents/forge/supervisor.ex modified +4 -1

@@ -19,7 +19,10 @@ defmodule OpenAgents.Forge.Supervisor do

19 19
20 20
  defp repository_children do
21 21
    if Application.get_env(:openagents, :repository_provisioner_enabled, true) do
22
      [{OpenAgents.Repositories.Provisioner, []}]
22
      [
23
        {OpenAgents.Repositories.Provisioner, []},
24
        {OpenAgents.Repositories.ImportWorkspaceJanitor, []}
25
      ]
23 26
    else
24 27
      []
25 28
    end
lib/openagents/repositories.ex modified +170 -19

@@ -4,10 +4,12 @@ defmodule OpenAgents.Repositories do

4 4
  import Ecto.Query, warn: false
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Repo
7
  alias OpenAgents.{Audit, Repo}
8
  alias OpenAgents.Machines.Machine
8 9
9 10
  alias OpenAgents.Repositories.{
10 11
    IdempotencyRequest,
12
    MachineGrant,
11 13
    Membership,
12 14
    Namespace,
13 15
    NamespaceAlias,

@@ -19,6 +21,7 @@ defmodule OpenAgents.Repositories do

19 21
  @initial_owner "OpenAgentsInc"
20 22
  @initial_name "openagents.com"
21 23
  @writable_roles ~w(owner maintainer contributor)
24
  @repository_namespace_limit 100
22 25
23 26
  def initial_path, do: {@initial_owner, @initial_name}
24 27

@@ -299,15 +302,87 @@ defmodule OpenAgents.Repositories do

299 302
  end
300 303
301 304
  def add_member(%Repository{} = repository, %User{} = user, role \\ "contributor") do
302
    %Membership{}
303
    |> Membership.changeset(%{repository_id: repository.id, user_id: user.id, role: role})
304
    |> Repo.insert(
305
      on_conflict: {:replace, [:role, :updated_at]},
306
      conflict_target: [:repository_id, :user_id],
307
      returning: true
305
    Repo.transaction(fn ->
306
      membership =
307
        %Membership{}
308
        |> Membership.changeset(%{repository_id: repository.id, user_id: user.id, role: role})
309
        |> Repo.insert!(
310
          on_conflict: {:replace, [:role, :updated_at]},
311
          conflict_target: [:repository_id, :user_id],
312
          returning: true
313
        )
314
315
      Audit.record!(
316
        "repository.membership.updated",
317
        {:user, user.id},
318
        "membership",
319
        membership_subject_id(membership),
320
        repository_id: repository.id,
321
        metadata: %{"role" => membership.role}
322
      )
323
324
      membership
325
    end)
326
  end
327
328
  def grant_machine(%Repository{} = repository, %User{} = actor, %Machine{} = machine, operations)
329
      when is_list(operations) do
330
    with true <- machine.user_id == actor.id or {:error, :machine_not_owned},
331
         role when role in ~w(owner maintainer) <- membership_role(repository, actor) do
332
      Repo.transaction(fn ->
333
        grant =
334
          %MachineGrant{}
335
          |> MachineGrant.changeset(%{
336
            repository_id: repository.id,
337
            machine_id: machine.id,
338
            created_by_user_id: actor.id,
339
            operations: operations |> Enum.uniq() |> Enum.sort()
340
          })
341
          |> Repo.insert!(
342
            on_conflict: {:replace, [:operations, :created_by_user_id, :updated_at]},
343
            conflict_target: [:repository_id, :machine_id],
344
            returning: true
345
          )
346
347
        Audit.record!(
348
          "repository.machine_grant.updated",
349
          {:user, actor.id},
350
          "machine_grant",
351
          grant.id,
352
          repository_id: repository.id,
353
          metadata: %{"machine_id" => machine.id, "operations" => grant.operations}
354
        )
355
356
        grant
357
      end)
358
    else
359
      nil -> {:error, :repository_not_allowed}
360
      false -> {:error, :machine_not_owned}
361
      {:error, reason} -> {:error, reason}
362
      _role -> {:error, :repository_not_allowed}
363
    end
364
  rescue
365
    error in Ecto.InvalidChangesetError -> {:error, error.changeset}
366
  end
367
368
  def machine_access?(%Repository{id: repository_id}, machine_id, operation)
369
      when operation in ~w(read write) and is_binary(machine_id) do
370
    now = DateTime.utc_now()
371
372
    Repo.exists?(
373
      from grant in MachineGrant,
374
        join: machine in Machine,
375
        on:
376
          machine.id == grant.machine_id and machine.status == "active" and
377
            machine.token_expires_at > ^now,
378
        where:
379
          grant.repository_id == ^repository_id and grant.machine_id == ^machine_id and
380
            fragment("? = ANY(?)", ^operation, grant.operations)
308 381
    )
309 382
  end
310 383
384
  def machine_access?(%Repository{}, _machine_id, _operation), do: false
385
311 386
  def ensure_initial_membership(%User{} = user) do
312 387
    add_member(initial_repository!(), user)
313 388
  end

@@ -416,29 +491,75 @@ defmodule OpenAgents.Repositories do

416 491
         idempotency_key,
417 492
         request_digest
418 493
       ) do
494
    lock_and_validate_quota!(namespace.id)
495
419 496
    repository =
420 497
      %Repository{}
421 498
      |> Repository.creation_changeset(attrs, namespace, user.id, provisioning_kind)
422 499
      |> Repo.insert!()
423 500
424
    %Membership{}
425
    |> Membership.changeset(%{repository_id: repository.id, user_id: user.id, role: "owner"})
426
    |> Repo.insert!()
501
    Audit.record!("repository.created", {:user, user.id}, "repository", repository.id,
502
      repository_id: repository.id,
503
      metadata: %{
504
        "namespace_id" => namespace.id,
505
        "provisioning_kind" => provisioning_kind,
506
        "visibility" => repository.visibility
507
      }
508
    )
509
510
    membership =
511
      %Membership{}
512
      |> Membership.changeset(%{repository_id: repository.id, user_id: user.id, role: "owner"})
513
      |> Repo.insert!()
514
515
    Audit.record!(
516
      "repository.membership.created",
517
      {:user, user.id},
518
      "membership",
519
      membership_subject_id(membership),
520
      repository_id: repository.id,
521
      metadata: %{"role" => "owner"}
522
    )
427 523
428 524
    repository_import =
429 525
      if source do
430
        %RepositoryImport{}
431
        |> RepositoryImport.changeset(repository.id, source)
432
        |> Repo.insert!()
526
        created_import =
527
          %RepositoryImport{}
528
          |> RepositoryImport.changeset(repository.id, source)
529
          |> Repo.insert!()
530
531
        Audit.record!(
532
          "repository.import.created",
533
          {:user, user.id},
534
          "repository_import",
535
          created_import.id,
536
          repository_id: repository.id,
537
          metadata: %{
538
            "provider" => created_import.provider,
539
            "source_repository_id" => created_import.source_repository_id
540
          }
541
        )
542
543
        created_import
433 544
      end
434 545
435
    %ProvisioningOutbox{}
436
    |> ProvisioningOutbox.changeset(
437
      repository.id,
438
      repository_import && repository_import.id,
439
      operation
546
    outbox =
547
      %ProvisioningOutbox{}
548
      |> ProvisioningOutbox.changeset(
549
        repository.id,
550
        repository_import && repository_import.id,
551
        operation
552
      )
553
      |> Repo.insert!()
554
555
    Audit.record!(
556
      "repository.provisioning.pending",
557
      {:user, user.id},
558
      "provisioning_outbox",
559
      outbox.id,
560
      repository_id: repository.id,
561
      metadata: %{"operation" => operation}
440 562
    )
441
    |> Repo.insert!()
442 563
443 564
    %IdempotencyRequest{}
444 565
    |> IdempotencyRequest.changeset(

@@ -550,6 +671,33 @@ defmodule OpenAgents.Repositories do

550 671
    if collision?, do: Repo.rollback(:namespace_slug_conflict), else: :ok
551 672
  end
552 673
674
  defp lock_and_validate_quota!(namespace_id) do
675
    _namespace =
676
      Repo.one!(
677
        from namespace in Namespace, where: namespace.id == ^namespace_id, lock: "FOR UPDATE"
678
      )
679
680
    repository_count =
681
      Repo.aggregate(
682
        from(repository in Repository, where: repository.namespace_id == ^namespace_id),
683
        :count
684
      )
685
686
    if repository_count >= repository_namespace_limit(),
687
      do: Repo.rollback(:repository_quota_exceeded)
688
  end
689
690
  defp repository_namespace_limit do
691
    case Application.get_env(
692
           :openagents,
693
           :repository_namespace_limit,
694
           @repository_namespace_limit
695
         ) do
696
      limit when is_integer(limit) and limit > 0 -> limit
697
      _invalid -> @repository_namespace_limit
698
    end
699
  end
700
553 701
  defp repository_path_query(owner_key, name_key) do
554 702
    from repository in Repository,
555 703
      join: namespace in assoc(repository, :namespace),

@@ -616,6 +764,9 @@ defmodule OpenAgents.Repositories do

616 764
    end
617 765
  end
618 766
767
  defp membership_subject_id(membership),
768
    do: "#{membership.repository_id}:#{membership.user_id}"
769
619 770
  defp unwrap_transaction({:ok, value}), do: {:ok, value}
620 771
  defp unwrap_transaction({:error, reason}), do: {:error, reason}
621 772
end
lib/openagents/repositories/import_workspace_janitor.ex added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgents.Repositories.ImportWorkspaceJanitor do
2
  @moduledoc "Removes a bounded set of stale, crash-left GitHub import workspaces."
3
4
  use GenServer
5
6
  require Logger
7
8
  @default_interval_ms 15 * 60 * 1_000
9
  @default_retention_ms 2 * 60 * 60 * 1_000
10
  @maximum_entries 100
11
  @workspace_pattern ~r/\Aopenagents-import-[0-9a-f-]{36}-[0-9]+\z/
12
13
  def start_link(options) do
14
    name = Keyword.get(options, :name, __MODULE__)
15
    GenServer.start_link(__MODULE__, options, name: name)
16
  end
17
18
  @doc "Removes up to 100 stale import workspaces and returns the removal count."
19
  def sweep(now_ms \\ System.system_time(:millisecond)) do
20
    root = Application.get_env(:openagents, :repository_import_temp_dir, System.tmp_dir!())
21
22
    retention_ms =
23
      Application.get_env(
24
        :openagents,
25
        :repository_import_workspace_retention_ms,
26
        @default_retention_ms
27
      )
28
29
    case File.ls(root) do
30
      {:ok, entries} ->
31
        entries
32
        |> Enum.filter(&Regex.match?(@workspace_pattern, &1))
33
        |> Enum.sort()
34
        |> Enum.take(@maximum_entries)
35
        |> Enum.count(fn entry ->
36
          remove_if_stale(Path.join(root, entry), now_ms, retention_ms)
37
        end)
38
39
      {:error, _reason} ->
40
        0
41
    end
42
  end
43
44
  @impl true
45
  def init(options) do
46
    interval_ms = Keyword.get(options, :interval_ms, @default_interval_ms)
47
    schedule(interval_ms)
48
    {:ok, %{interval_ms: interval_ms}}
49
  end
50
51
  @impl true
52
  def handle_info(:sweep, state) do
53
    _count = sweep()
54
    schedule(state.interval_ms)
55
    {:noreply, state}
56
  end
57
58
  def handle_info(_message, state), do: {:noreply, state}
59
60
  defp remove_if_stale(path, now_ms, retention_ms)
61
       when is_integer(retention_ms) and retention_ms >= 0 do
62
    case File.lstat(path, time: :posix) do
63
      {:ok, %{type: :directory, mtime: mtime}} when now_ms - mtime * 1_000 > retention_ms ->
64
        case File.rm_rf(path) do
65
          {:ok, _paths} ->
66
            Logger.info("repository import janitor removed a stale workspace")
67
            true
68
69
          {:error, _reason, _path} ->
70
            false
71
        end
72
73
      _other ->
74
        false
75
    end
76
  end
77
78
  defp remove_if_stale(_path, _now_ms, _retention_ms), do: false
79
80
  defp schedule(interval_ms), do: Process.send_after(self(), :sweep, interval_ms)
81
end
lib/openagents/repositories/importer.ex modified +144 -42

@@ -1,11 +1,12 @@

1 1
defmodule OpenAgents.Repositories.Importer do
2 2
  @moduledoc "Copies one accepted GitHub ref snapshot into the durable forge WAL."
3 3
4
  alias OpenAgents.{Accounts, GitHubOAuth, Repo}
4
  alias OpenAgents.{Accounts, Audit, GitHubOAuth, Repo}
5 5
  alias OpenAgents.Forge.{Repos, Sync, WAL}
6 6
  alias OpenAgents.Repositories.{Repository, RepositoryImport}
7 7
8 8
  @maximum_append_attempts 3
9
  @default_import_timeout_ms 10 * 60 * 1_000
9 10
10 11
  def import(%Repository{} = repository, options \\ []) do
11 12
    repository = Repo.preload(repository, [:created_by_user, :repository_import])

@@ -25,23 +26,15 @@ defmodule OpenAgents.Repositories.Importer do

25 26
    result =
26 27
      try do
27 28
        with {:ok, source_url, credential} <- source_access(repository, running_import, options),
28
             :ok <- File.mkdir_p(temporary_directory),
29
             :ok <- File.chmod(temporary_directory, 0o700),
30
             {:ok, source_repository} <- initialize_source(temporary_directory),
31
             :ok <- fetch_source(source_repository, source_url, credential, temporary_directory),
32
             {:ok, refs} <- verify_snapshot(source_repository, running_import),
33
             {:ok, payload, format} <-
34
               create_payload(source_repository, refs, temporary_directory),
35 29
             :ok <-
36
               append_import(
30
               bounded_copy(
37 31
                 repository,
38 32
                 running_import,
39
                 payload,
40
                 format,
41
                 refs,
42
                 0
43
               ),
44
             :ok <- Sync.ensure_fresh(repository.storage_key, repository.default_branch) do
33
                 source_url,
34
                 credential,
35
                 temporary_directory,
36
                 options
37
               ) do
45 38
          mark_completed!(running_import)
46 39
          :ok
47 40
        end

@@ -63,6 +56,72 @@ defmodule OpenAgents.Repositories.Importer do

63 56
    end
64 57
  end
65 58
59
  defp bounded_copy(
60
         repository,
61
         repository_import,
62
         source_url,
63
         credential,
64
         temporary_directory,
65
         options
66
       ) do
67
    timeout_ms =
68
      Keyword.get(
69
        options,
70
        :timeout_ms,
71
        Application.get_env(
72
          :openagents,
73
          :repository_import_timeout_ms,
74
          @default_import_timeout_ms
75
        )
76
      )
77
78
    if is_integer(timeout_ms) and timeout_ms > 0 do
79
      task =
80
        Task.async(fn ->
81
          copy_snapshot(
82
            repository,
83
            repository_import,
84
            source_url,
85
            credential,
86
            temporary_directory
87
          )
88
        end)
89
90
      case Task.yield(task, timeout_ms) do
91
        {:ok, result} ->
92
          result
93
94
        {:exit, _reason} ->
95
          {:error, :import_failed}
96
97
        nil ->
98
          _result = Task.shutdown(task, :brutal_kill)
99
          {:error, :import_timeout}
100
      end
101
    else
102
      {:error, :import_timeout}
103
    end
104
  end
105
106
  defp copy_snapshot(
107
         repository,
108
         repository_import,
109
         source_url,
110
         credential,
111
         temporary_directory
112
       ) do
113
    with :ok <- File.mkdir_p(temporary_directory),
114
         :ok <- File.chmod(temporary_directory, 0o700),
115
         {:ok, source_repository} <- initialize_source(temporary_directory),
116
         :ok <- fetch_source(source_repository, source_url, credential, temporary_directory),
117
         {:ok, refs} <- verify_snapshot(source_repository, repository_import),
118
         {:ok, payload, format} <- create_payload(source_repository, refs, temporary_directory),
119
         :ok <- append_import(repository, repository_import, payload, format, refs, 0),
120
         :ok <- Sync.ensure_fresh(repository.storage_key, repository.default_branch) do
121
      :ok
122
    end
123
  end
124
66 125
  defp source_access(repository, repository_import, options) do
67 126
    case Keyword.get(options, :source_url) do
68 127
      source_url when is_binary(source_url) ->

@@ -281,44 +340,87 @@ defmodule OpenAgents.Repositories.Importer do

281 340
  defp mark_running!(repository_import) do
282 341
    now = DateTime.utc_now()
283 342
284
    repository_import
285
    |> RepositoryImport.transition_changeset(%{
286
      state: "running",
287
      attempt_count: repository_import.attempt_count + 1,
288
      error_code: nil,
289
      started_at: repository_import.started_at || now,
290
      completed_at: nil
291
    })
292
    |> Repo.update!()
343
    Repo.transaction(fn ->
344
      running =
345
        repository_import
346
        |> RepositoryImport.transition_changeset(%{
347
          state: "running",
348
          attempt_count: repository_import.attempt_count + 1,
349
          error_code: nil,
350
          started_at: repository_import.started_at || now,
351
          completed_at: nil
352
        })
353
        |> Repo.update!()
354
355
      audit_import_transition!(running)
356
      running
357
    end)
358
    |> elem(1)
293 359
  end
294 360
295 361
  defp mark_completed!(repository_import) do
296
    repository_import
297
    |> RepositoryImport.transition_changeset(%{
298
      state: "completed",
299
      attempt_count: repository_import.attempt_count,
300
      error_code: nil,
301
      started_at: repository_import.started_at,
302
      completed_at: DateTime.utc_now()
303
    })
304
    |> Repo.update!()
362
    Repo.transaction(fn ->
363
      completed =
364
        repository_import
365
        |> RepositoryImport.transition_changeset(%{
366
          state: "completed",
367
          attempt_count: repository_import.attempt_count,
368
          error_code: nil,
369
          started_at: repository_import.started_at,
370
          completed_at: DateTime.utc_now()
371
        })
372
        |> Repo.update!()
373
374
      audit_import_transition!(completed)
375
      completed
376
    end)
377
    |> elem(1)
305 378
  end
306 379
307 380
  defp mark_failed!(repository_import, error_code) do
308
    fresh_import!(repository_import.id)
309
    |> RepositoryImport.transition_changeset(%{
310
      state: "failed",
311
      attempt_count: repository_import.attempt_count,
312
      error_code: error_code,
313
      started_at: repository_import.started_at,
314
      completed_at: nil
315
    })
316
    |> Repo.update!()
381
    Repo.transaction(fn ->
382
      failed =
383
        fresh_import!(repository_import.id)
384
        |> RepositoryImport.transition_changeset(%{
385
          state: "failed",
386
          attempt_count: repository_import.attempt_count,
387
          error_code: error_code,
388
          started_at: repository_import.started_at,
389
          completed_at: nil
390
        })
391
        |> Repo.update!()
392
393
      audit_import_transition!(failed)
394
      failed
395
    end)
396
    |> elem(1)
397
  end
398
399
  defp audit_import_transition!(repository_import) do
400
    metadata =
401
      %{
402
        "attempt_count" => repository_import.attempt_count,
403
        "state" => repository_import.state
404
      }
405
      |> maybe_put_error(repository_import.error_code)
406
407
    Audit.record!(
408
      "repository.import.#{repository_import.state}",
409
      :system,
410
      "repository_import",
411
      repository_import.id,
412
      repository_id: repository_import.repository_id,
413
      metadata: metadata
414
    )
317 415
  end
318 416
417
  defp maybe_put_error(metadata, nil), do: metadata
418
  defp maybe_put_error(metadata, error_code), do: Map.put(metadata, "error_code", error_code)
419
319 420
  defp error_code(:source_changed), do: "source_changed"
320 421
  defp error_code(:github_scope_required), do: "github_scope_required"
321 422
  defp error_code(:github_token_missing), do: "github_connection_required"
423
  defp error_code(:import_timeout), do: "import_timeout"
322 424
  defp error_code(_reason), do: "import_failed"
323 425
324 426
  defp temporary_directory(import_id) do
lib/openagents/repositories/machine_grant.ex added +33

@@ -0,0 +1,33 @@

1
defmodule OpenAgents.Repositories.MachineGrant do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "repository_machine_grants" do
12
    belongs_to :repository, OpenAgents.Repositories.Repository
13
    belongs_to :machine, OpenAgents.Machines.Machine
14
    belongs_to :created_by_user, OpenAgents.Accounts.User
15
    field :operations, {:array, :string}, default: []
16
17
    timestamps()
18
  end
19
20
  def changeset(grant, attrs) do
21
    grant
22
    |> cast(attrs, [:repository_id, :machine_id, :created_by_user_id, :operations])
23
    |> validate_required([:repository_id, :machine_id, :created_by_user_id, :operations])
24
    |> validate_length(:operations, min: 1, max: 2)
25
    |> validate_subset(:operations, ~w(read write))
26
    |> unique_constraint([:repository_id, :machine_id])
27
    |> foreign_key_constraint(:repository_id)
28
    |> foreign_key_constraint(:machine_id)
29
    |> foreign_key_constraint(:created_by_user_id)
30
    |> check_constraint(:operations, name: :repository_machine_grants_operations_present)
31
    |> check_constraint(:operations, name: :repository_machine_grants_operations_allowed)
32
  end
33
end
lib/openagents/repositories/provisioner.ex modified +41 -11

@@ -8,7 +8,7 @@ defmodule OpenAgents.Repositories.Provisioner do

8 8
  require Logger
9 9
10 10
  alias OpenAgents.Forge.{Repos, WAL}
11
  alias OpenAgents.Repo
11
  alias OpenAgents.{Audit, Repo}
12 12
  alias OpenAgents.Repositories.{ProvisioningOutbox, Repository}
13 13
14 14
  @lease_seconds 120

@@ -91,16 +91,28 @@ defmodule OpenAgents.Repositories.Provisioner do

91 91
            nil
92 92
93 93
          %ProvisioningOutbox{} = claimed ->
94
            claimed
95
            |> ProvisioningOutbox.transition_changeset(%{
96
              state: "running",
97
              attempt_count: claimed.attempt_count + 1,
98
              retry_at: now,
99
              claimed_at: now,
100
              completed_at: nil,
101
              error_code: nil
102
            })
103
            |> Repo.update!()
94
            running =
95
              claimed
96
              |> ProvisioningOutbox.transition_changeset(%{
97
                state: "running",
98
                attempt_count: claimed.attempt_count + 1,
99
                retry_at: now,
100
                claimed_at: now,
101
                completed_at: nil,
102
                error_code: nil
103
              })
104
              |> Repo.update!()
105
106
            Audit.record!(
107
              "repository.provisioning.running",
108
              :system,
109
              "provisioning_outbox",
110
              running.id,
111
              repository_id: running.repository_id,
112
              metadata: %{"attempt_count" => running.attempt_count}
113
            )
114
115
            running
104 116
        end
105 117
      end)
106 118

@@ -180,6 +192,15 @@ defmodule OpenAgents.Repositories.Provisioner do

180 192
        error_code: nil
181 193
      })
182 194
      |> Repo.update!()
195
196
      Audit.record!(
197
        "repository.provisioning.completed",
198
        :system,
199
        "provisioning_outbox",
200
        outbox.id,
201
        repository_id: repository.id,
202
        metadata: %{"attempt_count" => outbox.attempt_count, "operation" => outbox.operation}
203
      )
183 204
    end)
184 205
185 206
    :ok

@@ -211,6 +232,15 @@ defmodule OpenAgents.Repositories.Provisioner do

211 232
        error_code: "provisioning_failed"
212 233
      })
213 234
      |> Repo.update!()
235
236
      Audit.record!("repository.provisioning.failed", :system, "provisioning_outbox", outbox.id,
237
        repository_id: repository.id,
238
        metadata: %{
239
          "attempt_count" => outbox.attempt_count,
240
          "error_code" => "provisioning_failed",
241
          "operation" => outbox.operation
242
        }
243
      )
214 244
    end)
215 245
216 246
    Logger.warning("repository_provisioning_failed code=provisioning_failed")
lib/openagents_web/controllers/repository_controller.ex modified +9

@@ -177,6 +177,15 @@ defmodule OpenAgentsWeb.RepositoryController do

177 177
    do:
178 178
      error(conn, :conflict, "repository_name_conflict", "Repository name is unavailable", "name")
179 179
180
  defp render_error(conn, :repository_quota_exceeded),
181
    do:
182
      error(
183
        conn,
184
        :unprocessable_entity,
185
        "repository_quota_exceeded",
186
        "The namespace repository quota is exhausted"
187
      )
188
180 189
  defp render_error(conn, :not_found),
181 190
    do: error(conn, :not_found, "not_found", "Repository not found")
182 191
lib/openagents_web/controllers/repository_import_controller.ex modified +9

@@ -145,6 +145,15 @@ defmodule OpenAgentsWeb.RepositoryImportController do

145 145
  defp render_error(conn, :idempotency_conflict),
146 146
    do: error(conn, :conflict, "idempotency_conflict", "The idempotency key is already in use")
147 147
148
  defp render_error(conn, :repository_quota_exceeded),
149
    do:
150
      error(
151
        conn,
152
        :unprocessable_entity,
153
        "repository_quota_exceeded",
154
        "The namespace repository quota is exhausted"
155
      )
156
148 157
  defp render_error(conn, :invalid_idempotency_key),
149 158
    do: error(conn, :bad_request, "invalid_idempotency_key", "Provide one Idempotency-Key header")
150 159
lib/openagents_web/live/repository_index_live.ex modified +41 -1

@@ -5,13 +5,41 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

5 5
6 6
  alias OpenAgents.Repositories
7 7
8
  @per_page 20
9
8 10
  @impl true
9 11
  def mount(_params, _session, socket) do
10
    repositories = Repositories.list_visible_repositories(socket.assigns.current_user)
12
    {repositories, more?} =
13
      Repositories.list_visible_repositories_page(
14
        socket.assigns.current_user,
15
        @per_page,
16
        nil
17
      )
11 18
12 19
    {:ok,
13 20
     socket
14 21
     |> assign(:page_title, "Repositories")
22
     |> assign(:repository_cursor, cursor(List.last(repositories)))
23
     |> assign(:repositories_more?, more?)
24
     |> stream(:repositories, repositories)}
25
  end
26
27
  @impl true
28
  def handle_event("load_more", _params, socket) do
29
    {repositories, more?} =
30
      Repositories.list_visible_repositories_page(
31
        socket.assigns.current_user,
32
        @per_page,
33
        socket.assigns.repository_cursor
34
      )
35
36
    {:noreply,
37
     socket
38
     |> assign(
39
       :repository_cursor,
40
       cursor(List.last(repositories)) || socket.assigns.repository_cursor
41
     )
42
     |> assign(:repositories_more?, more?)
15 43
     |> stream(:repositories, repositories)}
16 44
  end
17 45

@@ -68,6 +96,12 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

68 96
            </div>
69 97
          </.card>
70 98
        </div>
99
100
        <div :if={@repositories_more?} class="flex justify-center">
101
          <.button id="repositories-load-more" phx-click="load_more" variant={:secondary}>
102
            Load more
103
          </.button>
104
        </div>
71 105
      </main>
72 106
    </Layouts.app>
73 107
    """

@@ -76,4 +110,10 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

76 110
  defp status_variant("ready"), do: :success
77 111
  defp status_variant("failed"), do: :danger
78 112
  defp status_variant(_state), do: :info
113
114
  defp cursor(nil), do: nil
115
116
  defp cursor(repository) do
117
    {repository.namespace.slug_key, repository.name_key, repository.id}
118
  end
79 119
end
priv/api-contracts/repositories-v1.json modified +1

@@ -86,6 +86,7 @@

86 86
      "invalid_repository",
87 87
      "namespace_not_allowed",
88 88
      "not_found",
89
      "repository_quota_exceeded",
89 90
      "repository_name_conflict",
90 91
      "slow_down",
91 92
      "source_namespace_mismatch",
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -189,7 +189,8 @@

189 189
    20260820150000,
190 190
    20260820161342,
191 191
    20260820204317,
192
    20260820211218
192
    20260820211218,
193
    20260820220625
193 194
  ],
194 195
  "required_tables": [
195 196
    "users",
priv/repo/migrations/20260820220625_create_repository_security_controls.exs added +60

@@ -0,0 +1,60 @@

1
defmodule OpenAgents.Repo.Migrations.CreateRepositorySecurityControls do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:repository_machine_grants, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :repository_id,
9
          references(:repositories, type: :binary_id, on_delete: :delete_all),
10
          null: false
11
12
      add :machine_id, references(:machines, type: :binary_id, on_delete: :delete_all),
13
        null: false
14
15
      add :created_by_user_id,
16
          references(:users, type: :binary_id, on_delete: :restrict),
17
          null: false
18
19
      add :operations, {:array, :string}, null: false
20
21
      timestamps(type: :utc_datetime_usec)
22
    end
23
24
    create unique_index(:repository_machine_grants, [:repository_id, :machine_id])
25
    create index(:repository_machine_grants, [:machine_id])
26
27
    create constraint(:repository_machine_grants, :repository_machine_grants_operations_present,
28
             check: "cardinality(operations) > 0"
29
           )
30
31
    create constraint(:repository_machine_grants, :repository_machine_grants_operations_allowed,
32
             check: "operations <@ ARRAY['read', 'write']::varchar[]"
33
           )
34
35
    create table(:audit_events, primary_key: false) do
36
      add :id, :binary_id, primary_key: true
37
      add :event_type, :string, null: false
38
      add :actor_type, :string, null: false
39
      add :actor_id, :string
40
      add :subject_type, :string, null: false
41
      add :subject_id, :string, null: false
42
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :nilify_all)
43
      add :metadata, :map, null: false, default: %{}
44
45
      timestamps(type: :utc_datetime_usec, updated_at: false)
46
    end
47
48
    create index(:audit_events, [:repository_id, :inserted_at])
49
    create index(:audit_events, [:event_type, :inserted_at])
50
    create index(:audit_events, [:actor_type, :actor_id, :inserted_at])
51
52
    create constraint(:audit_events, :audit_events_actor_type_allowed,
53
             check: "actor_type IN ('user', 'machine', 'operator', 'system')"
54
           )
55
56
    create constraint(:audit_events, :audit_events_metadata_object,
57
             check: "jsonb_typeof(metadata) = 'object'"
58
           )
59
  end
60
end
test/openagents/api_tokens_test.exs modified +6 -1

@@ -1,7 +1,7 @@

1 1
defmodule OpenAgents.ApiTokensTest do
2 2
  use OpenAgents.DataCase, async: true
3 3
4
  alias OpenAgents.{Accounts, ApiTokens, Repo}
4
  alias OpenAgents.{Accounts, ApiTokens, AuditEvent, Repo}
5 5
6 6
  test "tokens are stored as digests, require scope, expire, and revoke" do
7 7
    {:ok, user} = Accounts.upsert_github_user(profile(801, "api-owner"))

@@ -14,6 +14,11 @@ defmodule OpenAgents.ApiTokensTest do

14 14
             })
15 15
16 16
    refute token.token_digest =~ plaintext
17
18
    assert %AuditEvent{actor_id: actor_id, metadata: %{"scopes" => ["forge:write"]}} =
19
             Repo.get_by!(AuditEvent, event_type: "api_token.created", subject_id: token.id)
20
21
    assert actor_id == user.id
17 22
    assert {:ok, authenticated, used} = ApiTokens.authenticate(plaintext, "forge:write")
18 23
    assert authenticated.id == user.id
19 24
    assert used.last_used_at
test/openagents/forge/git_http_test.exs modified +91 -2

@@ -10,7 +10,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

10 10
11 11
  import OpenAgents.AccountsFixtures
12 12
13
  alias OpenAgents.Forge
13
  alias OpenAgents.{AuditEvent, Forge, Machines, Repo, Repositories}
14 14
  alias OpenAgents.Forge.{Repos, WAL}
15 15
16 16
  defmodule TestPipeline do

@@ -41,7 +41,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

41 41
      |> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
42 42
      |> OpenAgents.Repo.update!()
43 43
44
    {:ok, _api_token, plaintext} =
44
    {:ok, api_token, plaintext} =
45 45
      OpenAgents.ApiTokens.create(user, %{
46 46
        name: "Git HTTP test",
47 47
        scopes: ["forge:write"],

@@ -61,6 +61,7 @@ defmodule OpenAgents.Forge.GitHTTPTest do

61 61
      base: base,
62 62
      port: port,
63 63
      repository: repository,
64
      api_token: api_token,
64 65
      token: plaintext,
65 66
      url: "http://x:#{plaintext}@127.0.0.1:#{port}/git-http-owner/demo.git",
66 67
      user: user

@@ -129,6 +130,14 @@ defmodule OpenAgents.Forge.GitHTTPTest do

129 130
    assert_receive {:forge_push, %{repo: storage_key, wal_seq: 0}}, 2_000
130 131
    assert storage_key == repository.storage_key
131 132
133
    assert %AuditEvent{event_type: "repository.git.write", repository_id: repository_id} =
134
             Repo.get_by(AuditEvent,
135
               event_type: "repository.git.write",
136
               repository_id: repository.id
137
             )
138
139
    assert repository_id == repository.id
140
132 141
    # A second clone sees the commit.
133 142
    verify = seed_clone!(base, url)
134 143
    assert File.read!(Path.join(verify, "hello.txt")) == "hello forge\n"

@@ -220,6 +229,32 @@ defmodule OpenAgents.Forge.GitHTTPTest do

220 229
    assert output =~ "404" or output =~ "not found" or output =~ "unknown"
221 230
  end
222 231
232
  test "Git RPC reauthenticates a token after ref advertisement", %{
233
    api_token: api_token,
234
    token: token,
235
    user: user
236
  } do
237
    authorization = "Basic " <> Base.encode64("x:#{token}")
238
239
    advertised =
240
      :get
241
      |> Plug.Test.conn("/git-http-owner/demo.git/info/refs?service=git-upload-pack")
242
      |> Plug.Conn.put_req_header("authorization", authorization)
243
      |> TestPipeline.call([])
244
245
    assert advertised.status == 200
246
    assert {:ok, _revoked} = OpenAgents.ApiTokens.revoke(user, api_token.id)
247
248
    rpc =
249
      :post
250
      |> Plug.Test.conn("/git-http-owner/demo.git/git-upload-pack", "")
251
      |> Plug.Conn.put_req_header("authorization", authorization)
252
      |> TestPipeline.call([])
253
254
    assert rpc.status == 401
255
    assert Plug.Conn.get_resp_header(rpc, "www-authenticate") != []
256
  end
257
223 258
  test "public repositories clone anonymously and private repositories issue a challenge", %{
224 259
    base: base,
225 260
    port: port,

@@ -284,6 +319,60 @@ defmodule OpenAgents.Forge.GitHTTPTest do

284 319
    assert output =~ "403" or output =~ "read only" or output =~ "unable to access"
285 320
  end
286 321
322
  test "a paired machine requires an explicit operation-scoped repository grant", %{
323
    base: base,
324
    port: port,
325
    repository: repository,
326
    user: user
327
  } do
328
    {:ok, pairing} =
329
      Machines.start_pairing(%{
330
        "name" => "git-machine",
331
        "tier" => "probe",
332
        "platform" => "linux",
333
        "agent_version" => "0.1.0",
334
        "roots" => []
335
      })
336
337
    assert {:ok, machine} = Machines.approve_pairing(user, pairing.code)
338
339
    assert {:ok, %{token: machine_token}} =
340
             Machines.claim_pairing(pairing.pairing.id, pairing.poll_secret)
341
342
    machine_url =
343
      "http://x:#{machine_token}@127.0.0.1:#{port}/git-http-owner/demo.git"
344
345
    {ungranted_output, ungranted_status} =
346
      System.cmd(
347
        "git",
348
        ["-c", "credential.helper=", "clone", machine_url, Path.join(base, "machine-ungranted")],
349
        stderr_to_stdout: true,
350
        env: [{"GIT_TERMINAL_PROMPT", "0"}]
351
      )
352
353
    assert ungranted_status != 0
354
    assert ungranted_output =~ "404" or ungranted_output =~ "not found"
355
356
    assert {:ok, _grant} = Repositories.grant_machine(repository, user, machine, ["read"])
357
    machine_clone = seed_clone!(base, machine_url)
358
    File.write!(Path.join(machine_clone, "machine.txt"), "machine\n")
359
    sh!(machine_clone, "git", ["add", "machine.txt"])
360
    sh!(machine_clone, "git", ["commit", "-m", "Machine commit"])
361
362
    {_output, read_only_status} =
363
      System.cmd("git", ["-c", "credential.helper=", "push", "origin", "HEAD:main"],
364
        cd: machine_clone,
365
        stderr_to_stdout: true
366
      )
367
368
    assert read_only_status != 0
369
370
    assert {:ok, _grant} =
371
             Repositories.grant_machine(repository, user, machine, ["read", "write"])
372
373
    sh!(machine_clone, "git", ["push", "origin", "HEAD:main"])
374
  end
375
287 376
  test "the legacy initial repository path remains available", %{
288 377
    base: base,
289 378
    port: port,
test/openagents/repositories/import_workspace_janitor_test.exs added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgents.Repositories.ImportWorkspaceJanitorTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.Repositories.ImportWorkspaceJanitor
5
6
  setup do
7
    root =
8
      Path.join(
9
        System.tmp_dir!(),
10
        "repository-import-janitor-#{System.unique_integer([:positive])}"
11
      )
12
13
    File.mkdir_p!(root)
14
    previous_root = Application.get_env(:openagents, :repository_import_temp_dir)
15
16
    previous_retention =
17
      Application.get_env(:openagents, :repository_import_workspace_retention_ms)
18
19
    Application.put_env(:openagents, :repository_import_temp_dir, root)
20
    Application.put_env(:openagents, :repository_import_workspace_retention_ms, 1_000)
21
22
    on_exit(fn ->
23
      restore_env(:repository_import_temp_dir, previous_root)
24
      restore_env(:repository_import_workspace_retention_ms, previous_retention)
25
      File.rm_rf(root)
26
    end)
27
28
    %{root: root}
29
  end
30
31
  test "removes only stale import workspaces and bounds each sweep", %{root: root} do
32
    stale = import_workspace(root, Ecto.UUID.generate(), 1)
33
    fresh = import_workspace(root, Ecto.UUID.generate(), 2)
34
    unrelated = Path.join(root, "keep-me")
35
    File.mkdir_p!(stale)
36
    File.mkdir_p!(fresh)
37
    File.mkdir_p!(unrelated)
38
39
    future = System.system_time(:millisecond) + 2_000
40
    assert ImportWorkspaceJanitor.sweep(future) == 2
41
    refute File.exists?(stale)
42
    refute File.exists?(fresh)
43
    assert File.exists?(unrelated)
44
45
    Enum.each(1..101, fn number ->
46
      File.mkdir_p!(import_workspace(root, Ecto.UUID.generate(), number + 10))
47
    end)
48
49
    assert ImportWorkspaceJanitor.sweep(future) == 100
50
    assert length(File.ls!(root)) == 2
51
  end
52
53
  defp import_workspace(root, id, suffix),
54
    do: Path.join(root, "openagents-import-#{id}-#{suffix}")
55
56
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
57
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
58
end
test/openagents/repositories/provisioner_test.exs modified +22

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

3 3
4 4
  import OpenAgents.AccountsFixtures
5 5
6
  alias OpenAgents.AuditEvent
6 7
  alias OpenAgents.Forge.{Repos, WAL}
7 8
  alias OpenAgents.Repositories
8 9
  alias OpenAgents.Repositories.{Importer, Provisioner, ProvisioningOutbox, Repository}

@@ -54,6 +55,15 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

54 55
    assert outbox.state == "completed"
55 56
    assert outbox.attempt_count == 1
56 57
58
    assert audit_types(repository.id) ==
59
             MapSet.new([
60
               "repository.created",
61
               "repository.membership.created",
62
               "repository.provisioning.completed",
63
               "repository.provisioning.pending",
64
               "repository.provisioning.running"
65
             ])
66
57 67
    assert {:ok, _generation, %{"entries" => [], "refs" => %{}}} =
58 68
             WAL.read_index(repository.storage_key)
59 69

@@ -90,6 +100,7 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

90 100
    assert failed_repository.lifecycle_state == "failed"
91 101
    assert failed_repository.provision_error_code == "provisioning_failed"
92 102
    refute inspect(failed_outbox) =~ "fixture_secret_failure"
103
    assert "repository.provisioning.failed" in audit_types(repository.id)
93 104
  end
94 105
95 106
  test "a one-time import persists a bundle that reconstructs after cache loss", %{test: _test} do

@@ -144,6 +155,9 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

144 155
145 156
    assert completed_import.state == "completed"
146 157
    assert completed_import.completed_at
158
    assert "repository.import.completed" in audit_types(repository.id)
159
    assert "repository.import.created" in audit_types(repository.id)
160
    assert "repository.import.running" in audit_types(repository.id)
147 161
    assert {:ok, _generation, index} = WAL.read_index(repository.storage_key)
148 162
    assert [%{"format" => "git_bundle", "import_id" => import_id}] = WAL.entries(index)
149 163
    assert import_id == repository_import.id

@@ -187,4 +201,12 @@ defmodule OpenAgents.Repositories.ProvisionerTest do

187 201
188 202
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
189 203
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
204
205
  defp audit_types(repository_id) do
206
    AuditEvent
207
    |> where([event], event.repository_id == ^repository_id)
208
    |> select([event], event.event_type)
209
    |> OpenAgents.Repo.all()
210
    |> MapSet.new()
211
  end
190 212
end
test/openagents_web/controllers/repository_controller_test.exs modified +27 -1

@@ -1,5 +1,5 @@

1 1
defmodule OpenAgentsWeb.RepositoryControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  alias OpenAgents.ApiTokens
5 5
  alias OpenAgents.Repositories

@@ -175,6 +175,32 @@ defmodule OpenAgentsWeb.RepositoryControllerTest do

175 175
             |> json_response(200)
176 176
  end
177 177
178
  test "creation enforces the namespace repository quota", %{conn: conn} do
179
    previous = Application.get_env(:openagents, :repository_namespace_limit)
180
    Application.put_env(:openagents, :repository_namespace_limit, 1)
181
182
    on_exit(fn ->
183
      if is_nil(previous),
184
        do: Application.delete_env(:openagents, :repository_namespace_limit),
185
        else: Application.put_env(:openagents, :repository_namespace_limit, previous)
186
    end)
187
188
    user = github_user("repository-api-quota", "quota-owner")
189
190
    assert conn
191
           |> authorize(user)
192
           |> put_req_header("idempotency-key", "quota-first")
193
           |> post(~p"/api/v3/user/repos", %{name: "first"})
194
           |> json_response(202)
195
196
    assert %{"code" => "repository_quota_exceeded"} =
197
             conn
198
             |> authorize(user)
199
             |> put_req_header("idempotency-key", "quota-second")
200
             |> post(~p"/api/v3/user/repos", %{name: "second"})
201
             |> json_response(422)
202
  end
203
178 204
  defp authorize(conn, user) do
179 205
    {:ok, _credential, plaintext} =
180 206
      ApiTokens.create(user, %{name: "repository API test", scopes: ["forge:write"]})
test/openagents_web/live/repository_live_test.exs modified +25

@@ -38,6 +38,31 @@ defmodule OpenAgentsWeb.RepositoryLiveTest do

38 38
    assert has_element?(view, "#import-repository")
39 39
  end
40 40
41
  test "repository index loads bounded pages", %{conn: conn} do
42
    user = github_user("repository-live-pagination", "pagination-owner")
43
44
    Enum.each(1..21, fn number ->
45
      assert {:ok, _repository, :created} =
46
               Repositories.create_user_repository(
47
                 user,
48
                 %{name: "paged-#{String.pad_leading(to_string(number), 2, "0")}"},
49
                 "pagination-#{number}"
50
               )
51
    end)
52
53
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories")
54
    last_repository = Repositories.get_by_path!("pagination-owner", "paged-21")
55
56
    assert has_element?(view, "#repositories-load-more")
57
    assert has_element?(view, "#repositories")
58
    refute has_element?(view, "#repositories-#{last_repository.id}")
59
60
    view |> element("#repositories-load-more") |> render_click()
61
62
    assert has_element?(view, "#repositories-#{last_repository.id}")
63
    refute has_element?(view, "#repositories-load-more")
64
  end
65
41 66
  test "new repository defaults private and normalizes its name", %{conn: conn} do
42 67
    user = github_user("repository-live-create", "create-owner")
43 68
    {:ok, view, _html} = live(log_in(conn, user), ~p"/repositories/new")

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