Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/fountain/lib/fountain/conversations/sandbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ defmodule Fountain.Conversations.Sandbox do
field :provider, :string, default: "sprites"
# Adapter-opaque state (e.g. a server-assigned id). Never tenant-visible.
field :provider_meta, :map, default: %{}
# Trusted control-plane identity; general sandbox attributes cannot set it.
field :provider_instance_id, :string
field :mode, :string, default: "ephemeral"
field :terminated_at, :utc_datetime
field :last_resumed_at, :utc_datetime
Expand Down
101 changes: 101 additions & 0 deletions apps/fountain/lib/fountain/conversations/sandbox_identity.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
defmodule Fountain.Conversations.SandboxIdentity do
@moduledoc """
Persist a sandbox's provider identity from an owned control-plane lookup.

This is identity recording, not authorization for a subsequent provider write.
Lifecycle operations still need durable intent and a coordinated incarnation
check; a GET followed by a name-based DELETE is not atomic.
"""

import Ecto.Query
alias Fountain.Conversations.Sandbox
alias Fountain.{Audit, Repo}
alias Managoat.Sandbox.Handle

@doc "Read control metadata, then bind the unchanged owned row; refuses an open transaction."
def _unsafe_capture(%Sandbox{} = observed, %Handle{} = handle) do
if Repo.in_transaction?(),
do: {:error, :transaction_open},
else: capture(observed, handle)
end

defp capture(observed, handle) do
with true <- handle.name == observed.sprite_name,
true <- is_atom(handle.provider),
true <- Atom.to_string(handle.provider) == observed.provider,
{:ok, %{raw: %{"name" => name, "id" => id}}} <- Managoat.Sandbox.get(handle),
true <- name == observed.sprite_name do
# Ownership: the caller supplied its owned sandbox; binding rechecks the row.
_unsafe_bind(observed, id)
else
false -> {:error, :ownership_changed}
{:error, _} = error -> error
_ -> {:error, :provider_identity_missing}
end
end

@doc """
Bind trusted provider metadata once; never accept identity from worker output.
Call outside a transaction so the best-effort audit follows the binding commit.
"""
def _unsafe_bind(%Sandbox{} = observed, id) when is_binary(id) and byte_size(id) in 1..256 do
if Repo.in_transaction?(), do: {:error, :transaction_open}, else: bind(observed, id)
end

def _unsafe_bind(%Sandbox{}, _), do: {:error, :provider_identity_missing}

defp bind(observed, id) do
result =
Repo.transaction(fn ->
Repo.query!("SELECT pg_advisory_xact_lock($1, $2)", [4316, :erlang.phash2(observed.id)])
current = Repo.one(from s in Sandbox, where: s.id == ^observed.id, lock: "FOR UPDATE")
if is_nil(current), do: Repo.rollback(:not_found)

unless same_binding?(current, observed), do: Repo.rollback(:ownership_changed)
if current.status in ["failed", "terminated"], do: Repo.rollback(:sandbox_retired)

case current.provider_instance_id do
nil ->
changeset =
current
|> Ecto.Changeset.change(provider_instance_id: id)
|> Ecto.Changeset.unique_constraint([:provider, :provider_instance_id])

case Repo.update(changeset) do
{:ok, bound} -> {bound, true}
{:error, changeset} -> Repo.rollback(changeset)
end

^id ->
{current, false}

_ ->
Repo.rollback(:provider_identity_changed)
end
end)

case result do
{:ok, {bound, changed?}} ->
if changed? do
Audit.record(%{
user_id: bound.user_id,
action: "sandbox.provider_identity_bound",
resource_type: "sandbox",
resource_id: bound.id,
actor: "system:sandbox_identity",
metadata: %{"provider" => bound.provider}
})
end

{:ok, bound}

{:error, _} = error ->
error
end
end

defp same_binding?(current, observed) do
current.user_id == observed.user_id and current.provider == observed.provider and
current.sprite_name == observed.sprite_name
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
defmodule Fountain.Repo.Migrations.AddSandboxProviderIdentity do
use Ecto.Migration

def up do
alter table(:sandboxes) do
add :provider_instance_id, :text
end

# Retired rows retain the identity of their provider instance too.
create unique_index(:sandboxes, [:provider, :provider_instance_id],
where: "provider_instance_id IS NOT NULL"
)
end

def down do
execute "LOCK TABLE sandboxes IN ACCESS EXCLUSIVE MODE"

execute """
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM sandboxes WHERE provider_instance_id IS NOT NULL) THEN
RAISE EXCEPTION 'cannot discard recorded sandbox provider identities';
END IF;
END $$;
"""

drop index(:sandboxes, [:provider, :provider_instance_id])

alter table(:sandboxes) do
remove :provider_instance_id
end
end
end
Loading
Loading