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

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
use ExUnit.Case, async: false
alias Parrhesia.Storage.Adapters.Memory.ACL
alias Parrhesia.Storage.Adapters.Memory.Admin
alias Parrhesia.Storage.Adapters.Memory.Events
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, 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} =
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 Parrhesia.Repo
alias Parrhesia.Storage.Adapters.Postgres.ACL
alias Parrhesia.Storage.Adapters.Postgres.Admin
alias Parrhesia.Storage.Adapters.Postgres.Groups
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_allowed?(%{}, pubkey)
assert {:ok, false} = Moderation.has_allowed_pubkeys?(%{})
assert :ok = Moderation.allow_pubkey(%{}, pubkey)
assert {:ok, true} = Moderation.pubkey_allowed?(%{}, pubkey)
assert {:ok, true} = Moderation.has_allowed_pubkeys?(%{})
assert :ok = Moderation.disallow_pubkey(%{}, 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 = 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)
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
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, %{"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, %{})
assert {:ok, %{"methods" => methods}} = Admin.execute(%{}, :supportedmethods, %{})
assert "allow_pubkey" in methods
assert {:error, {:unsupported_method, "status"}} = Admin.execute(%{}, :status, %{})
end
end

View File

@@ -24,6 +24,7 @@ defmodule Parrhesia.Storage.BehaviourContractsTest do
:block_ip,
:disallow_pubkey,
:event_banned?,
:has_allowed_pubkeys?,
:ip_blocked?,
:pubkey_allowed?,
:pubkey_banned?,
@@ -33,6 +34,15 @@ defmodule Parrhesia.Storage.BehaviourContractsTest do
]
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
assert callback_names(Parrhesia.Storage.Groups) ==
[

View File

@@ -5,6 +5,7 @@ defmodule Parrhesia.StorageTest do
test "resolves default storage modules" do
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.groups() == Parrhesia.Storage.Adapters.Postgres.Groups
assert Storage.admin() == Parrhesia.Storage.Adapters.Postgres.Admin

View File

@@ -2,6 +2,7 @@ defmodule Parrhesia.Web.ConnectionTest do
use ExUnit.Case, async: false
alias Ecto.Adapters.SQL.Sandbox
alias Parrhesia.API.ACL
alias Parrhesia.Negentropy.Engine
alias Parrhesia.Negentropy.Message
alias Parrhesia.Protocol.EventValidator
@@ -107,6 +108,124 @@ defmodule Parrhesia.Web.ConnectionTest do
Enum.find(decoded, fn frame -> List.first(frame) == "OK" 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
state = connection_state()

View File

@@ -135,6 +135,87 @@ defmodule Parrhesia.Web.RouterTest do
}
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
now = System.system_time(:second)