Implement ACL runtime enforcement and management API
This commit is contained in:
85
test/parrhesia/api/acl_test.exs
Normal file
85
test/parrhesia/api/acl_test.exs
Normal 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
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ==
|
||||
[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user