You've already forked tribes-plugin-trust
6e050b1141
CI / Test (push) Failing after 23s
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.
302 lines
9.1 KiB
Elixir
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
|