Implement ACL runtime enforcement and management API

This commit is contained in:
2026-03-16 17:49:16 +01:00
parent 14fb0f7ffb
commit fd17026c32
26 changed files with 1487 additions and 24 deletions

250
lib/parrhesia/api/acl.ex Normal file
View File

@@ -0,0 +1,250 @@
defmodule Parrhesia.API.ACL do
@moduledoc """
Public ACL API and rule matching for protected sync traffic.
"""
alias Parrhesia.API.RequestContext
alias Parrhesia.Protocol.Filter
alias Parrhesia.Storage
@spec grant(map(), keyword()) :: :ok | {:error, term()}
def grant(rule, _opts \\ []) do
with {:ok, _stored_rule} <- Storage.acl().put_rule(%{}, normalize_rule(rule)) do
:ok
end
end
@spec revoke(map(), keyword()) :: :ok | {:error, term()}
def revoke(rule, _opts \\ []) do
Storage.acl().delete_rule(%{}, normalize_delete_selector(rule))
end
@spec list(keyword()) :: {:ok, [map()]} | {:error, term()}
def list(opts \\ []) do
Storage.acl().list_rules(%{}, normalize_list_opts(opts))
end
@spec check(atom(), map(), keyword()) :: :ok | {:error, term()}
def check(capability, subject, opts \\ [])
def check(capability, subject, opts)
when capability in [:sync_read, :sync_write] and is_map(subject) do
context = Keyword.get(opts, :context, %RequestContext{})
with {:ok, normalized_capability} <- normalize_capability(capability),
{:ok, normalized_context} <- normalize_context(context),
{:ok, protected_filters} <- protected_filters() do
if protected_subject?(normalized_capability, subject, protected_filters) do
authorize_subject(normalized_capability, subject, normalized_context)
else
:ok
end
end
end
def check(_capability, _subject, _opts), do: {:error, :invalid_acl_capability}
@spec protected_read?(map()) :: boolean()
def protected_read?(filter) when is_map(filter) do
case protected_filters() do
{:ok, protected_filters} ->
protected_subject?(:sync_read, filter, protected_filters)
{:error, _reason} ->
false
end
end
def protected_read?(_filter), do: false
@spec protected_write?(map()) :: boolean()
def protected_write?(event) when is_map(event) do
case protected_filters() do
{:ok, protected_filters} ->
protected_subject?(:sync_write, event, protected_filters)
{:error, _reason} ->
false
end
end
def protected_write?(_event), do: false
defp authorize_subject(capability, subject, %RequestContext{} = context) do
if MapSet.size(context.authenticated_pubkeys) == 0 do
{:error, :auth_required}
else
capability
|> list_rules_for_capability()
|> authorize_against_rules(capability, context.authenticated_pubkeys, subject)
end
end
defp list_rules_for_capability(capability) do
Storage.acl().list_rules(%{}, principal_type: :pubkey, capability: capability)
end
defp authorize_against_rules({:ok, rules}, capability, authenticated_pubkeys, subject) do
if Enum.any?(authenticated_pubkeys, &principal_authorized?(&1, subject, rules)) do
:ok
else
{:error, denial_reason(capability)}
end
end
defp authorize_against_rules({:error, reason}, _capability, _authenticated_pubkeys, _subject),
do: {:error, reason}
defp principal_authorized?(authenticated_pubkey, subject, rules) do
Enum.any?(rules, fn rule ->
rule.principal == authenticated_pubkey and
rule_covers_subject?(rule.capability, rule.match, subject)
end)
end
defp rule_covers_subject?(:sync_read, rule_match, filter),
do: filter_within_rule?(filter, rule_match)
defp rule_covers_subject?(:sync_write, rule_match, event),
do: Filter.matches_filter?(event, rule_match)
defp protected_subject?(:sync_read, filter, protected_filters) do
Enum.any?(protected_filters, &filters_overlap?(filter, &1))
end
defp protected_subject?(:sync_write, event, protected_filters) do
Enum.any?(protected_filters, &Filter.matches_filter?(event, &1))
end
defp filters_overlap?(left, right) when is_map(left) and is_map(right) do
comparable_keys =
left
|> comparable_filter_keys(right)
|> Enum.reject(&(&1 in ["limit", "search", "since", "until"]))
Enum.all?(
comparable_keys,
&filter_constraint_compatible?(Map.get(left, &1), Map.get(right, &1), &1)
) and
filter_ranges_overlap?(left, right)
end
defp filter_constraint_compatible?(nil, _right, _key), do: true
defp filter_constraint_compatible?(_left, nil, _key), do: true
defp filter_constraint_compatible?(left, right, _key) when is_list(left) and is_list(right) do
MapSet.disjoint?(MapSet.new(left), MapSet.new(right)) == false
end
defp filter_constraint_compatible?(left, right, _key), do: left == right
defp filter_within_rule?(filter, rule_match) when is_map(filter) and is_map(rule_match) do
Enum.reject(rule_match, fn {key, _value} -> key in ["since", "until", "limit", "search"] end)
|> Enum.all?(fn {key, rule_value} ->
requested_value = Map.get(filter, key)
requested_constraint_within_rule?(requested_value, rule_value, key)
end) and filter_range_within_rule?(filter, rule_match)
end
defp requested_constraint_within_rule?(nil, _rule_value, _key), do: false
defp requested_constraint_within_rule?(requested_values, rule_values, _key)
when is_list(requested_values) and is_list(rule_values) do
requested_values
|> MapSet.new()
|> MapSet.subset?(MapSet.new(rule_values))
end
defp requested_constraint_within_rule?(requested_value, rule_value, _key),
do: requested_value == rule_value
defp denial_reason(:sync_read), do: :sync_read_not_allowed
defp denial_reason(:sync_write), do: :sync_write_not_allowed
defp normalize_context(%RequestContext{} = context), do: {:ok, normalize_pubkeys(context)}
defp normalize_context(_context), do: {:error, :invalid_context}
defp normalize_pubkeys(%RequestContext{} = context) do
normalized_pubkeys =
context.authenticated_pubkeys
|> Enum.map(&String.downcase/1)
|> MapSet.new()
%RequestContext{context | authenticated_pubkeys: normalized_pubkeys}
end
defp normalize_rule(rule) when is_map(rule), do: rule
defp normalize_rule(_rule), do: %{}
defp normalize_delete_selector(selector) when is_map(selector), do: selector
defp normalize_delete_selector(_selector), do: %{}
defp normalize_list_opts(opts) do
[]
|> maybe_put_opt(:principal_type, Keyword.get(opts, :principal_type))
|> maybe_put_opt(:principal, normalize_list_principal(Keyword.get(opts, :principal)))
|> maybe_put_opt(:capability, Keyword.get(opts, :capability))
end
defp normalize_list_principal(nil), do: nil
defp normalize_list_principal(principal) when is_binary(principal),
do: String.downcase(principal)
defp normalize_list_principal(principal), do: principal
defp maybe_put_opt(opts, _key, nil), do: opts
defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp normalize_capability(capability) do
case capability do
:sync_read -> {:ok, :sync_read}
:sync_write -> {:ok, :sync_write}
_other -> {:error, :invalid_acl_capability}
end
end
defp protected_filters do
filters =
:parrhesia
|> Application.get_env(:acl, [])
|> Keyword.get(:protected_filters, [])
if is_list(filters) and
Enum.all?(filters, &(match?(%{}, &1) and Filter.validate_filter(&1) == :ok)) do
{:ok, filters}
else
{:error, :invalid_protected_filters}
end
end
defp comparable_filter_keys(left, right) do
Map.keys(left)
|> Kernel.++(Map.keys(right))
|> Enum.uniq()
end
defp filter_ranges_overlap?(left, right) do
since = max(boundary_value(left, "since", :lower), boundary_value(right, "since", :lower))
until = min(boundary_value(left, "until", :upper), boundary_value(right, "until", :upper))
since <= until
end
defp filter_range_within_rule?(filter, rule_match) do
requested_since = Map.get(filter, "since")
requested_until = Map.get(filter, "until")
rule_since = Map.get(rule_match, "since")
rule_until = Map.get(rule_match, "until")
lower_ok? =
is_nil(rule_since) or (is_integer(requested_since) and requested_since >= rule_since)
upper_ok? =
is_nil(rule_until) or (is_integer(requested_until) and requested_until <= rule_until)
lower_ok? and upper_ok?
end
defp boundary_value(filter, key, :lower), do: Map.get(filter, key, 0)
defp boundary_value(filter, key, :upper), do: Map.get(filter, key, 9_223_372_036_854_775_807)
end

View File

@@ -0,0 +1,84 @@
defmodule Parrhesia.API.Admin do
@moduledoc """
Public management API facade.
"""
alias Parrhesia.API.ACL
alias Parrhesia.Storage
@supported_acl_methods ~w(acl_grant acl_revoke acl_list)
@spec execute(String.t() | atom(), map(), keyword()) :: {:ok, map()} | {:error, term()}
def execute(method, params, opts \\ [])
def execute(method, params, _opts) when is_map(params) do
case normalize_method_name(method) do
"acl_grant" -> acl_grant(params)
"acl_revoke" -> acl_revoke(params)
"acl_list" -> acl_list(params)
"supportedmethods" -> {:ok, %{"methods" => supported_methods()}}
other_method -> Storage.admin().execute(%{}, other_method, params)
end
end
def execute(method, _params, _opts),
do: {:error, {:unsupported_method, normalize_method_name(method)}}
@spec stats(keyword()) :: {:ok, map()} | {:error, term()}
def stats(_opts \\ []), do: Storage.admin().execute(%{}, :stats, %{})
@spec health(keyword()) :: {:ok, map()} | {:error, term()}
def health(_opts \\ []), do: {:ok, %{"status" => "ok"}}
@spec list_audit_logs(keyword()) :: {:ok, [map()]} | {:error, term()}
def list_audit_logs(opts \\ []) do
Storage.admin().list_audit_logs(%{}, opts)
end
defp acl_grant(params) do
with :ok <- ACL.grant(params) do
{:ok, %{"ok" => true}}
end
end
defp acl_revoke(params) do
with :ok <- ACL.revoke(params) do
{:ok, %{"ok" => true}}
end
end
defp acl_list(params) do
with {:ok, rules} <- ACL.list(acl_list_opts(params)) do
{:ok, %{"rules" => rules}}
end
end
defp acl_list_opts(params) do
[]
|> maybe_put_opt(:principal_type, fetch_value(params, :principal_type))
|> maybe_put_opt(:principal, fetch_value(params, :principal))
|> maybe_put_opt(:capability, fetch_value(params, :capability))
end
defp supported_methods do
storage_supported =
case Storage.admin().execute(%{}, :supportedmethods, %{}) do
{:ok, methods} when is_list(methods) -> methods
{:ok, %{"methods" => methods}} when is_list(methods) -> methods
_other -> []
end
(storage_supported ++ @supported_acl_methods)
|> Enum.uniq()
|> Enum.sort()
end
defp maybe_put_opt(opts, _key, nil), do: opts
defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
defp fetch_value(map, key), do: Map.get(map, key) || Map.get(map, Atom.to_string(key))
defp normalize_method_name(method) when is_atom(method), do: Atom.to_string(method)
defp normalize_method_name(method) when is_binary(method), do: method
defp normalize_method_name(method), do: inspect(method)
end

View File

@@ -0,0 +1,28 @@
defmodule Parrhesia.API.RequestContext do
@moduledoc """
Shared request context used across API and policy surfaces.
"""
defstruct authenticated_pubkeys: MapSet.new(),
actor: nil,
caller: :local,
remote_ip: nil,
subscription_id: nil,
peer_id: nil,
metadata: %{}
@type t :: %__MODULE__{
authenticated_pubkeys: MapSet.t(String.t()),
actor: term(),
caller: atom(),
remote_ip: String.t() | nil,
subscription_id: String.t() | nil,
peer_id: String.t() | nil,
metadata: map()
}
@spec put_metadata(t(), map()) :: t()
def put_metadata(%__MODULE__{} = context, metadata) when is_map(metadata) do
%__MODULE__{context | metadata: Map.merge(context.metadata, metadata)}
end
end

View File

@@ -0,0 +1,68 @@
defmodule Parrhesia.Policy.ConnectionPolicy do
@moduledoc """
Connection/session-level policy checks shared by websocket and management entrypoints.
"""
alias Parrhesia.Storage
@spec authorize_remote_ip(tuple() | String.t() | nil) :: :ok | {:error, :ip_blocked}
def authorize_remote_ip(remote_ip) do
case normalize_ip(remote_ip) do
nil ->
:ok
normalized_ip ->
case Storage.moderation().ip_blocked?(%{}, normalized_ip) do
{:ok, true} -> {:error, :ip_blocked}
_other -> :ok
end
end
end
@spec authorize_authenticated_pubkey(String.t()) :: :ok | {:error, :pubkey_not_allowed}
def authorize_authenticated_pubkey(pubkey) when is_binary(pubkey) do
if allowlist_active?() do
case Storage.moderation().pubkey_allowed?(%{}, pubkey) do
{:ok, true} -> :ok
_other -> {:error, :pubkey_not_allowed}
end
else
:ok
end
end
@spec authorize_authenticated_pubkeys(MapSet.t(String.t())) ::
:ok | {:error, :auth_required | :pubkey_not_allowed}
def authorize_authenticated_pubkeys(authenticated_pubkeys) do
if allowlist_active?() do
cond do
MapSet.size(authenticated_pubkeys) == 0 ->
{:error, :auth_required}
Enum.any?(authenticated_pubkeys, &(authorize_authenticated_pubkey(&1) == :ok)) ->
:ok
true ->
{:error, :pubkey_not_allowed}
end
else
:ok
end
end
defp allowlist_active? do
case Storage.moderation().has_allowed_pubkeys?(%{}) do
{:ok, true} -> true
_other -> false
end
end
defp normalize_ip(nil), do: nil
defp normalize_ip({_, _, _, _} = remote_ip), do: :inet.ntoa(remote_ip) |> to_string()
defp normalize_ip({_, _, _, _, _, _, _, _} = remote_ip),
do: :inet.ntoa(remote_ip) |> to_string()
defp normalize_ip(remote_ip) when is_binary(remote_ip), do: remote_ip
defp normalize_ip(_remote_ip), do: nil
end

View File

@@ -3,11 +3,17 @@ defmodule Parrhesia.Policy.EventPolicy do
Write/read policy checks for relay operations. Write/read policy checks for relay operations.
""" """
alias Parrhesia.API.ACL
alias Parrhesia.API.RequestContext
alias Parrhesia.Policy.ConnectionPolicy
alias Parrhesia.Storage alias Parrhesia.Storage
@type policy_error :: @type policy_error ::
:auth_required :auth_required
| :pubkey_not_allowed
| :restricted_giftwrap | :restricted_giftwrap
| :sync_read_not_allowed
| :sync_write_not_allowed
| :marmot_group_h_tag_required | :marmot_group_h_tag_required
| :marmot_group_h_values_exceeded | :marmot_group_h_values_exceeded
| :marmot_group_filter_window_too_wide | :marmot_group_filter_window_too_wide
@@ -33,15 +39,31 @@ defmodule Parrhesia.Policy.EventPolicy do
@spec authorize_read([map()], MapSet.t(String.t())) :: :ok | {:error, policy_error()} @spec authorize_read([map()], MapSet.t(String.t())) :: :ok | {:error, policy_error()}
def authorize_read(filters, authenticated_pubkeys) when is_list(filters) do def authorize_read(filters, authenticated_pubkeys) when is_list(filters) do
authorize_read(filters, authenticated_pubkeys, request_context(authenticated_pubkeys))
end
@spec authorize_read([map()], MapSet.t(String.t()), RequestContext.t()) ::
:ok | {:error, policy_error()}
def authorize_read(filters, authenticated_pubkeys, %RequestContext{} = context)
when is_list(filters) do
auth_required? = config_bool([:policies, :auth_required_for_reads], false) auth_required? = config_bool([:policies, :auth_required_for_reads], false)
cond do cond do
match?(
{:error, _reason},
ConnectionPolicy.authorize_authenticated_pubkeys(authenticated_pubkeys)
) ->
ConnectionPolicy.authorize_authenticated_pubkeys(authenticated_pubkeys)
auth_required? and MapSet.size(authenticated_pubkeys) == 0 -> auth_required? and MapSet.size(authenticated_pubkeys) == 0 ->
{:error, :auth_required} {:error, :auth_required}
giftwrap_restricted?(filters, authenticated_pubkeys) -> giftwrap_restricted?(filters, authenticated_pubkeys) ->
{:error, :restricted_giftwrap} {:error, :restricted_giftwrap}
match?({:error, _reason}, authorize_sync_reads(filters, context)) ->
authorize_sync_reads(filters, context)
true -> true ->
enforce_marmot_group_read_guardrails(filters) enforce_marmot_group_read_guardrails(filters)
end end
@@ -49,8 +71,17 @@ defmodule Parrhesia.Policy.EventPolicy do
@spec authorize_write(map(), MapSet.t(String.t())) :: :ok | {:error, policy_error()} @spec authorize_write(map(), MapSet.t(String.t())) :: :ok | {:error, policy_error()}
def authorize_write(event, authenticated_pubkeys) when is_map(event) do def authorize_write(event, authenticated_pubkeys) when is_map(event) do
authorize_write(event, authenticated_pubkeys, request_context(authenticated_pubkeys))
end
@spec authorize_write(map(), MapSet.t(String.t()), RequestContext.t()) ::
:ok | {:error, policy_error()}
def authorize_write(event, authenticated_pubkeys, %RequestContext{} = context)
when is_map(event) do
checks = [ checks = [
fn -> ConnectionPolicy.authorize_authenticated_pubkeys(authenticated_pubkeys) end,
fn -> maybe_require_auth_for_write(authenticated_pubkeys) end, fn -> maybe_require_auth_for_write(authenticated_pubkeys) end,
fn -> authorize_sync_write(event, context) end,
fn -> reject_if_pubkey_banned(event) end, fn -> reject_if_pubkey_banned(event) end,
fn -> reject_if_event_banned(event) end, fn -> reject_if_event_banned(event) end,
fn -> enforce_pow(event) end, fn -> enforce_pow(event) end,
@@ -69,10 +100,17 @@ defmodule Parrhesia.Policy.EventPolicy do
@spec error_message(policy_error()) :: String.t() @spec error_message(policy_error()) :: String.t()
def error_message(:auth_required), do: "auth-required: authentication required" def error_message(:auth_required), do: "auth-required: authentication required"
def error_message(:pubkey_not_allowed), do: "restricted: authenticated pubkey is not allowed"
def error_message(:restricted_giftwrap), def error_message(:restricted_giftwrap),
do: "restricted: giftwrap access requires recipient authentication" do: "restricted: giftwrap access requires recipient authentication"
def error_message(:sync_read_not_allowed),
do: "restricted: sync read not allowed for authenticated pubkey"
def error_message(:sync_write_not_allowed),
do: "restricted: sync write not allowed for authenticated pubkey"
def error_message(:marmot_group_h_tag_required), def error_message(:marmot_group_h_tag_required),
do: "restricted: kind 445 queries must include a #h tag" do: "restricted: kind 445 queries must include a #h tag"
@@ -143,6 +181,19 @@ defmodule Parrhesia.Policy.EventPolicy do
end end
end end
defp authorize_sync_reads(filters, %RequestContext{} = context) do
Enum.reduce_while(filters, :ok, fn filter, :ok ->
case ACL.check(:sync_read, filter, context: context) do
:ok -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp authorize_sync_write(event, %RequestContext{} = context) do
ACL.check(:sync_write, event, context: context)
end
defp giftwrap_restricted?(filters, authenticated_pubkeys) do defp giftwrap_restricted?(filters, authenticated_pubkeys) do
if MapSet.size(authenticated_pubkeys) == 0 do if MapSet.size(authenticated_pubkeys) == 0 do
any_filter_targets_giftwrap?(filters) any_filter_targets_giftwrap?(filters)
@@ -672,4 +723,8 @@ defmodule Parrhesia.Policy.EventPolicy do
default default
end end
end end
defp request_context(authenticated_pubkeys) do
%RequestContext{authenticated_pubkeys: authenticated_pubkeys}
end
end end

View File

@@ -8,6 +8,7 @@ defmodule Parrhesia.Storage do
@default_modules [ @default_modules [
events: Parrhesia.Storage.Adapters.Postgres.Events, events: Parrhesia.Storage.Adapters.Postgres.Events,
acl: Parrhesia.Storage.Adapters.Postgres.ACL,
moderation: Parrhesia.Storage.Adapters.Postgres.Moderation, moderation: Parrhesia.Storage.Adapters.Postgres.Moderation,
groups: Parrhesia.Storage.Adapters.Postgres.Groups, groups: Parrhesia.Storage.Adapters.Postgres.Groups,
admin: Parrhesia.Storage.Adapters.Postgres.Admin admin: Parrhesia.Storage.Adapters.Postgres.Admin
@@ -19,6 +20,9 @@ defmodule Parrhesia.Storage do
@spec moderation() :: module() @spec moderation() :: module()
def moderation, do: fetch_module!(:moderation, Parrhesia.Storage.Moderation) def moderation, do: fetch_module!(:moderation, Parrhesia.Storage.Moderation)
@spec acl() :: module()
def acl, do: fetch_module!(:acl, Parrhesia.Storage.ACL)
@spec groups() :: module() @spec groups() :: module()
def groups, do: fetch_module!(:groups, Parrhesia.Storage.Groups) def groups, do: fetch_module!(:groups, Parrhesia.Storage.Groups)

View File

@@ -0,0 +1,14 @@
defmodule Parrhesia.Storage.ACL do
@moduledoc """
Storage callbacks for persisted ACL rules.
"""
@type context :: map()
@type rule :: map()
@type opts :: keyword()
@type reason :: term()
@callback put_rule(context(), rule()) :: {:ok, rule()} | {:error, reason()}
@callback delete_rule(context(), map()) :: :ok | {:error, reason()}
@callback list_rules(context(), opts()) :: {:ok, [rule()]} | {:error, reason()}
end

View File

@@ -0,0 +1,157 @@
defmodule Parrhesia.Storage.Adapters.Memory.ACL do
@moduledoc """
In-memory prototype adapter for `Parrhesia.Storage.ACL`.
"""
alias Parrhesia.Storage.Adapters.Memory.Store
@behaviour Parrhesia.Storage.ACL
@impl true
def put_rule(_context, rule) when is_map(rule) do
with {:ok, normalized_rule} <- normalize_rule(rule) do
Store.get_and_update(fn state -> put_rule_in_state(state, normalized_rule) end)
end
end
def put_rule(_context, _rule), do: {:error, :invalid_acl_rule}
@impl true
def delete_rule(_context, selector) when is_map(selector) do
case normalize_delete_selector(selector) do
{:ok, {:id, id}} ->
Store.update(fn state ->
%{state | acl_rules: Enum.reject(state.acl_rules, &(&1.id == id))}
end)
:ok
{:ok, {:exact, rule}} ->
Store.update(fn state ->
%{state | acl_rules: Enum.reject(state.acl_rules, &same_rule?(&1, rule))}
end)
:ok
{:error, reason} ->
{:error, reason}
end
end
def delete_rule(_context, _selector), do: {:error, :invalid_acl_rule}
@impl true
def list_rules(_context, opts) when is_list(opts) do
rules =
Store.get(fn state -> Enum.reverse(state.acl_rules) end)
|> Enum.filter(fn rule ->
matches_principal_type?(rule, Keyword.get(opts, :principal_type)) and
matches_principal?(rule, Keyword.get(opts, :principal)) and
matches_capability?(rule, Keyword.get(opts, :capability))
end)
{:ok, rules}
end
def list_rules(_context, _opts), do: {:error, :invalid_opts}
defp put_rule_in_state(state, normalized_rule) do
case Enum.find(state.acl_rules, &same_rule?(&1, normalized_rule)) do
nil ->
next_id = state.next_acl_rule_id
persisted_rule = Map.put(normalized_rule, :id, next_id)
{{:ok, persisted_rule},
%{
state
| acl_rules: [persisted_rule | state.acl_rules],
next_acl_rule_id: next_id + 1
}}
existing_rule ->
{{:ok, existing_rule}, state}
end
end
defp matches_principal_type?(_rule, nil), do: true
defp matches_principal_type?(rule, principal_type), do: rule.principal_type == principal_type
defp matches_principal?(_rule, nil), do: true
defp matches_principal?(rule, principal), do: rule.principal == principal
defp matches_capability?(_rule, nil), do: true
defp matches_capability?(rule, capability), do: rule.capability == capability
defp same_rule?(left, right) do
left.principal_type == right.principal_type and
left.principal == right.principal and
left.capability == right.capability and
left.match == right.match
end
defp normalize_delete_selector(%{"id" => id}), do: normalize_delete_selector(%{id: id})
defp normalize_delete_selector(%{id: id}) when is_integer(id) and id > 0,
do: {:ok, {:id, id}}
defp normalize_delete_selector(selector) do
case normalize_rule(selector) do
{:ok, rule} -> {:ok, {:exact, rule}}
{:error, reason} -> {:error, reason}
end
end
defp normalize_rule(rule) when is_map(rule) do
with {:ok, principal_type} <- normalize_principal_type(fetch(rule, :principal_type)),
{:ok, principal} <- normalize_principal(fetch(rule, :principal)),
{:ok, capability} <- normalize_capability(fetch(rule, :capability)),
{:ok, match} <- normalize_match(fetch(rule, :match)) do
{:ok,
%{
principal_type: principal_type,
principal: principal,
capability: capability,
match: match
}}
end
end
defp normalize_rule(_rule), do: {:error, :invalid_acl_rule}
defp normalize_principal_type(:pubkey), do: {:ok, :pubkey}
defp normalize_principal_type("pubkey"), do: {:ok, :pubkey}
defp normalize_principal_type(_value), do: {:error, :invalid_acl_principal_type}
defp normalize_principal(value) when is_binary(value) and byte_size(value) == 64,
do: {:ok, String.downcase(value)}
defp normalize_principal(_value), do: {:error, :invalid_acl_principal}
defp normalize_capability(:sync_read), do: {:ok, :sync_read}
defp normalize_capability(:sync_write), do: {:ok, :sync_write}
defp normalize_capability("sync_read"), do: {:ok, :sync_read}
defp normalize_capability("sync_write"), do: {:ok, :sync_write}
defp normalize_capability(_value), do: {:error, :invalid_acl_capability}
defp normalize_match(match) when is_map(match) do
normalized_match =
Enum.reduce(match, %{}, fn
{key, values}, acc when is_binary(key) ->
Map.put(acc, key, values)
{key, values}, acc when is_atom(key) ->
Map.put(acc, Atom.to_string(key), values)
_entry, acc ->
acc
end)
{:ok, normalized_match}
end
defp normalize_match(_match), do: {:error, :invalid_acl_match}
defp fetch(map, key) do
Map.get(map, key) || Map.get(map, Atom.to_string(key))
end
end

View File

@@ -33,6 +33,11 @@ defmodule Parrhesia.Storage.Adapters.Memory.Moderation do
{:ok, Store.get(fn state -> MapSet.member?(state.allowed_pubkeys, pubkey) end)} {:ok, Store.get(fn state -> MapSet.member?(state.allowed_pubkeys, pubkey) end)}
end end
@impl true
def has_allowed_pubkeys?(_context) do
{:ok, Store.get(fn state -> MapSet.size(state.allowed_pubkeys) > 0 end)}
end
@impl true @impl true
def ban_event(_context, event_id), do: update_ban_set(:events, event_id, :add) def ban_event(_context, event_id), do: update_ban_set(:events, event_id, :add)

View File

@@ -10,6 +10,8 @@ defmodule Parrhesia.Storage.Adapters.Memory.Store do
deleted: MapSet.new(), deleted: MapSet.new(),
bans: %{pubkeys: MapSet.new(), events: MapSet.new(), ips: MapSet.new()}, bans: %{pubkeys: MapSet.new(), events: MapSet.new(), ips: MapSet.new()},
allowed_pubkeys: MapSet.new(), allowed_pubkeys: MapSet.new(),
acl_rules: [],
next_acl_rule_id: 1,
groups: %{}, groups: %{},
roles: %{}, roles: %{},
audit_logs: [] audit_logs: []

View File

@@ -0,0 +1,273 @@
defmodule Parrhesia.Storage.Adapters.Postgres.ACL do
@moduledoc """
PostgreSQL-backed implementation for `Parrhesia.Storage.ACL`.
"""
import Ecto.Query
alias Parrhesia.Repo
@behaviour Parrhesia.Storage.ACL
@impl true
def put_rule(_context, rule) when is_map(rule) do
with {:ok, normalized_rule} <- normalize_rule(rule) do
normalized_rule
|> find_matching_rule()
|> maybe_insert_rule(normalized_rule)
end
end
def put_rule(_context, _rule), do: {:error, :invalid_acl_rule}
defp maybe_insert_rule(nil, normalized_rule), do: insert_rule(normalized_rule)
defp maybe_insert_rule(existing_rule, _normalized_rule), do: {:ok, existing_rule}
@impl true
def delete_rule(_context, selector) when is_map(selector) do
case normalize_delete_selector(selector) do
{:ok, {:id, id}} ->
query = from(rule in "acl_rules", where: rule.id == ^id)
{_deleted, _result} = Repo.delete_all(query)
:ok
{:ok, {:exact, rule}} ->
query =
from(stored_rule in "acl_rules",
where:
stored_rule.principal_type == ^rule.principal_type and
stored_rule.principal == ^rule.principal and
stored_rule.capability == ^rule.capability and
stored_rule.match == ^rule.match
)
{_deleted, _result} = Repo.delete_all(query)
:ok
{:error, reason} ->
{:error, reason}
end
end
def delete_rule(_context, _selector), do: {:error, :invalid_acl_rule}
@impl true
def list_rules(_context, opts) when is_list(opts) do
query =
from(rule in "acl_rules",
order_by: [
asc: rule.principal_type,
asc: rule.principal,
asc: rule.capability,
asc: rule.id
],
select: %{
id: rule.id,
principal_type: rule.principal_type,
principal: rule.principal,
capability: rule.capability,
match: rule.match,
inserted_at: rule.inserted_at
}
)
|> maybe_filter_principal_type(Keyword.get(opts, :principal_type))
|> maybe_filter_principal(Keyword.get(opts, :principal))
|> maybe_filter_capability(Keyword.get(opts, :capability))
{:ok, Enum.map(Repo.all(query), &normalize_persisted_rule/1)}
end
def list_rules(_context, _opts), do: {:error, :invalid_opts}
defp maybe_filter_principal_type(query, nil), do: query
defp maybe_filter_principal_type(query, principal_type) when is_atom(principal_type) do
maybe_filter_principal_type(query, Atom.to_string(principal_type))
end
defp maybe_filter_principal_type(query, principal_type) when is_binary(principal_type) do
where(query, [rule], rule.principal_type == ^principal_type)
end
defp maybe_filter_principal_type(query, _principal_type), do: query
defp maybe_filter_principal(query, nil), do: query
defp maybe_filter_principal(query, principal) when is_binary(principal) do
case decode_hex_or_binary(principal, 32, :invalid_acl_principal) do
{:ok, decoded_principal} -> where(query, [rule], rule.principal == ^decoded_principal)
{:error, _reason} -> where(query, [rule], false)
end
end
defp maybe_filter_principal(query, _principal), do: query
defp maybe_filter_capability(query, nil), do: query
defp maybe_filter_capability(query, capability) when is_atom(capability) do
maybe_filter_capability(query, Atom.to_string(capability))
end
defp maybe_filter_capability(query, capability) when is_binary(capability) do
where(query, [rule], rule.capability == ^capability)
end
defp maybe_filter_capability(query, _capability), do: query
defp find_matching_rule(normalized_rule) do
query =
from(stored_rule in "acl_rules",
where:
stored_rule.principal_type == ^normalized_rule.principal_type and
stored_rule.principal == ^normalized_rule.principal and
stored_rule.capability == ^normalized_rule.capability and
stored_rule.match == ^normalized_rule.match,
limit: 1,
select: %{
id: stored_rule.id,
principal_type: stored_rule.principal_type,
principal: stored_rule.principal,
capability: stored_rule.capability,
match: stored_rule.match,
inserted_at: stored_rule.inserted_at
}
)
case Repo.one(query) do
nil -> nil
stored_rule -> normalize_persisted_rule(stored_rule)
end
end
defp insert_rule(normalized_rule) do
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
row = %{
principal_type: normalized_rule.principal_type,
principal: normalized_rule.principal,
capability: normalized_rule.capability,
match: normalized_rule.match,
inserted_at: now
}
case Repo.insert_all("acl_rules", [row], returning: [:id, :inserted_at]) do
{1, [inserted_row]} ->
{:ok, normalize_persisted_rule(Map.merge(row, Map.new(inserted_row)))}
_other ->
{:error, :acl_rule_insert_failed}
end
end
defp normalize_persisted_rule(rule) do
%{
id: rule.id,
principal_type: normalize_principal_type(rule.principal_type),
principal: Base.encode16(rule.principal, case: :lower),
capability: normalize_capability(rule.capability),
match: normalize_match(rule.match),
inserted_at: rule.inserted_at
}
end
defp normalize_delete_selector(%{"id" => id}), do: normalize_delete_selector(%{id: id})
defp normalize_delete_selector(%{id: id}) when is_integer(id) and id > 0,
do: {:ok, {:id, id}}
defp normalize_delete_selector(selector) do
case normalize_rule(selector) do
{:ok, normalized_rule} -> {:ok, {:exact, normalized_rule}}
{:error, reason} -> {:error, reason}
end
end
defp normalize_rule(rule) when is_map(rule) do
with {:ok, principal_type} <- normalize_principal_type_value(fetch(rule, :principal_type)),
{:ok, principal} <-
decode_hex_or_binary(fetch(rule, :principal), 32, :invalid_acl_principal),
{:ok, capability} <- normalize_capability_value(fetch(rule, :capability)),
{:ok, match} <- normalize_match_value(fetch(rule, :match)) do
{:ok,
%{
principal_type: principal_type,
principal: principal,
capability: capability,
match: match
}}
end
end
defp normalize_rule(_rule), do: {:error, :invalid_acl_rule}
defp normalize_principal_type("pubkey"), do: :pubkey
defp normalize_principal_type(principal_type), do: principal_type
defp normalize_capability("sync_read"), do: :sync_read
defp normalize_capability("sync_write"), do: :sync_write
defp normalize_capability(capability), do: capability
defp normalize_principal_type_value(:pubkey), do: {:ok, "pubkey"}
defp normalize_principal_type_value("pubkey"), do: {:ok, "pubkey"}
defp normalize_principal_type_value(_principal_type), do: {:error, :invalid_acl_principal_type}
defp normalize_capability_value(:sync_read), do: {:ok, "sync_read"}
defp normalize_capability_value(:sync_write), do: {:ok, "sync_write"}
defp normalize_capability_value("sync_read"), do: {:ok, "sync_read"}
defp normalize_capability_value("sync_write"), do: {:ok, "sync_write"}
defp normalize_capability_value(_capability), do: {:error, :invalid_acl_capability}
defp normalize_match_value(match) when is_map(match) do
normalized_match =
Enum.reduce(match, %{}, fn
{key, values}, acc when is_binary(key) ->
Map.put(acc, key, values)
{key, values}, acc when is_atom(key) ->
Map.put(acc, Atom.to_string(key), values)
_entry, acc ->
acc
end)
{:ok, normalize_match(normalized_match)}
end
defp normalize_match_value(_match), do: {:error, :invalid_acl_match}
defp normalize_match(match) when is_map(match) do
Enum.reduce(match, %{}, fn
{key, values}, acc when is_binary(key) and is_list(values) ->
Map.put(acc, key, Enum.uniq(values))
{key, value}, acc when is_binary(key) ->
Map.put(acc, key, value)
_entry, acc ->
acc
end)
end
defp normalize_match(_match), do: %{}
defp fetch(map, key) do
Map.get(map, key) || Map.get(map, Atom.to_string(key))
end
defp decode_hex_or_binary(value, expected_bytes, _reason)
when is_binary(value) and byte_size(value) == expected_bytes,
do: {:ok, value}
defp decode_hex_or_binary(value, expected_bytes, reason) when is_binary(value) do
if byte_size(value) == expected_bytes * 2 do
case Base.decode16(value, case: :mixed) do
{:ok, decoded} -> {:ok, decoded}
:error -> {:error, reason}
end
else
{:error, reason}
end
end
defp decode_hex_or_binary(_value, _expected_bytes, reason), do: {:error, reason}
end

View File

@@ -20,6 +20,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Admin do
case method_name do case method_name do
"ping" -> {:ok, %{"status" => "ok"}} "ping" -> {:ok, %{"status" => "ok"}}
"stats" -> {:ok, relay_stats()} "stats" -> {:ok, relay_stats()}
"supportedmethods" -> {:ok, %{"methods" => supported_methods()}}
"list_audit_logs" -> list_audit_logs(%{}, audit_list_opts(params)) "list_audit_logs" -> list_audit_logs(%{}, audit_list_opts(params))
_other -> execute_moderation_method(moderation, method_name, params) _other -> execute_moderation_method(moderation, method_name, params)
end end
@@ -84,15 +85,36 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Admin do
defp relay_stats do defp relay_stats do
events_count = Repo.aggregate("events", :count, :id) events_count = Repo.aggregate("events", :count, :id)
banned_pubkeys = Repo.aggregate("banned_pubkeys", :count, :pubkey) banned_pubkeys = Repo.aggregate("banned_pubkeys", :count, :pubkey)
allowed_pubkeys = Repo.aggregate("allowed_pubkeys", :count, :pubkey)
blocked_ips = Repo.aggregate("blocked_ips", :count, :ip) blocked_ips = Repo.aggregate("blocked_ips", :count, :ip)
acl_rules = Repo.aggregate("acl_rules", :count, :id)
%{ %{
"events" => events_count, "events" => events_count,
"banned_pubkeys" => banned_pubkeys, "banned_pubkeys" => banned_pubkeys,
"allowed_pubkeys" => allowed_pubkeys,
"acl_rules" => acl_rules,
"blocked_ips" => blocked_ips "blocked_ips" => blocked_ips
} }
end end
defp supported_methods do
[
"allow_pubkey",
"ban_event",
"ban_pubkey",
"block_ip",
"disallow_pubkey",
"list_audit_logs",
"ping",
"stats",
"supportedmethods",
"unban_event",
"unban_pubkey",
"unblock_ip"
]
end
defp execute_moderation_method(moderation, "ban_pubkey", params), defp execute_moderation_method(moderation, "ban_pubkey", params),
do: execute_pubkey_method(fn ctx, value -> moderation.ban_pubkey(ctx, value) end, params) do: execute_pubkey_method(fn ctx, value -> moderation.ban_pubkey(ctx, value) end, params)

View File

@@ -67,6 +67,11 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Moderation do
end end
end end
@impl true
def has_allowed_pubkeys?(_context) do
{:ok, scope_populated?(:allowed_pubkeys)}
end
@impl true @impl true
def ban_event(_context, event_id) do def ban_event(_context, event_id) do
with {:ok, normalized_event_id} <- normalize_hex_or_binary(event_id, 32, :invalid_event_id), with {:ok, normalized_event_id} <- normalize_hex_or_binary(event_id, 32, :invalid_event_id),
@@ -163,6 +168,24 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Moderation do
end end
end end
defp scope_populated?(scope) do
{table, field} = cache_scope_source!(scope)
if moderation_cache_enabled?() do
case cache_table_ref() do
:undefined ->
scope_populated_db?(table, field)
cache_table ->
ensure_cache_scope_loaded(scope, cache_table)
:ets.select_count(cache_table, [{{{:member, scope, :_}, true}, [], [true]}]) > 0
end
else
scope_populated_db?(table, field)
end
end
defp ensure_cache_scope_loaded(scope, table) do defp ensure_cache_scope_loaded(scope, table) do
loaded_key = cache_loaded_key(scope) loaded_key = cache_loaded_key(scope)
@@ -246,6 +269,16 @@ defmodule Parrhesia.Storage.Adapters.Postgres.Moderation do
Repo.one(query) == 1 Repo.one(query) == 1
end end
defp scope_populated_db?(table, field) do
query =
from(record in table,
select: field(record, ^field),
limit: 1
)
not is_nil(Repo.one(query))
end
defp normalize_hex_or_binary(value, expected_bytes, _reason) defp normalize_hex_or_binary(value, expected_bytes, _reason)
when is_binary(value) and byte_size(value) == expected_bytes, when is_binary(value) and byte_size(value) == expected_bytes,
do: {:ok, value} do: {:ok, value}

View File

@@ -16,6 +16,7 @@ defmodule Parrhesia.Storage.Moderation do
@callback allow_pubkey(context(), pubkey()) :: :ok | {:error, reason()} @callback allow_pubkey(context(), pubkey()) :: :ok | {:error, reason()}
@callback disallow_pubkey(context(), pubkey()) :: :ok | {:error, reason()} @callback disallow_pubkey(context(), pubkey()) :: :ok | {:error, reason()}
@callback pubkey_allowed?(context(), pubkey()) :: {:ok, boolean()} | {:error, reason()} @callback pubkey_allowed?(context(), pubkey()) :: {:ok, boolean()} | {:error, reason()}
@callback has_allowed_pubkeys?(context()) :: {:ok, boolean()} | {:error, reason()}
@callback ban_event(context(), event_id()) :: :ok | {:error, reason()} @callback ban_event(context(), event_id()) :: :ok | {:error, reason()}
@callback unban_event(context(), event_id()) :: :ok | {:error, reason()} @callback unban_event(context(), event_id()) :: :ok | {:error, reason()}

View File

@@ -21,6 +21,9 @@ defmodule Parrhesia.TestSupport.PermissiveModeration do
@impl true @impl true
def pubkey_allowed?(_context, _pubkey), do: {:ok, true} def pubkey_allowed?(_context, _pubkey), do: {:ok, true}
@impl true
def has_allowed_pubkeys?(_context), do: {:ok, false}
@impl true @impl true
def ban_event(_context, _event_id), do: :ok def ban_event(_context, _event_id), do: :ok

View File

@@ -5,10 +5,12 @@ defmodule Parrhesia.Web.Connection do
@behaviour WebSock @behaviour WebSock
alias Parrhesia.API.RequestContext
alias Parrhesia.Auth.Challenges alias Parrhesia.Auth.Challenges
alias Parrhesia.Fanout.MultiNode alias Parrhesia.Fanout.MultiNode
alias Parrhesia.Groups.Flow alias Parrhesia.Groups.Flow
alias Parrhesia.Negentropy.Sessions alias Parrhesia.Negentropy.Sessions
alias Parrhesia.Policy.ConnectionPolicy
alias Parrhesia.Policy.EventPolicy alias Parrhesia.Policy.EventPolicy
alias Parrhesia.Protocol alias Parrhesia.Protocol
alias Parrhesia.Protocol.Filter alias Parrhesia.Protocol.Filter
@@ -49,6 +51,7 @@ defmodule Parrhesia.Web.Connection do
auth_challenges: Challenges, auth_challenges: Challenges,
auth_challenge: nil, auth_challenge: nil,
relay_url: nil, relay_url: nil,
remote_ip: nil,
negentropy_sessions: Sessions, negentropy_sessions: Sessions,
outbound_queue: :queue.new(), outbound_queue: :queue.new(),
outbound_queue_size: 0, outbound_queue_size: 0,
@@ -79,6 +82,7 @@ defmodule Parrhesia.Web.Connection do
auth_challenges: GenServer.server() | nil, auth_challenges: GenServer.server() | nil,
auth_challenge: String.t() | nil, auth_challenge: String.t() | nil,
relay_url: String.t() | nil, relay_url: String.t() | nil,
remote_ip: String.t() | nil,
negentropy_sessions: GenServer.server() | nil, negentropy_sessions: GenServer.server() | nil,
outbound_queue: :queue.queue({String.t(), map()}), outbound_queue: :queue.queue({String.t(), map()}),
outbound_queue_size: non_neg_integer(), outbound_queue_size: non_neg_integer(),
@@ -105,6 +109,7 @@ defmodule Parrhesia.Web.Connection do
auth_challenges: auth_challenges, auth_challenges: auth_challenges,
auth_challenge: maybe_issue_auth_challenge(auth_challenges), auth_challenge: maybe_issue_auth_challenge(auth_challenges),
relay_url: relay_url(opts), relay_url: relay_url(opts),
remote_ip: remote_ip(opts),
negentropy_sessions: negentropy_sessions(opts), negentropy_sessions: negentropy_sessions(opts),
max_outbound_queue: max_outbound_queue(opts), max_outbound_queue: max_outbound_queue(opts),
outbound_overflow_strategy: outbound_overflow_strategy(opts), outbound_overflow_strategy: outbound_overflow_strategy(opts),
@@ -230,7 +235,12 @@ defmodule Parrhesia.Web.Connection do
result = result =
with :ok <- validate_event_payload_size(event, next_state.max_event_bytes), with :ok <- validate_event_payload_size(event, next_state.max_event_bytes),
:ok <- Protocol.validate_event(event), :ok <- Protocol.validate_event(event),
:ok <- EventPolicy.authorize_write(event, next_state.authenticated_pubkeys), :ok <-
EventPolicy.authorize_write(
event,
next_state.authenticated_pubkeys,
request_context(next_state)
),
:ok <- maybe_process_group_event(event), :ok <- maybe_process_group_event(event),
{:ok, _result, message} <- persist_event(event) do {:ok, _result, message} <- persist_event(event) do
{:ok, message} {:ok, message}
@@ -286,7 +296,12 @@ defmodule Parrhesia.Web.Connection do
started_at = System.monotonic_time() started_at = System.monotonic_time()
with :ok <- Filter.validate_filters(filters), with :ok <- Filter.validate_filters(filters),
:ok <- EventPolicy.authorize_read(filters, state.authenticated_pubkeys), :ok <-
EventPolicy.authorize_read(
filters,
state.authenticated_pubkeys,
request_context(state, subscription_id)
),
{:ok, next_state} <- upsert_subscription(state, subscription_id, filters), {:ok, next_state} <- upsert_subscription(state, subscription_id, filters),
:ok <- maybe_upsert_index_subscription(next_state, subscription_id, filters), :ok <- maybe_upsert_index_subscription(next_state, subscription_id, filters),
{:ok, events} <- query_initial_events(filters, state.authenticated_pubkeys) do {:ok, events} <- query_initial_events(filters, state.authenticated_pubkeys) do
@@ -306,9 +321,19 @@ defmodule Parrhesia.Web.Connection do
{:error, :auth_required} -> {:error, :auth_required} ->
restricted_close(state, subscription_id, EventPolicy.error_message(:auth_required)) restricted_close(state, subscription_id, EventPolicy.error_message(:auth_required))
{:error, :pubkey_not_allowed} ->
restricted_close(state, subscription_id, EventPolicy.error_message(:pubkey_not_allowed))
{:error, :restricted_giftwrap} -> {:error, :restricted_giftwrap} ->
restricted_close(state, subscription_id, EventPolicy.error_message(:restricted_giftwrap)) restricted_close(state, subscription_id, EventPolicy.error_message(:restricted_giftwrap))
{:error, :sync_read_not_allowed} ->
restricted_close(
state,
subscription_id,
EventPolicy.error_message(:sync_read_not_allowed)
)
{:error, :marmot_group_h_tag_required} -> {:error, :marmot_group_h_tag_required} ->
restricted_close( restricted_close(
state, state,
@@ -374,7 +399,12 @@ defmodule Parrhesia.Web.Connection do
started_at = System.monotonic_time() started_at = System.monotonic_time()
with :ok <- Filter.validate_filters(filters), with :ok <- Filter.validate_filters(filters),
:ok <- EventPolicy.authorize_read(filters, state.authenticated_pubkeys), :ok <-
EventPolicy.authorize_read(
filters,
state.authenticated_pubkeys,
request_context(state, subscription_id)
),
{:ok, count} <- count_events(filters, state.authenticated_pubkeys), {:ok, count} <- count_events(filters, state.authenticated_pubkeys),
{:ok, payload} <- build_count_payload(filters, count, options) do {:ok, payload} <- build_count_payload(filters, count, options) do
Telemetry.emit( Telemetry.emit(
@@ -389,6 +419,13 @@ defmodule Parrhesia.Web.Connection do
{:error, :auth_required} -> {:error, :auth_required} ->
restricted_count_notice(state, subscription_id, EventPolicy.error_message(:auth_required)) restricted_count_notice(state, subscription_id, EventPolicy.error_message(:auth_required))
{:error, :pubkey_not_allowed} ->
restricted_count_notice(
state,
subscription_id,
EventPolicy.error_message(:pubkey_not_allowed)
)
{:error, :restricted_giftwrap} -> {:error, :restricted_giftwrap} ->
restricted_count_notice( restricted_count_notice(
state, state,
@@ -396,6 +433,13 @@ defmodule Parrhesia.Web.Connection do
EventPolicy.error_message(:restricted_giftwrap) EventPolicy.error_message(:restricted_giftwrap)
) )
{:error, :sync_read_not_allowed} ->
restricted_count_notice(
state,
subscription_id,
EventPolicy.error_message(:sync_read_not_allowed)
)
{:error, :marmot_group_h_tag_required} -> {:error, :marmot_group_h_tag_required} ->
restricted_count_notice( restricted_count_notice(
state, state,
@@ -428,7 +472,8 @@ defmodule Parrhesia.Web.Connection do
with :ok <- Protocol.validate_event(auth_event), with :ok <- Protocol.validate_event(auth_event),
:ok <- validate_auth_event(state, auth_event), :ok <- validate_auth_event(state, auth_event),
:ok <- validate_auth_challenge(state, auth_event) do :ok <- validate_auth_challenge(state, auth_event),
:ok <- authorize_authenticated_pubkey(auth_event) do
pubkey = Map.get(auth_event, "pubkey") pubkey = Map.get(auth_event, "pubkey")
next_state = next_state =
@@ -449,7 +494,12 @@ defmodule Parrhesia.Web.Connection do
defp handle_neg_open(%__MODULE__{} = state, subscription_id, filter, message) do defp handle_neg_open(%__MODULE__{} = state, subscription_id, filter, message) do
with :ok <- Filter.validate_filters([filter]), with :ok <- Filter.validate_filters([filter]),
:ok <- EventPolicy.authorize_read([filter], state.authenticated_pubkeys), :ok <-
EventPolicy.authorize_read(
[filter],
state.authenticated_pubkeys,
request_context(state, subscription_id)
),
{:ok, response_message} <- {:ok, response_message} <-
maybe_open_negentropy(state, subscription_id, filter, message) do maybe_open_negentropy(state, subscription_id, filter, message) do
response = response =
@@ -545,7 +595,9 @@ defmodule Parrhesia.Web.Connection do
defp error_message_for_ingest_failure(reason) defp error_message_for_ingest_failure(reason)
when reason in [ when reason in [
:auth_required, :auth_required,
:pubkey_not_allowed,
:restricted_giftwrap, :restricted_giftwrap,
:sync_write_not_allowed,
:protected_event_requires_auth, :protected_event_requires_auth,
:protected_event_pubkey_mismatch, :protected_event_pubkey_mismatch,
:pow_below_minimum, :pow_below_minimum,
@@ -702,7 +754,9 @@ defmodule Parrhesia.Web.Connection do
:invalid_search, :invalid_search,
:invalid_tag_filter, :invalid_tag_filter,
:auth_required, :auth_required,
:pubkey_not_allowed,
:restricted_giftwrap, :restricted_giftwrap,
:sync_read_not_allowed,
:marmot_group_h_tag_required, :marmot_group_h_tag_required,
:marmot_group_h_values_exceeded, :marmot_group_h_values_exceeded,
:marmot_group_filter_window_too_wide :marmot_group_filter_window_too_wide
@@ -829,6 +883,7 @@ defmodule Parrhesia.Web.Connection do
defp auth_error_message(:auth_event_too_old), do: "invalid: AUTH event is too old" defp auth_error_message(:auth_event_too_old), do: "invalid: AUTH event is too old"
defp auth_error_message(:challenge_mismatch), do: "invalid: AUTH challenge mismatch" defp auth_error_message(:challenge_mismatch), do: "invalid: AUTH challenge mismatch"
defp auth_error_message(:missing_challenge), do: "invalid: AUTH challenge unavailable" defp auth_error_message(:missing_challenge), do: "invalid: AUTH challenge unavailable"
defp auth_error_message(:pubkey_not_allowed), do: EventPolicy.error_message(:pubkey_not_allowed)
defp auth_error_message(reason) when is_binary(reason), do: reason defp auth_error_message(reason) when is_binary(reason), do: reason
defp auth_error_message(reason), do: "invalid: #{inspect(reason)}" defp auth_error_message(reason), do: "invalid: #{inspect(reason)}"
@@ -1422,6 +1477,23 @@ defmodule Parrhesia.Web.Connection do
|> normalize_relay_url() |> normalize_relay_url()
end end
defp remote_ip(opts) when is_list(opts) do
opts
|> Keyword.get(:remote_ip)
|> normalize_remote_ip()
end
defp remote_ip(opts) when is_map(opts) do
opts
|> Map.get(:remote_ip)
|> normalize_remote_ip()
end
defp remote_ip(_opts), do: nil
defp normalize_remote_ip(remote_ip) when is_binary(remote_ip) and remote_ip != "", do: remote_ip
defp normalize_remote_ip(_remote_ip), do: nil
defp max_frame_bytes(opts) when is_list(opts) do defp max_frame_bytes(opts) when is_list(opts) do
opts opts
|> Keyword.get(:max_frame_bytes) |> Keyword.get(:max_frame_bytes)
@@ -1542,6 +1614,21 @@ defmodule Parrhesia.Web.Connection do
|> Keyword.get(:auth_max_age_seconds, @default_auth_max_age_seconds) |> Keyword.get(:auth_max_age_seconds, @default_auth_max_age_seconds)
end end
defp request_context(%__MODULE__{} = state, subscription_id \\ nil) do
%RequestContext{
authenticated_pubkeys: state.authenticated_pubkeys,
caller: :websocket,
remote_ip: state.remote_ip,
subscription_id: subscription_id
}
end
defp authorize_authenticated_pubkey(%{"pubkey" => pubkey}) when is_binary(pubkey) do
ConnectionPolicy.authorize_authenticated_pubkey(pubkey)
end
defp authorize_authenticated_pubkey(_auth_event), do: {:error, :invalid_event}
defp maybe_allow_event_ingest( defp maybe_allow_event_ingest(
%__MODULE__{ %__MODULE__{
event_ingest_window_started_at_ms: window_started_at_ms, event_ingest_window_started_at_ms: window_started_at_ms,

View File

@@ -5,8 +5,8 @@ defmodule Parrhesia.Web.Management do
import Plug.Conn import Plug.Conn
alias Parrhesia.API.Admin
alias Parrhesia.Auth.Nip98 alias Parrhesia.Auth.Nip98
alias Parrhesia.Storage
@spec handle(Plug.Conn.t()) :: Plug.Conn.t() @spec handle(Plug.Conn.t()) :: Plug.Conn.t()
def handle(conn) do def handle(conn) do
@@ -59,11 +59,11 @@ defmodule Parrhesia.Web.Management do
defp parse_payload(_payload), do: {:error, :invalid_payload} defp parse_payload(_payload), do: {:error, :invalid_payload}
defp execute_method(payload) do defp execute_method(payload) do
Storage.admin().execute(%{}, payload.method, payload.params) Admin.execute(payload.method, payload.params)
end end
defp append_audit_log(auth_event, payload, result) do defp append_audit_log(auth_event, payload, result) do
Storage.admin().append_audit_log(%{}, %{ Parrhesia.Storage.admin().append_audit_log(%{}, %{
method: payload.method, method: payload.method,
actor_pubkey: Map.get(auth_event, "pubkey"), actor_pubkey: Map.get(auth_event, "pubkey"),
params: payload.params, params: payload.params,

View File

@@ -3,6 +3,7 @@ defmodule Parrhesia.Web.Router do
use Plug.Router use Plug.Router
alias Parrhesia.Policy.ConnectionPolicy
alias Parrhesia.Web.Management alias Parrhesia.Web.Management
alias Parrhesia.Web.Metrics alias Parrhesia.Web.Metrics
alias Parrhesia.Web.Readiness alias Parrhesia.Web.Readiness
@@ -38,25 +39,34 @@ defmodule Parrhesia.Web.Router do
end end
post "/management" do post "/management" do
Management.handle(conn) case ConnectionPolicy.authorize_remote_ip(conn.remote_ip) do
:ok -> Management.handle(conn)
{:error, :ip_blocked} -> send_resp(conn, 403, "forbidden")
end
end end
get "/relay" do get "/relay" do
if accepts_nip11?(conn) do case ConnectionPolicy.authorize_remote_ip(conn.remote_ip) do
body = JSON.encode!(RelayInfo.document()) :ok ->
if accepts_nip11?(conn) do
body = JSON.encode!(RelayInfo.document())
conn conn
|> put_resp_content_type("application/nostr+json") |> put_resp_content_type("application/nostr+json")
|> send_resp(200, body) |> send_resp(200, body)
else else
conn conn
|> WebSockAdapter.upgrade( |> WebSockAdapter.upgrade(
Parrhesia.Web.Connection, Parrhesia.Web.Connection,
%{relay_url: relay_url(conn)}, %{relay_url: relay_url(conn), remote_ip: remote_ip(conn)},
timeout: 60_000, timeout: 60_000,
max_frame_size: max_frame_bytes() max_frame_size: max_frame_bytes()
) )
|> halt() |> halt()
end
{:error, :ip_blocked} ->
send_resp(conn, 403, "forbidden")
end end
end end
@@ -90,4 +100,12 @@ defmodule Parrhesia.Web.Router do
defp max_frame_bytes do defp max_frame_bytes do
Parrhesia.Config.get([:limits, :max_frame_bytes], 1_048_576) Parrhesia.Config.get([:limits, :max_frame_bytes], 1_048_576)
end end
defp remote_ip(conn) do
case conn.remote_ip do
{_, _, _, _} = remote_ip -> :inet.ntoa(remote_ip) |> to_string()
{_, _, _, _, _, _, _, _} = remote_ip -> :inet.ntoa(remote_ip) |> to_string()
_other -> nil
end
end
end end

View File

@@ -0,0 +1,15 @@
defmodule Parrhesia.Repo.Migrations.AddAclRules do
use Ecto.Migration
def change do
create table(:acl_rules) do
add(:principal_type, :string, null: false)
add(:principal, :binary, null: false)
add(:capability, :string, null: false)
add(:match, :map, null: false, default: %{})
timestamps(updated_at: false, type: :utc_datetime_usec)
end
create(index(:acl_rules, [:principal_type, :principal, :capability]))
end
end

View File

@@ -0,0 +1,85 @@
defmodule Parrhesia.API.ACLTest do
use ExUnit.Case, async: false
alias Ecto.Adapters.SQL.Sandbox
alias Parrhesia.API.ACL
alias Parrhesia.API.RequestContext
alias Parrhesia.Repo
setup do
:ok = Sandbox.checkout(Repo)
previous_acl = Application.get_env(:parrhesia, :acl, [])
Application.put_env(
:parrhesia,
:acl,
protected_filters: [%{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}]
)
on_exit(fn ->
Application.put_env(:parrhesia, :acl, previous_acl)
end)
:ok
end
test "grant/list/revoke round-trips rules" do
rule = %{
principal_type: :pubkey,
principal: String.duplicate("a", 64),
capability: :sync_read,
match: %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
}
assert :ok = ACL.grant(rule)
assert {:ok, [stored_rule]} = ACL.list(principal: rule.principal, capability: :sync_read)
assert stored_rule.match == rule.match
assert :ok = ACL.revoke(%{id: stored_rule.id})
assert {:ok, []} = ACL.list(principal: rule.principal)
end
test "check/3 requires auth and matching grant for protected sync reads" do
filter = %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
authenticated_pubkey = String.duplicate("b", 64)
assert {:error, :auth_required} =
ACL.check(:sync_read, filter, context: %RequestContext{})
assert {:error, :sync_read_not_allowed} =
ACL.check(:sync_read, filter,
context: %RequestContext{authenticated_pubkeys: MapSet.new([authenticated_pubkey])}
)
assert :ok =
ACL.grant(%{
principal_type: :pubkey,
principal: authenticated_pubkey,
capability: :sync_read,
match: filter
})
assert :ok =
ACL.check(:sync_read, filter,
context: %RequestContext{authenticated_pubkeys: MapSet.new([authenticated_pubkey])}
)
end
test "check/3 rejects broader filters than the granted rule" do
principal = String.duplicate("c", 64)
assert :ok =
ACL.grant(%{
principal_type: :pubkey,
principal: principal,
capability: :sync_read,
match: %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
})
assert {:error, :sync_read_not_allowed} =
ACL.check(:sync_read, %{"kinds" => [5000]},
context: %RequestContext{authenticated_pubkeys: MapSet.new([principal])}
)
end
end

View File

@@ -1,6 +1,7 @@
defmodule Parrhesia.Storage.Adapters.Memory.AdapterTest do defmodule Parrhesia.Storage.Adapters.Memory.AdapterTest do
use ExUnit.Case, async: false use ExUnit.Case, async: false
alias Parrhesia.Storage.Adapters.Memory.ACL
alias Parrhesia.Storage.Adapters.Memory.Admin alias Parrhesia.Storage.Adapters.Memory.Admin
alias Parrhesia.Storage.Adapters.Memory.Events alias Parrhesia.Storage.Adapters.Memory.Events
alias Parrhesia.Storage.Adapters.Memory.Groups alias Parrhesia.Storage.Adapters.Memory.Groups
@@ -27,6 +28,17 @@ defmodule Parrhesia.Storage.Adapters.Memory.AdapterTest do
assert :ok = Moderation.ban_pubkey(%{}, "pk") assert :ok = Moderation.ban_pubkey(%{}, "pk")
assert {:ok, true} = Moderation.pubkey_banned?(%{}, "pk") assert {:ok, true} = Moderation.pubkey_banned?(%{}, "pk")
assert {:ok, false} = Moderation.has_allowed_pubkeys?(%{})
assert :ok = Moderation.allow_pubkey(%{}, String.duplicate("f", 64))
assert {:ok, true} = Moderation.has_allowed_pubkeys?(%{})
assert {:ok, %{capability: :sync_read}} =
ACL.put_rule(%{}, %{
principal_type: :pubkey,
principal: String.duplicate("f", 64),
capability: :sync_read,
match: %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
})
assert {:ok, membership} = assert {:ok, membership} =
Groups.put_membership(%{}, %{group_id: "g1", pubkey: "pk", role: "member"}) Groups.put_membership(%{}, %{group_id: "g1", pubkey: "pk", role: "member"})

View File

@@ -3,6 +3,7 @@ defmodule Parrhesia.Storage.Adapters.Postgres.AdapterContractTest do
alias Ecto.Adapters.SQL.Sandbox alias Ecto.Adapters.SQL.Sandbox
alias Parrhesia.Repo alias Parrhesia.Repo
alias Parrhesia.Storage.Adapters.Postgres.ACL
alias Parrhesia.Storage.Adapters.Postgres.Admin alias Parrhesia.Storage.Adapters.Postgres.Admin
alias Parrhesia.Storage.Adapters.Postgres.Groups alias Parrhesia.Storage.Adapters.Postgres.Groups
alias Parrhesia.Storage.Adapters.Postgres.Moderation alias Parrhesia.Storage.Adapters.Postgres.Moderation
@@ -32,10 +33,13 @@ defmodule Parrhesia.Storage.Adapters.Postgres.AdapterContractTest do
assert {:ok, false} = Moderation.pubkey_banned?(%{}, pubkey) assert {:ok, false} = Moderation.pubkey_banned?(%{}, pubkey)
assert {:ok, false} = Moderation.pubkey_allowed?(%{}, pubkey) assert {:ok, false} = Moderation.pubkey_allowed?(%{}, pubkey)
assert {:ok, false} = Moderation.has_allowed_pubkeys?(%{})
assert :ok = Moderation.allow_pubkey(%{}, pubkey) assert :ok = Moderation.allow_pubkey(%{}, pubkey)
assert {:ok, true} = Moderation.pubkey_allowed?(%{}, pubkey) assert {:ok, true} = Moderation.pubkey_allowed?(%{}, pubkey)
assert {:ok, true} = Moderation.has_allowed_pubkeys?(%{})
assert :ok = Moderation.disallow_pubkey(%{}, pubkey) assert :ok = Moderation.disallow_pubkey(%{}, pubkey)
assert {:ok, false} = Moderation.pubkey_allowed?(%{}, pubkey) assert {:ok, false} = Moderation.pubkey_allowed?(%{}, pubkey)
assert {:ok, false} = Moderation.has_allowed_pubkeys?(%{})
assert {:ok, false} = Moderation.event_banned?(%{}, event_id) assert {:ok, false} = Moderation.event_banned?(%{}, event_id)
assert :ok = Moderation.ban_event(%{}, event_id) assert :ok = Moderation.ban_event(%{}, event_id)
@@ -102,6 +106,28 @@ defmodule Parrhesia.Storage.Adapters.Postgres.AdapterContractTest do
assert {:ok, nil} = Groups.get_membership(%{}, group_id, member_pubkey) assert {:ok, nil} = Groups.get_membership(%{}, group_id, member_pubkey)
end end
test "acl adapter upserts, lists, and deletes rules" do
principal = String.duplicate("f", 64)
rule = %{
principal_type: :pubkey,
principal: principal,
capability: :sync_read,
match: %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
}
assert {:ok, stored_rule} = ACL.put_rule(%{}, rule)
assert stored_rule.principal == principal
assert {:ok, [listed_rule]} =
ACL.list_rules(%{}, principal_type: :pubkey, capability: :sync_read)
assert listed_rule.id == stored_rule.id
assert :ok = ACL.delete_rule(%{}, %{id: stored_rule.id})
assert {:ok, []} = ACL.list_rules(%{}, principal: principal)
end
test "admin adapter appends and filters audit logs" do test "admin adapter appends and filters audit logs" do
actor_pubkey = String.duplicate("d", 64) actor_pubkey = String.duplicate("d", 64)
@@ -130,9 +156,19 @@ defmodule Parrhesia.Storage.Adapters.Postgres.AdapterContractTest do
assert {:ok, %{"status" => "ok"}} = Admin.execute(%{}, :ping, %{}) assert {:ok, %{"status" => "ok"}} = Admin.execute(%{}, :ping, %{})
assert {:ok, %{"events" => _events, "banned_pubkeys" => _banned, "blocked_ips" => _ips}} = assert {:ok,
%{
"events" => _events,
"banned_pubkeys" => _banned,
"allowed_pubkeys" => _allowed,
"acl_rules" => _acl_rules,
"blocked_ips" => _ips
}} =
Admin.execute(%{}, :stats, %{}) Admin.execute(%{}, :stats, %{})
assert {:ok, %{"methods" => methods}} = Admin.execute(%{}, :supportedmethods, %{})
assert "allow_pubkey" in methods
assert {:error, {:unsupported_method, "status"}} = Admin.execute(%{}, :status, %{}) assert {:error, {:unsupported_method, "status"}} = Admin.execute(%{}, :status, %{})
end end
end end

View File

@@ -24,6 +24,7 @@ defmodule Parrhesia.Storage.BehaviourContractsTest do
:block_ip, :block_ip,
:disallow_pubkey, :disallow_pubkey,
:event_banned?, :event_banned?,
:has_allowed_pubkeys?,
:ip_blocked?, :ip_blocked?,
:pubkey_allowed?, :pubkey_allowed?,
:pubkey_banned?, :pubkey_banned?,
@@ -33,6 +34,15 @@ defmodule Parrhesia.Storage.BehaviourContractsTest do
] ]
end end
test "acl behavior exposes expected callbacks" do
assert callback_names(Parrhesia.Storage.ACL) ==
[
:delete_rule,
:list_rules,
:put_rule
]
end
test "groups behavior exposes expected callbacks" do test "groups behavior exposes expected callbacks" do
assert callback_names(Parrhesia.Storage.Groups) == assert callback_names(Parrhesia.Storage.Groups) ==
[ [

View File

@@ -5,6 +5,7 @@ defmodule Parrhesia.StorageTest do
test "resolves default storage modules" do test "resolves default storage modules" do
assert Storage.events() == Parrhesia.Storage.Adapters.Postgres.Events assert Storage.events() == Parrhesia.Storage.Adapters.Postgres.Events
assert Storage.acl() == Parrhesia.Storage.Adapters.Postgres.ACL
assert Storage.moderation() == Parrhesia.Storage.Adapters.Postgres.Moderation assert Storage.moderation() == Parrhesia.Storage.Adapters.Postgres.Moderation
assert Storage.groups() == Parrhesia.Storage.Adapters.Postgres.Groups assert Storage.groups() == Parrhesia.Storage.Adapters.Postgres.Groups
assert Storage.admin() == Parrhesia.Storage.Adapters.Postgres.Admin assert Storage.admin() == Parrhesia.Storage.Adapters.Postgres.Admin

View File

@@ -2,6 +2,7 @@ defmodule Parrhesia.Web.ConnectionTest do
use ExUnit.Case, async: false use ExUnit.Case, async: false
alias Ecto.Adapters.SQL.Sandbox alias Ecto.Adapters.SQL.Sandbox
alias Parrhesia.API.ACL
alias Parrhesia.Negentropy.Engine alias Parrhesia.Negentropy.Engine
alias Parrhesia.Negentropy.Message alias Parrhesia.Negentropy.Message
alias Parrhesia.Protocol.EventValidator alias Parrhesia.Protocol.EventValidator
@@ -107,6 +108,124 @@ defmodule Parrhesia.Web.ConnectionTest do
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end) Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
end end
test "AUTH rejects pubkeys outside the allowlist" do
assert :ok = Parrhesia.Storage.moderation().allow_pubkey(%{}, String.duplicate("a", 64))
state = connection_state()
auth_event = valid_auth_event(state.auth_challenge)
payload = JSON.encode!(["AUTH", auth_event])
assert {:push, frames, _next_state} = Connection.handle_in({payload, [opcode: :text]}, state)
decoded = Enum.map(frames, fn {:text, frame} -> JSON.decode!(frame) end)
assert ["OK", _, false, "restricted: authenticated pubkey is not allowed"] =
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
end
test "protected sync REQ requires matching ACL grant" do
previous_acl = Application.get_env(:parrhesia, :acl, [])
Application.put_env(
:parrhesia,
:acl,
protected_filters: [%{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}]
)
on_exit(fn ->
Application.put_env(:parrhesia, :acl, previous_acl)
end)
state = connection_state()
auth_event = valid_auth_event(state.auth_challenge)
assert {:push, _, authed_state} =
Connection.handle_in({JSON.encode!(["AUTH", auth_event]), [opcode: :text]}, state)
req_payload =
JSON.encode!(["REQ", "sync-sub", %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}])
assert {:push, denied_frames, ^authed_state} =
Connection.handle_in({req_payload, [opcode: :text]}, authed_state)
assert Enum.map(denied_frames, fn {:text, frame} -> JSON.decode!(frame) end) == [
["AUTH", authed_state.auth_challenge],
["CLOSED", "sync-sub", "restricted: sync read not allowed for authenticated pubkey"]
]
assert :ok =
ACL.grant(%{
principal_type: :pubkey,
principal: auth_event["pubkey"],
capability: :sync_read,
match: %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
})
assert {:push, responses, granted_state} =
Connection.handle_in({req_payload, [opcode: :text]}, authed_state)
assert Map.has_key?(granted_state.subscriptions, "sync-sub")
assert List.last(Enum.map(responses, fn {:text, frame} -> JSON.decode!(frame) end)) == [
"EOSE",
"sync-sub"
]
end
test "protected sync EVENT requires matching ACL grant" do
previous_acl = Application.get_env(:parrhesia, :acl, [])
Application.put_env(
:parrhesia,
:acl,
protected_filters: [%{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}]
)
on_exit(fn ->
Application.put_env(:parrhesia, :acl, previous_acl)
end)
state = connection_state()
auth_event = valid_auth_event(state.auth_challenge)
assert {:push, _, authed_state} =
Connection.handle_in({JSON.encode!(["AUTH", auth_event]), [opcode: :text]}, state)
event =
valid_event(%{
"kind" => 5000,
"tags" => [["r", "tribes.accounts.user"]],
"content" => "sync payload"
})
payload = JSON.encode!(["EVENT", event])
assert {:push, {:text, denied_response}, denied_state} =
Connection.handle_in({payload, [opcode: :text]}, authed_state)
assert JSON.decode!(denied_response) == [
"OK",
event["id"],
false,
"restricted: sync write not allowed for authenticated pubkey"
]
assert denied_state.authenticated_pubkeys == authed_state.authenticated_pubkeys
assert :ok =
ACL.grant(%{
principal_type: :pubkey,
principal: auth_event["pubkey"],
capability: :sync_write,
match: %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
})
assert {:push, {:text, accepted_response}, _next_state} =
Connection.handle_in({payload, [opcode: :text]}, authed_state)
assert JSON.decode!(accepted_response) == ["OK", event["id"], true, "ok: event stored"]
end
test "protected event is rejected unless authenticated" do test "protected event is rejected unless authenticated" do
state = connection_state() state = connection_state()

View File

@@ -135,6 +135,87 @@ defmodule Parrhesia.Web.RouterTest do
} }
end end
test "POST /management denies blocked IPs before auth" do
assert :ok = Parrhesia.Storage.moderation().block_ip(%{}, "8.8.8.8")
conn =
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|> put_req_header("content-type", "application/json")
|> Map.put(:remote_ip, {8, 8, 8, 8})
|> Router.call([])
assert conn.status == 403
assert conn.resp_body == "forbidden"
end
test "GET /relay denies blocked IPs" do
assert :ok = Parrhesia.Storage.moderation().block_ip(%{}, "8.8.4.4")
conn =
conn(:get, "/relay")
|> put_req_header("accept", "application/nostr+json")
|> Map.put(:remote_ip, {8, 8, 4, 4})
|> Router.call([])
assert conn.status == 403
assert conn.resp_body == "forbidden"
end
test "POST /management supports ACL methods" do
management_url = "http://www.example.com/management"
auth_event = nip98_event("POST", management_url)
authorization = "Nostr " <> Base.encode64(JSON.encode!(auth_event))
grant_conn =
conn(
:post,
"/management",
JSON.encode!(%{
"method" => "acl_grant",
"params" => %{
"principal_type" => "pubkey",
"principal" => String.duplicate("c", 64),
"capability" => "sync_read",
"match" => %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
}
})
)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", authorization)
|> Router.call([])
assert grant_conn.status == 200
list_conn =
conn(
:post,
"/management",
JSON.encode!(%{
"method" => "acl_list",
"params" => %{"principal" => String.duplicate("c", 64)}
})
)
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", authorization)
|> Router.call([])
assert list_conn.status == 200
assert %{
"ok" => true,
"result" => %{
"rules" => [
%{
"principal" => principal,
"capability" => "sync_read"
}
]
}
} = JSON.decode!(list_conn.resp_body)
assert principal == String.duplicate("c", 64)
end
defp nip98_event(method, url) do defp nip98_event(method, url) do
now = System.system_time(:second) now = System.system_time(:second)