feat: gate Kobold datasets with access policies
CI / Test (push) Failing after 24s

Make Kobold require Trust, declare editable dataset/feature permissions, and enforce generic access rules for remote dataset reads and writes.\n\nUpdate the Docker e2e harness to include Trust and verify handshake-backed public access plus explicit private read/write grants.
This commit is contained in:
2026-05-28 21:30:24 +02:00
parent 97a1852c7a
commit f7e147b8be
7 changed files with 354 additions and 14 deletions
+26 -1
View File
@@ -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 {
+95 -1
View File
@@ -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)
+35 -1
View File
@@ -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
+2 -1
View File
@@ -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": {
+106 -5
View File
@@ -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
+25 -5
View File
@@ -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 \
+65
View File
@@ -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])}"