diff --git a/flake.nix b/flake.nix index 93c6a9a..f706704 100644 --- a/flake.nix +++ b/flake.nix @@ -3,11 +3,16 @@ inputs = { tribes.url = "git+https://git.teralink.net/tribes/tribes.git"; + trust = { + url = "git+https://git.teralink.net/tribes/tribes-plugin-trust.git"; + flake = false; + }; }; outputs = { self, tribes, + trust, }: let systems = [ "x86_64-linux" @@ -56,7 +61,9 @@ }; src = cleanSource ./.; + trustSrc = cleanSource trust; koboldManifest = builtins.fromJSON (builtins.readFile ./manifest.json); + trustManifest = builtins.fromJSON (builtins.readFile "${trust}/manifest.json"); buildTribesPlugin = pkgs.callPackage "${tribes}/nix/tribes-plugin-package.nix" {}; koboldPlugin = buildTribesPlugin { @@ -77,6 +84,16 @@ meta.description = "Distributed dataset plugin for Tribes"; }; + trustPluginE2E = buildTribesPlugin { + pname = "tribes-plugin-trust"; + inherit version; + src = trustSrc; + hostRelease = tribes.packages.${system}.tribesReleaseE2E; + hostSource = tribes; + mixFodDepsHash = "sha256-O8+48XnD2eFRW/w6sHvo3qS5hunddEGVGpvjMduClBM="; + meta.description = "Tribe trust plugin for Tribes"; + }; + koboldExternalPlugin = package: { name = "kobold"; inherit package; @@ -84,6 +101,13 @@ extraPackages = []; }; + trustExternalPlugin = package: { + name = "trust"; + inherit package; + manifest = trustManifest; + extraPackages = []; + }; + koboldWithTribesE2E = (import "${tribes}/default.nix" { inherit pkgs lib; @@ -94,6 +118,7 @@ name = "tribes_ui"; path = "./plugins/tribes_ui"; } + (trustExternalPlugin trustPluginE2E) (koboldExternalPlugin koboldPluginE2E) ]; }) @@ -101,7 +126,7 @@ in { default = koboldPlugin; - inherit koboldPlugin koboldPluginE2E koboldWithTribesE2E; + inherit koboldPlugin koboldPluginE2E trustPluginE2E koboldWithTribesE2E; } // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { koboldDockerE2E = pkgs.dockerTools.buildLayeredImage { diff --git a/lib/kobold/api.ex b/lib/kobold/api.ex index adf4072..fb521ab 100644 --- a/lib/kobold/api.ex +++ b/lib/kobold/api.ex @@ -21,6 +21,7 @@ defmodule TribeOne.TribesPlugin.Kobold.API do def management_upsert_record(_context, params), do: upsert_record(params) def management_state(_context, params), do: state(params) + def management_access_rule_create(_context, params), do: create_access_rule(params) def management_rebuild_projections(_context, params), do: rebuild_projections(params) def schema do @@ -79,7 +80,8 @@ defmodule TribeOne.TribesPlugin.Kobold.API do action = if visibility(params) == :public, do: :create_public_dataset, else: :create_private_dataset - with {:ok, dataset} <- apply(Kobold, action, [attrs, [authorize?: false]]) do + with {:ok, dataset} <- apply(Kobold, action, [attrs, [authorize?: false]]), + :ok <- maybe_seed_dataset_policy(dataset) do {:ok, %{ok: true, dataset: dataset_json(dataset)}} end rescue @@ -118,6 +120,7 @@ defmodule TribeOne.TribesPlugin.Kobold.API do resource_name = required_string!(params, "resource_name") with {:ok, dataset} <- Kobold.get_dataset(dataset_id, authorize?: false), + :ok <- authorize_dataset(params, dataset, "write"), {:ok, definition} <- Kobold.get_resource_definition(dataset.id, resource_name, authorize?: false) do fields = map_param(params, "fields") @@ -163,6 +166,7 @@ defmodule TribeOne.TribesPlugin.Kobold.API do run_id = optional_string(params, "run_id") with {:ok, datasets} <- datasets_for_run(run_id), + {:ok, datasets} <- authorize_datasets(params, datasets, "read"), {:ok, resources} <- resources_for_datasets(datasets), {:ok, events} <- events_for_run(run_id, datasets) do {:ok, @@ -177,6 +181,26 @@ defmodule TribeOne.TribesPlugin.Kobold.API do end end + def create_access_rule(params) do + with {:ok, rule} <- + Tribes.Access.put_rule(%{ + plugin_name: "kobold", + resource_type: required_string!(params, "resource_type"), + resource_id: Map.get(params, "resource_id", "*"), + action: required_string!(params, "action"), + subject_type: Map.get(params, "subject_type", "tribe"), + subject_id: Map.get(params, "subject_id", "*"), + effect: Map.get(params, "effect", "allow"), + condition: Map.get(params, "condition", "none"), + min_trust_score: Map.get(params, "min_trust_score"), + note: optional_string(params, "note") + }) do + {:ok, %{ok: true, rule: rule}} + end + rescue + error in ArgumentError -> {:error, error.message} + end + def rebuild_projections(params) do with {:ok, %{events: events}} <- state_events(params), :ok <- rebuild_events(events) do @@ -202,6 +226,76 @@ defmodule TribeOne.TribesPlugin.Kobold.API do end) end + defp maybe_seed_dataset_policy(%Dataset{visibility: :public} = dataset) do + with {:ok, _read_rule} <- + Tribes.Access.put_rule(%{ + plugin_name: "kobold", + resource_type: "kobold.dataset", + resource_id: dataset.id, + action: "read", + subject_type: "tribe", + subject_id: "*", + effect: "allow", + condition: "min_trust_score", + min_trust_score: 0, + note: "Default public dataset read access after Trust handshake" + }), + {:ok, _advertise_rule} <- + Tribes.Access.put_rule(%{ + plugin_name: "kobold", + resource_type: "kobold.dataset", + resource_id: dataset.id, + action: "advertise", + subject_type: "tribe", + subject_id: "*", + effect: "allow", + condition: "min_trust_score", + min_trust_score: 0, + note: "Default public dataset advertisement after Trust handshake" + }) do + :ok + else + {:error, reason} -> {:error, reason} + end + end + + defp maybe_seed_dataset_policy(%Dataset{}), do: :ok + + defp authorize_datasets(params, datasets, action) do + case remote_subject(params) do + nil -> + {:ok, datasets} + + subject -> + {:ok, Enum.filter(datasets, &dataset_allowed?(subject, &1, action))} + end + end + + defp authorize_dataset(params, dataset, action) do + case remote_subject(params) do + nil -> + :ok + + subject -> + if dataset_allowed?(subject, dataset, action) do + :ok + else + {:error, :access_denied} + end + end + end + + defp dataset_allowed?(subject, dataset, action) do + Tribes.Access.allowed?(subject, action, Tribes.Access.resource("kobold.dataset", dataset.id)) + end + + defp remote_subject(params) do + case optional_string(params, "remote_tribe_pubkey") do + nil -> nil + pubkey -> Tribes.Access.subject(:tribe, pubkey) + end + end + defp datasets_for_run(nil), do: Kobold.list_datasets(authorize?: false) defp datasets_for_run(run_id), do: Kobold.list_datasets_by_run(run_id, authorize?: false) diff --git a/lib/kobold/plugin.ex b/lib/kobold/plugin.ex index 28f3b76..ad3c4a9 100644 --- a/lib/kobold/plugin.ex +++ b/lib/kobold/plugin.ex @@ -85,6 +85,14 @@ defmodule TribeOne.TribesPlugin.Kobold.Plugin do auth: :admin, description: "Read Kobold dataset state" }, + %{ + name: "kobold.access.rules.create", + version: "1", + module: TribeOne.TribesPlugin.Kobold.API, + action: :management_access_rule_create, + auth: :admin, + description: "Create a Kobold access rule" + }, %{ name: "kobold.projections.rebuild", version: "1", @@ -96,7 +104,33 @@ defmodule TribeOne.TribesPlugin.Kobold.Plugin do ], metrics: [], plugs: [], - hooks: %{} + hooks: %{}, + access_schema: %{ + title: "Kobold permissions", + description: "Dataset and feature access rules for Kobold.", + resources: [ + %{ + type: "kobold.dataset", + label: "Kobold dataset", + description: "Controls read/write/admin access to individual Kobold datasets.", + actions: [ + %{name: "advertise", label: "Advertise"}, + %{name: "read", label: "Read"}, + %{name: "write", label: "Write"}, + %{name: "admin", label: "Admin"} + ] + }, + %{ + type: "kobold.feature", + label: "Kobold feature", + description: "Controls access to plugin-level Kobold features.", + actions: [ + %{name: "use", label: "Use"}, + %{name: "admin", label: "Admin"} + ] + } + ] + } }) end end diff --git a/manifest.json b/manifest.json index 697b27d..f4f49ae 100644 --- a/manifest.json +++ b/manifest.json @@ -11,7 +11,8 @@ "org.tribes.kobold.dataset@1" ], "requires": [ - "org.tribe-one.caps.ui@1" + "org.tribe-one.caps.ui@1", + "org.tribes.alliance.trust@1" ], "enhances_with": [], "assets": { diff --git a/scripts/kobold_e2e_runner.exs b/scripts/kobold_e2e_runner.exs index 3089f91..5474780 100644 --- a/scripts/kobold_e2e_runner.exs +++ b/scripts/kobold_e2e_runner.exs @@ -21,12 +21,16 @@ defmodule TribeOne.TribesPlugin.Kobold.E2ERunner do {:ok, edge_info} = admin_with_retry(@edge, "node_info", %{}, attempts: 90, delay_ms: 1_000) - assert_plugin_loaded!(@origin, "origin") - assert_plugin_loaded!(@edge, "edge") + assert_plugin_loaded!(@origin, "origin", "kobold") + assert_plugin_loaded!(@edge, "edge", "kobold") + assert_plugin_loaded!(@origin, "origin", "trust") + assert_plugin_loaded!(@edge, "edge", "trust") assert_kobold_schema_ready!(@origin, "origin") assert_kobold_schema_ready!(@edge, "edge") :ok = connect_cluster_pair(@origin, origin_info, @edge, edge_info) + edge_pubkey = node_pubkey(edge_info) + :ok = establish_unknown_tribe_trust(@origin, edge_pubkey) run_id = "kobold-docker-e2e-#{System.system_time(:millisecond)}" {:ok, _} = kobold(@origin, "kobold.reset", %{"run_id" => run_id}) @@ -63,6 +67,25 @@ defmodule TribeOne.TribesPlugin.Kobold.E2ERunner do "fields" => %{"name" => "Private pepper", "germination_days" => 14} }) + assert_remote_cannot_read_or_write_private!(run_id, private_dataset["id"], edge_pubkey) + :ok = grant_dataset_access(@origin, private_dataset["id"], edge_pubkey, "read") + :ok = grant_dataset_access(@origin, private_dataset["id"], edge_pubkey, "write") + + {:ok, %{"record" => private_remote_record}} = + kobold(@origin, "kobold.records.upsert", %{ + "dataset_id" => private_dataset["id"], + "resource_name" => "SeedVariety", + "remote_tribe_pubkey" => edge_pubkey, + "fields" => %{"name" => "Remote-written private bean", "germination_days" => 10} + }) + + assert_remote_can_read_private!( + run_id, + private_dataset["id"], + private_remote_record["record_id"], + edge_pubkey + ) + assert_origin_state!(run_id, public_record["record_id"], private_record["record_id"]) assert_ok( @@ -93,6 +116,46 @@ defmodule TribeOne.TribesPlugin.Kobold.E2ERunner do end end + defp assert_remote_cannot_read_or_write_private!(run_id, private_dataset_id, edge_pubkey) do + {:ok, state} = + kobold(@origin, "kobold.state", %{ + "run_id" => run_id, + "remote_tribe_pubkey" => edge_pubkey + }) + + assert_ok( + Enum.all?(Map.get(state, "datasets", []), &(&1["id"] != private_dataset_id)), + "remote tribe cannot read private dataset without permission" + ) + + assert_ok( + match?( + {:error, %{body: %{"error" => error}}} when is_binary(error), + kobold(@origin, "kobold.records.upsert", %{ + "dataset_id" => private_dataset_id, + "resource_name" => "SeedVariety", + "remote_tribe_pubkey" => edge_pubkey, + "fields" => %{"name" => "Denied private bean", "germination_days" => 11} + }) + ), + "remote tribe cannot write private dataset without permission" + ) + end + + defp assert_remote_can_read_private!(run_id, private_dataset_id, record_id, edge_pubkey) do + {:ok, state} = + kobold(@origin, "kobold.state", %{ + "run_id" => run_id, + "remote_tribe_pubkey" => edge_pubkey + }) + + assert_ok( + Enum.any?(Map.get(state, "datasets", []), &(&1["id"] == private_dataset_id)) and + Enum.any?(Map.get(state, "records", []), &(&1["record_id"] == record_id)), + "remote tribe can read explicitly shared private dataset" + ) + end + defp assert_origin_state!(run_id, public_record_id, private_record_id) do {:ok, state} = kobold(@origin, "kobold.state", %{"run_id" => run_id}) records = Map.get(state, "records", []) @@ -135,16 +198,16 @@ defmodule TribeOne.TribesPlugin.Kobold.E2ERunner do end) end - defp assert_plugin_loaded!(base_url, label) do + defp assert_plugin_loaded!(base_url, label, plugin_name) do {:ok, plugins} = admin_with_retry(base_url, "plugin_list", %{}, attempts: 30, delay_ms: 1_000) loaded? = Enum.any?(plugins["plugins"], fn - %{"name" => "kobold", "status" => "loaded"} -> true + %{"name" => ^plugin_name, "status" => "loaded"} -> true _other -> false end) - assert_ok(loaded?, "#{label} kobold plugin loaded") + assert_ok(loaded?, "#{label} #{plugin_name} plugin loaded") end defp assert_kobold_schema_ready!(base_url, label) do @@ -158,6 +221,35 @@ defmodule TribeOne.TribesPlugin.Kobold.E2ERunner do ) end + defp establish_unknown_tribe_trust(base_url, edge_pubkey) do + with {:ok, _relationship} <- + trust(base_url, "trust.hello.receive", %{ + "from_tribe" => edge_pubkey, + "name" => "Kobold edge", + "api_url" => @edge <> "/api/tribes/v1", + "relay_urls" => ["ws://kobold-edge:4000/nostr/relay"], + "capabilities" => ["org.tribes.kobold.dataset@1"], + "requested_capabilities" => ["org.tribes.kobold.dataset.read@1"] + }) do + :ok + end + end + + defp grant_dataset_access(base_url, dataset_id, subject_id, action) do + with {:ok, _rule} <- + kobold(base_url, "kobold.access.rules.create", %{ + "resource_type" => "kobold.dataset", + "resource_id" => dataset_id, + "action" => action, + "subject_type" => "tribe", + "subject_id" => subject_id, + "effect" => "allow", + "condition" => "none" + }) do + :ok + end + end + defp kobold(base_url, method, params) do admin(base_url, "plugin.call", %{ "plugin" => "kobold", @@ -167,6 +259,15 @@ defmodule TribeOne.TribesPlugin.Kobold.E2ERunner do }) end + defp trust(base_url, method, params) do + admin(base_url, "plugin.call", %{ + "plugin" => "trust", + "method" => method, + "version" => "1", + "params" => params + }) + end + defp connect_cluster_pair(left_base_url, left_node_info, right_base_url, right_node_info) do with {:ok, _} <- upsert_cluster_node(left_base_url, right_node_info), {:ok, _} <- upsert_cluster_node(right_base_url, left_node_info) do diff --git a/scripts/run_docker_e2e.sh b/scripts/run_docker_e2e.sh index de9d492..f0c0af1 100755 --- a/scripts/run_docker_e2e.sh +++ b/scripts/run_docker_e2e.sh @@ -4,15 +4,19 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" HOST_ROOT="${TRIBES_HOST_ROOT:-$ROOT_DIR/../tribes}" HOST_ROOT="$(cd "$HOST_ROOT" && pwd -P)" +TRUST_ROOT="${TRIBES_TRUST_ROOT:-$ROOT_DIR/../tribes-plugin-trust}" +TRUST_ROOT="$(cd "$TRUST_ROOT" && pwd -P)" COMPOSE_FILE="$ROOT_DIR/test/e2e/compose.kobold.yaml" PROJECT="kobold-e2e-$(date +%s)" IMAGE_LINK="$ROOT_DIR/.kobold-e2e-image" HOST_SOURCE_TMP="" +TRUST_SOURCE_TMP="" export COMPOSE_PROJECT_NAME="$PROJECT" export E2E_COMPOSE_FILE="$COMPOSE_FILE" export KOBOLD_PLUGIN_ROOT="$ROOT_DIR" export TRIBES_HOST_ROOT="$HOST_ROOT" +export TRIBES_TRUST_ROOT="$TRUST_ROOT" export TRIBES_KOBOLD_IMAGE="${TRIBES_KOBOLD_IMAGE:-tribes-kobold-e2e:latest}" cleanup() { @@ -25,6 +29,10 @@ cleanup() { if [[ -n "$HOST_SOURCE_TMP" ]]; then rm -rf "$HOST_SOURCE_TMP" fi + + if [[ -n "$TRUST_SOURCE_TMP" ]]; then + rm -rf "$TRUST_SOURCE_TMP" + fi } trap cleanup EXIT @@ -34,10 +42,11 @@ command -v docker >/dev/null 2>&1 || { exit 1 } -if [[ "${SKIP_BUILD:-0}" != "1" ]]; then - echo "==> preparing filtered Tribes host source" - HOST_SOURCE_TMP="$(mktemp -d -t kobold-tribes-host.XXXXXX)" - tar -C "$HOST_ROOT" \ +copy_filtered_source() { + local from="$1" + local to="$2" + + tar -C "$from" \ --exclude=.git \ --exclude=.devenv \ --exclude=.direnv \ @@ -46,12 +55,23 @@ if [[ "${SKIP_BUILD:-0}" != "1" ]]; then --exclude=node_modules \ --exclude=result \ --exclude='result-*' \ - -cf - . | tar -C "$HOST_SOURCE_TMP" -xf - + -cf - . | tar -C "$to" -xf - +} + +if [[ "${SKIP_BUILD:-0}" != "1" ]]; then + echo "==> preparing filtered Tribes host source" + HOST_SOURCE_TMP="$(mktemp -d -t kobold-tribes-host.XXXXXX)" + copy_filtered_source "$HOST_ROOT" "$HOST_SOURCE_TMP" + + echo "==> preparing filtered Trust plugin source" + TRUST_SOURCE_TMP="$(mktemp -d -t kobold-trust-plugin.XXXXXX)" + copy_filtered_source "$TRUST_ROOT" "$TRUST_SOURCE_TMP" echo "==> building kobold e2e image" image_path="$( nix build --impure "$ROOT_DIR#koboldDockerE2E" \ --override-input tribes "path:$HOST_SOURCE_TMP" \ + --override-input trust "path:$TRUST_SOURCE_TMP" \ --no-write-lock-file \ --out-link "$IMAGE_LINK" \ --print-out-paths \ diff --git a/test/kobold/api_test.exs b/test/kobold/api_test.exs index e572997..32b2ea4 100644 --- a/test/kobold/api_test.exs +++ b/test/kobold/api_test.exs @@ -61,6 +61,71 @@ defmodule TribeOne.TribesPlugin.Kobold.APITest do assert [%{fields: %{"species" => "Tomato"}}] = records end + test "remote tribe access uses generic permission rules" do + run_id = "kobold-access-#{System.unique_integer([:positive])}" + remote = "remote-access-tribe" + + assert {:ok, %{dataset: dataset}} = + API.create_dataset(%{ + "run_id" => run_id, + "name" => "Shared notes", + "visibility" => "private" + }) + + assert {:ok, %{resource: _resource}} = + API.create_resource_definition(%{ + "dataset_id" => dataset.id, + "name" => "Entry", + "fields" => %{"title" => %{"type" => "string", "required" => true}} + }) + + assert {:ok, %{datasets: []}} = + API.state(%{"run_id" => run_id, "remote_tribe_pubkey" => remote}) + + assert {:error, :access_denied} = + API.upsert_record(%{ + "dataset_id" => dataset.id, + "resource_name" => "Entry", + "remote_tribe_pubkey" => remote, + "fields" => %{"title" => "Denied"} + }) + + assert {:ok, _rule} = + Tribes.Access.put_rule(%{ + plugin_name: "kobold", + resource_type: "kobold.dataset", + resource_id: dataset.id, + action: "read", + subject_type: "tribe", + subject_id: remote, + effect: "allow" + }) + + assert {:ok, %{datasets: [%{id: dataset_id}]}} = + API.state(%{"run_id" => run_id, "remote_tribe_pubkey" => remote}) + + assert dataset_id == dataset.id + + assert {:ok, _rule} = + Tribes.Access.put_rule(%{ + plugin_name: "kobold", + resource_type: "kobold.dataset", + resource_id: dataset.id, + action: "write", + subject_type: "tribe", + subject_id: remote, + effect: "allow" + }) + + assert {:ok, %{record: %{fields: %{"title" => "Allowed"}}}} = + API.upsert_record(%{ + "dataset_id" => dataset.id, + "resource_name" => "Entry", + "remote_tribe_pubkey" => remote, + "fields" => %{"title" => "Allowed"} + }) + end + test "record validation uses resource definition fields" do run_id = "kobold-validation-#{System.unique_integer([:positive])}"