Files
self 6e050b1141
CI / Test (push) Failing after 23s
feat: provide trust facts for access policies
Expose Trust as the generic access trust provider, add admin management methods for e2e setup, and align dependency locks with the host release.\n\nKeep Trust resources buildable in plugin packaging by avoiding host-only policy checks in plugin resources.
2026-05-28 21:30:14 +02:00

302 lines
9.1 KiB
Elixir

defmodule TribeOne.TribesPlugin.Trust.Plugin do
@moduledoc """
Tribes plugin entry point.
"""
use Tribes.Plugin.Base, otp_app: :tribe_one_trust
import Plug.Conn
import Phoenix.Controller, only: [json: 2]
alias Parrhesia.API.Auth
alias Parrhesia.Web.Listener
alias TribeOne.TribesPlugin.Trust
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "Trust",
path: "/trust",
icon: nil,
requires: [],
order: 50
}
],
pages: [
%{
path: "/trust",
live_view: TribeOne.TribesPlugin.TrustWeb.HomeLive,
layout: nil
}
],
api_routes: [],
management_methods: [
%{
name: "trust.hello.receive",
version: "1",
module: __MODULE__,
action: :management_receive_hello,
auth: :admin,
description: "Record a Trust federation hello"
},
%{
name: "trust.relationships.update",
version: "1",
module: __MODULE__,
action: :management_update_relationship,
auth: :admin,
description: "Update a remote tribe trust relationship"
}
],
metrics: [],
plugs: [],
hooks: %{},
ash_domains: [TribeOne.TribesPlugin.Trust.Domain],
config_schema: %{
title: "Trust",
description: "Tribe-to-tribe federation and trust settings.",
groups: [
%{
id: "federation",
label: "Federation",
settings: [
%{
key: "federation.hidden",
label: "Hide unauthenticated federation identity",
type: :boolean,
default: false,
description:
"When enabled, /.well-known/tribes/identity returns 404 unless a signed federation call is used."
}
]
}
]
}
})
end
def access_trust_facts(subject), do: Trust.access_trust_facts(subject)
def management_receive_hello(_context, params) do
with {:ok, result} <- Trust.receive_hello(params) do
{:ok, relationship_payload(result.relationship)}
end
end
def management_update_relationship(_context, %{"remote_tribe_pubkey" => pubkey} = params) do
with {:ok, relationship} <- Trust.update_relationship(pubkey, params) do
{:ok, relationship_payload(relationship)}
end
end
def management_update_relationship(_context, _params),
do: {:error, :invalid_remote_tribe_pubkey}
def federation_identity(conn) do
cond do
Trust.federation_hidden?() ->
conn |> put_status(:not_found) |> json(%{"ok" => false, "error" => "not-found"})
true ->
case local_identity(api_url(conn)) do
{:ok, identity} ->
json(conn, identity)
{:error, :local_tribe_not_found} ->
conn |> put_status(404) |> json(%{"ok" => false, "error" => "local-tribe-not-found"})
{:error, reason} ->
conn |> put_status(503) |> json(%{"ok" => false, "error" => inspect(reason)})
end
end
end
def federation_hello(conn) do
authorization = get_req_header(conn, "authorization") |> List.first()
full_url = full_request_url(conn)
with {:ok, auth_context} <- Auth.validate_nip98(authorization, conn.method, full_url),
{:ok, attrs} <- parse_hello(conn.body_params),
:ok <- verify_sender(auth_context.pubkey, attrs),
{:ok, result} <- Trust.receive_hello(attrs) do
json(conn, %{
"ok" => true,
"relationship" => %{
"remote_tribe_pubkey" => result.relationship.remote_tribe_pubkey,
"status" => to_string(result.relationship.status),
"trust_score" => result.relationship.trust_score,
"granted_capabilities" => result.relationship.granted_capabilities || []
}
})
else
{:error, :missing_authorization} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-required"})
{:error, :invalid_authorization} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "invalid-authorization"})
{:error, :invalid_event} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "invalid-auth-event"})
{:error, :stale_event} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "stale-auth-event"})
{:error, :replayed_auth_event} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "replayed-auth-event"})
{:error, :invalid_method_tag} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-method-tag-mismatch"})
{:error, :invalid_url_tag} ->
conn |> put_status(401) |> json(%{"ok" => false, "error" => "auth-url-tag-mismatch"})
{:error, :sender_mismatch} ->
conn |> put_status(403) |> json(%{"ok" => false, "error" => "sender-mismatch"})
{:error, :invalid_payload} ->
conn |> put_status(400) |> json(%{"ok" => false, "error" => "invalid-payload"})
{:error, reason} ->
conn |> put_status(400) |> json(%{"ok" => false, "error" => inspect(reason)})
end
end
defp local_identity(api_url) when is_binary(api_url) do
with {:ok, tribe} when not is_nil(tribe) <- Tribes.Alliance.local_tribe(authorize?: false),
{:ok, relay_urls} <- Tribes.Cluster.list_public_relay_urls() do
{:ok,
%{
"protocol" => "org.tribes.federation@1",
"tribe" => %{
"pubkey" => tribe.pubkey,
"name" => tribe.name,
"description" => tribe.description,
"visibility" => to_string(tribe.visibility)
},
"api_url" => api_url,
"relay_urls" => relay_urls,
"capabilities" => Trust.federation_capabilities()
}}
else
{:ok, nil} -> {:error, :local_tribe_not_found}
{:error, _reason} = error -> error
end
end
defp parse_hello(%{"from_tribe" => from_tribe} = params) when is_binary(from_tribe) do
if is_list(Map.get(params, "relay_urls", [])) and is_list(Map.get(params, "capabilities", [])) and
is_list(Map.get(params, "requested_capabilities", [])) do
{:ok, params}
else
{:error, :invalid_payload}
end
end
defp parse_hello(_params), do: {:error, :invalid_payload}
defp verify_sender(pubkey, %{"from_tribe" => from_tribe}) when is_binary(pubkey) do
if String.downcase(pubkey) == String.downcase(from_tribe) do
:ok
else
{:error, :sender_mismatch}
end
end
defp verify_sender(_pubkey, _attrs), do: {:error, :sender_mismatch}
defp relationship_payload(relationship) do
%{
"remote_tribe_pubkey" => relationship.remote_tribe_pubkey,
"status" => to_string(relationship.status),
"trust_score" => relationship.trust_score,
"granted_capabilities" => relationship.granted_capabilities || []
}
end
defp api_url(conn) do
base_url(conn) <> "/api/tribes/v1"
end
defp full_request_url(conn) do
query_suffix = if conn.query_string == "", do: "", else: "?#{conn.query_string}"
base_url(conn) <> Listener.visible_request_path(conn) <> query_suffix
end
defp base_url(conn) do
scheme = forwarded_scheme(conn)
{host, host_port} = forwarded_host(conn)
port = forwarded_port(conn, scheme, host_port)
port_suffix =
cond do
scheme == :http and port == 80 -> ""
scheme == :https and port == 443 -> ""
true -> ":#{port}"
end
"#{scheme}://#{host}#{port_suffix}"
end
defp forwarded_scheme(conn) do
case first_forwarded_header(conn, "x-forwarded-proto") do
"https" -> :https
"http" -> :http
_other -> conn.scheme
end
end
defp forwarded_host(conn) do
case first_forwarded_header(conn, "x-forwarded-host") do
nil -> {conn.host, nil}
forwarded_host -> parse_forwarded_host(forwarded_host)
end
end
defp forwarded_port(conn, scheme, host_port) do
forwarded? =
Enum.any?(["x-forwarded-proto", "x-forwarded-host", "x-forwarded-port"], fn header ->
is_binary(first_forwarded_header(conn, header))
end)
case first_forwarded_header(conn, "x-forwarded-port") do
nil when is_integer(host_port) -> host_port
nil when forwarded? -> default_port(scheme)
nil -> conn.port
port -> parse_port(port, default_port(scheme))
end
end
defp first_forwarded_header(conn, header) do
conn
|> get_req_header(header)
|> List.first()
|> case do
nil -> nil
value -> value |> String.split(",", parts: 2) |> List.first() |> String.trim()
end
end
defp parse_forwarded_host(host) when is_binary(host) do
case URI.parse("//" <> host) do
%URI{host: parsed_host, port: port} when is_binary(parsed_host) and parsed_host != "" ->
{parsed_host, port}
_other ->
{host, nil}
end
end
defp parse_port(value, fallback) when is_binary(value) do
case Integer.parse(value) do
{port, ""} when port > 0 -> port
_other -> fallback
end
end
defp default_port(:https), do: 443
defp default_port(_scheme), do: 80
end