Current section

Files

Jump to
agent_session_manager lib asm managed_session.ex
Raw

lib/asm/managed_session.ex

defmodule ASM.ManagedSession do
@moduledoc """
Secret-free identity and lifecycle state for one governed provider session.
Session and execution identity are opaque strings plus generations and
fences. Provider process identifiers are never part of this contract.
"""
@statuses ~w(allocated starting active draining completed failed cancelled ambiguous)
@terminal_statuses ~w(completed failed cancelled ambiguous)
# Attribute key NAMES that may never appear in a governed session's attrs. This
# is a denylist of names; no value for any of them is ever accepted or stored.
@forbidden_attr_keys ~w(
api_key auth_root authorization client_secret config_root credential env home
material password pid private_key raw_credential refresh_token secret token
)
@fields [
:contract_version,
:session_ref,
:generation,
:provider_account_ref,
:credential_generation,
:materialization_ref,
:authority_ref,
:target_ref,
:runtime_gateway,
:execution_ref,
:provider_session_ref,
:receipt_ref,
:status,
:fence,
:row_version
]
@required @fields -- [:execution_ref, :provider_session_ref, :receipt_ref]
@enforce_keys @required
defstruct @fields
@type t :: %__MODULE__{}
def new(%__MODULE__{} = session), do: validate(session)
def new(attrs) when is_map(attrs) or is_list(attrs) do
attrs = Map.new(attrs)
session = %__MODULE__{
contract_version: value(attrs, :contract_version, 1),
session_ref: value(attrs, :session_ref),
generation: value(attrs, :generation),
provider_account_ref: value(attrs, :provider_account_ref),
credential_generation: value(attrs, :credential_generation),
materialization_ref: value(attrs, :materialization_ref),
authority_ref: value(attrs, :authority_ref),
target_ref: value(attrs, :target_ref),
runtime_gateway: value(attrs, :runtime_gateway),
execution_ref: value(attrs, :execution_ref),
provider_session_ref: value(attrs, :provider_session_ref),
receipt_ref: value(attrs, :receipt_ref),
status: attrs |> value(:status) |> normalize_string(),
fence: value(attrs, :fence),
row_version: value(attrs, :row_version, 1)
}
if known_fields?(attrs) and safe_attrs?(attrs),
do: validate(session),
else: {:error, :invalid_managed_session}
end
def new(_attrs), do: {:error, :invalid_managed_session}
def new!(attrs) do
case new(attrs) do
{:ok, session} -> session
{:error, reason} -> raise ArgumentError, Atom.to_string(reason)
end
end
def terminal?(%__MODULE__{status: status}), do: status in @terminal_statuses
def statuses, do: @statuses
defp validate(%__MODULE__{} = session) do
required_refs = [
session.session_ref,
session.provider_account_ref,
session.materialization_ref,
session.authority_ref,
session.target_ref,
session.runtime_gateway
]
with true <- session.contract_version == 1,
true <- Enum.all?(required_refs, &safe_ref?/1),
true <- positive_integer?(session.generation),
true <- positive_integer?(session.credential_generation),
true <- session.status in @statuses,
true <- non_negative_integer?(session.fence),
true <- positive_integer?(session.row_version),
true <- optional_ref?(session.execution_ref),
true <- optional_ref?(session.provider_session_ref),
true <- optional_ref?(session.receipt_ref),
true <- coherent_state?(session) do
{:ok, session}
else
_other -> {:error, :invalid_managed_session}
end
end
defp coherent_state?(%__MODULE__{status: "allocated"} = session) do
is_nil(session.execution_ref) and is_nil(session.provider_session_ref) and
is_nil(session.receipt_ref)
end
defp coherent_state?(%__MODULE__{status: "starting"} = session) do
is_nil(session.provider_session_ref) and is_nil(session.receipt_ref)
end
defp coherent_state?(%__MODULE__{status: status} = session)
when status in ~w(active draining) do
safe_ref?(session.execution_ref) and is_nil(session.receipt_ref)
end
defp coherent_state?(%__MODULE__{} = session) do
terminal?(session) and safe_ref?(session.receipt_ref)
end
defp safe_attrs?(attrs) do
Enum.all?(attrs, fn {key, nested} ->
normalized = key |> to_string() |> String.downcase()
normalized not in @forbidden_attr_keys and not String.starts_with?(normalized, "raw_") and
not is_pid(nested) and not is_port(nested) and not is_reference(nested)
end)
end
defp known_fields?(attrs) do
allowed = MapSet.new(Enum.flat_map(@fields, &[&1, Atom.to_string(&1)]))
Enum.all?(Map.keys(attrs), &MapSet.member?(allowed, &1))
end
defp optional_ref?(nil), do: true
defp optional_ref?(value), do: safe_ref?(value)
defp safe_ref?(value), do: is_binary(value) and String.trim(value) != "" and not path?(value)
defp path?(value), do: String.starts_with?(value, ["/", "~/"])
defp positive_integer?(value), do: is_integer(value) and value > 0
defp non_negative_integer?(value), do: is_integer(value) and value >= 0
defp value(attrs, key, default \\ nil),
do: Map.get(attrs, key, Map.get(attrs, Atom.to_string(key), default))
defp normalize_string(value) when is_atom(value), do: Atom.to_string(value)
defp normalize_string(value), do: value
end
defmodule ASM.ManagedSession.Lifecycle do
@moduledoc "Optimistic lifecycle validation for governed ASM sessions."
alias ASM.ManagedSession
@transitions %{
"allocated" => ~w(starting cancelled),
"starting" => ~w(active failed cancelled ambiguous),
"active" => ~w(draining completed failed cancelled ambiguous),
"draining" => ~w(completed failed cancelled ambiguous),
"completed" => [],
"failed" => [],
"cancelled" => [],
"ambiguous" => []
}
@update_fields [:execution_ref, :provider_session_ref, :receipt_ref]
def transition(%ManagedSession{} = session, next_status, attrs)
when is_map(attrs) or is_list(attrs) do
attrs = Map.new(attrs)
next_status = normalize_string(next_status)
with true <- known_fields?(attrs),
true <- value(attrs, :expected_row_version) == session.row_version,
true <- next_status in Map.fetch!(@transitions, session.status) do
result =
session
|> apply_updates(attrs)
|> Map.put(:status, next_status)
|> Map.put(:row_version, session.row_version + 1)
|> ManagedSession.new()
case result do
{:ok, _session} = ok -> ok
{:error, _reason} -> {:error, :invalid_managed_session_transition}
end
else
false -> {:error, :invalid_managed_session_transition}
end
end
def transition(%ManagedSession{}, _next_status, _attrs),
do: {:error, :invalid_managed_session_transition}
defp apply_updates(%ManagedSession{} = session, attrs) do
Enum.reduce(@update_fields, Map.from_struct(session), fn field, acc ->
case fetch(attrs, field) do
{:ok, nested} -> Map.put(acc, field, nested)
:error -> acc
end
end)
end
defp known_fields?(attrs) do
fields = [:expected_row_version | @update_fields]
allowed = MapSet.new(Enum.flat_map(fields, &[&1, Atom.to_string(&1)]))
Enum.all?(Map.keys(attrs), &MapSet.member?(allowed, &1))
end
defp fetch(attrs, key) do
case Map.fetch(attrs, key) do
{:ok, nested} -> {:ok, nested}
:error -> Map.fetch(attrs, Atom.to_string(key))
end
end
defp value(attrs, key), do: Map.get(attrs, key, Map.get(attrs, Atom.to_string(key)))
defp normalize_string(value) when is_atom(value), do: Atom.to_string(value)
defp normalize_string(value), do: value
end