Skip to content
2 changes: 2 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ config :kodo, Kodo.Integrations.CredentialEncryption,
current_key_version: "test-v1",
keys: %{"test-v1" => :binary.copy(<<1>>, 32), "test-old" => :binary.copy(<<2>>, 32)}

config :kodo, :openai_validation_client, Kodo.Test.FakeOpenAIValidationClient

# We don't run a server during test. If one is required,
# you can enable the server option below.
config :kodo, KodoWeb.Endpoint,
Expand Down
18 changes: 16 additions & 2 deletions crates/kodo/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ fn canonicalize(path: &Path) -> Result<PathBuf, WorkspaceError> {
mod tests {
use std::fs;
use std::io::{Read, Seek, SeekFrom};
use std::sync::mpsc;
use std::thread;

use tempfile::TempDir;

Expand All @@ -375,14 +377,26 @@ mod tests {
fn permits_only_one_runner_for_a_workspace() {
let repository = git_repository();
let workspace = Workspace::from_root(repository.path()).unwrap();
let first = workspace.lock_runner().unwrap();
let holder_workspace = workspace.clone();
let (locked_tx, locked_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let holder = thread::spawn(move || {
let lock = holder_workspace.lock_runner().unwrap();
locked_tx.send(()).unwrap();
release_rx.recv().unwrap();
drop(lock);
});

// Do not let the contender race the thread intended to hold the lock.
locked_rx.recv().unwrap();

assert!(matches!(
workspace.lock_runner(),
Err(WorkspaceError::RunnerAlreadyActive(path)) if path == workspace.root()
));

drop(first);
release_tx.send(()).unwrap();
holder.join().unwrap();
workspace.lock_runner().unwrap();
}

Expand Down
128 changes: 100 additions & 28 deletions lib/kodo/integrations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ defmodule Kodo.Integrations do
import Ecto.Query

alias Kodo.Accounts.Scope
alias Kodo.Integrations.AuditEvent
alias Kodo.Integrations.CredentialEncryption
alias Kodo.Integrations.Integration
alias Kodo.Repo
Expand Down Expand Up @@ -34,6 +35,13 @@ defmodule Kodo.Integrations do
end
end

def list_audit_events(%Scope{user: user}) do
AuditEvent
|> where([event], event.actor_user_id == ^user.id)
|> order_by([event], asc: event.inserted_at, asc: event.id)
|> Repo.all()
end

def connect(scope, provider, authentication_type, credentials, opts \\ [])

def connect(%Scope{user: user}, provider, "api_key", credentials, opts) do
Expand All @@ -57,8 +65,7 @@ defmodule Kodo.Integrations do
})
)
|> Integration.constraint_changeset()
|> Repo.insert()
|> normalize_insert_result()
|> insert_with_audit(user.id, "api_key_submitted")
else
false -> {:error, changeset}
{:error, _reason} = error -> error
Expand All @@ -75,8 +82,22 @@ defmodule Kodo.Integrations do
generation,
credentials,
opts,
Integration.connection_statuses(),
"api_key"
["connected"],
"api_key",
"api_key_replaced"
)
end

def reconnect_api_key(%Scope{} = scope, id, generation, credentials, opts \\ []) do
install_credentials(
scope,
id,
generation,
credentials,
opts,
["disconnected"],
"api_key",
"api_key_submitted"
)
end

Expand All @@ -88,7 +109,8 @@ defmodule Kodo.Integrations do
credentials,
opts,
Integration.connection_statuses(),
"oauth"
"oauth",
"oauth_succeeded"
)
end

Expand All @@ -100,20 +122,21 @@ defmodule Kodo.Integrations do
credentials,
opts,
~w(connected reauthorization_required),
"oauth"
"oauth",
"refresh_succeeded"
)
end

def validation_succeeded(%Scope{} = scope, id, generation) do
update_fenced(scope, id, generation, ["connected"], %{
update_fenced(scope, id, generation, ["connected"], "validation_succeeded", %{
validation_status: "valid",
validated_at: now(),
validation_error_code: nil
})
end

def validation_invalid(%Scope{} = scope, id, generation) do
update_fenced(scope, id, generation, ["connected"], %{
update_fenced(scope, id, generation, ["connected"], "validation_invalid", %{
validation_status: "invalid",
validated_at: now(),
validation_error_code: "invalid_credentials"
Expand All @@ -122,7 +145,7 @@ defmodule Kodo.Integrations do

def validation_unavailable(%Scope{} = scope, id, generation, error_code)
when error_code in @safe_validation_errors do
update_fenced(scope, id, generation, ["connected"], %{
update_fenced(scope, id, generation, ["connected"], "validation_unavailable", %{
validation_status: "unavailable",
validated_at: now(),
validation_error_code: error_code
Expand All @@ -135,7 +158,7 @@ defmodule Kodo.Integrations do
def refresh_invalid_grant(%Scope{} = scope, id, generation) do
with {:ok, integration} <- get_integration(scope, id),
true <- integration.authentication_type == "oauth" do
update_fenced(scope, id, generation, ["connected"], %{
update_fenced(scope, id, generation, ["connected"], "refresh_invalid_grant", %{
connection_status: "reauthorization_required",
validation_status: "unverified",
validated_at: nil,
Expand All @@ -148,18 +171,25 @@ defmodule Kodo.Integrations do
end

def disconnect(%Scope{} = scope, id, generation) do
update_fenced(scope, id, generation, Integration.connection_statuses(), %{
connection_status: "disconnected",
validation_status: "unverified",
encrypted_credentials: nil,
encryption_key_version: nil,
credential_format_version: nil,
credential_generation: generation + 1,
expires_at: nil,
validated_at: nil,
refreshed_at: nil,
validation_error_code: nil
})
update_fenced(
scope,
id,
generation,
Integration.connection_statuses(),
"integration_disconnected",
%{
connection_status: "disconnected",
validation_status: "unverified",
encrypted_credentials: nil,
encryption_key_version: nil,
credential_format_version: nil,
credential_generation: generation + 1,
expires_at: nil,
validated_at: nil,
refreshed_at: nil,
validation_error_code: nil
}
)
end

def safe_validation_errors, do: @safe_validation_errors
Expand All @@ -171,7 +201,8 @@ defmodule Kodo.Integrations do
credentials,
opts,
allowed_connections,
authentication_type
authentication_type,
audit_event_type
) do
with {:ok, integration} <- get_integration(scope, id),
:ok <- require_generation(integration, generation),
Expand All @@ -188,7 +219,7 @@ defmodule Kodo.Integrations do
validation_error_code: nil
})

update_fenced(scope, id, generation, allowed_connections, changes)
update_fenced(scope, id, generation, allowed_connections, audit_event_type, changes)
else
{:error, _reason} = error -> error
end
Expand All @@ -202,21 +233,39 @@ defmodule Kodo.Integrations do
defp require_authentication_type(%Integration{}, _type),
do: {:error, :authentication_type_mismatch}

defp update_fenced(%Scope{user: user}, id, generation, allowed_connections, changes)
defp update_fenced(
%Scope{user: user},
id,
generation,
allowed_connections,
audit_event_type,
changes
)
when is_integer(generation) and generation >= 0 do
case Ecto.UUID.cast(id) do
{:ok, id} ->
Repo.transaction(fn ->
execute_fenced_update(user.id, id, generation, allowed_connections, changes)
integration =
execute_fenced_update(user.id, id, generation, allowed_connections, changes)

audit!(user.id, integration, audit_event_type)
integration
end)

:error ->
{:error, :stale_credential_generation}
end
end

defp update_fenced(%Scope{}, _id, _generation, _allowed_connections, _changes),
do: {:error, :stale_credential_generation}
defp update_fenced(
%Scope{},
_id,
_generation,
_allowed_connections,
_audit_event_type,
_changes
),
do: {:error, :stale_credential_generation}

defp execute_fenced_update(user_id, id, generation, allowed_connections, changes) do
query =
Expand All @@ -242,6 +291,29 @@ defmodule Kodo.Integrations do

defp normalize_insert_result(result), do: result

defp insert_with_audit(changeset, actor_user_id, event_type) do
Repo.transaction(fn ->
case changeset |> Repo.insert() |> normalize_insert_result() do
{:ok, integration} ->
audit!(actor_user_id, integration, event_type)
integration

{:error, reason} ->
Repo.rollback(reason)
end
end)
end

defp audit!(actor_user_id, integration, event_type) do
%AuditEvent{actor_user_id: actor_user_id, integration_id: integration.id}
|> AuditEvent.changeset(%{
provider: integration.provider,
event_type: event_type,
credential_generation: integration.credential_generation
})
|> Repo.insert!()
end

defp constraint_error?(changeset, type) do
Enum.any?(changeset.errors, fn {_field, {_message, metadata}} ->
metadata[:constraint] == type
Expand Down
42 changes: 42 additions & 0 deletions lib/kodo/integrations/audit_event.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
defmodule Kodo.Integrations.AuditEvent do
@moduledoc "A credential-free record of a sensitive provider-integration action."

use Ecto.Schema
import Ecto.Changeset

@event_types ~w(
api_key_submitted
api_key_replaced
validation_succeeded
validation_invalid
validation_unavailable
integration_disconnected
oauth_succeeded
refresh_succeeded
refresh_invalid_grant
)

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id

schema "integration_audit_events" do
field :provider, :string
field :event_type, :string
field :credential_generation, :integer

belongs_to :actor_user, Kodo.Accounts.User, type: :id
belongs_to :integration, Kodo.Integrations.Integration

timestamps(type: :utc_datetime_usec, updated_at: false)
end

@doc false
def changeset(event, attrs) do
event
|> cast(attrs, [:provider, :event_type, :credential_generation])
|> validate_required([:provider, :event_type, :credential_generation])
|> validate_inclusion(:event_type, @event_types)
|> foreign_key_constraint(:actor_user_id)
|> foreign_key_constraint(:integration_id)
end
end
Loading
Loading