fix: bypass ACL for local callers

This commit is contained in:
2026-03-26 13:01:37 +01:00
parent a74106d665
commit 39282c8a59
3 changed files with 89 additions and 11 deletions

View File

@@ -71,6 +71,9 @@ defmodule Parrhesia.API.ACL do
`opts[:context]` defaults to an empty `Parrhesia.API.RequestContext`, which means protected `opts[:context]` defaults to an empty `Parrhesia.API.RequestContext`, which means protected
subjects will fail with `{:error, :auth_required}` until authenticated pubkeys are present. subjects will fail with `{:error, :auth_required}` until authenticated pubkeys are present.
Local callers bypass ACL enforcement entirely. ACL is intended to protect external sync traffic,
not trusted in-process calls.
""" """
@spec check(atom(), map(), keyword()) :: :ok | {:error, term()} @spec check(atom(), map(), keyword()) :: :ok | {:error, term()}
def check(capability, subject, opts \\ []) def check(capability, subject, opts \\ [])
@@ -80,13 +83,8 @@ defmodule Parrhesia.API.ACL do
context = Keyword.get(opts, :context, %RequestContext{}) context = Keyword.get(opts, :context, %RequestContext{})
with {:ok, normalized_capability} <- normalize_capability(capability), with {:ok, normalized_capability} <- normalize_capability(capability),
{:ok, normalized_context} <- normalize_context(context), {:ok, normalized_context} <- normalize_context(context) do
{:ok, protected_filters} <- protected_filters() do maybe_authorize_subject(normalized_capability, subject, normalized_context)
if protected_subject?(normalized_capability, subject, protected_filters) do
authorize_subject(normalized_capability, subject, normalized_context)
else
:ok
end
end end
end end
@@ -134,6 +132,18 @@ defmodule Parrhesia.API.ACL do
end end
end end
defp maybe_authorize_subject(_capability, _subject, %RequestContext{caller: :local}), do: :ok
defp maybe_authorize_subject(capability, subject, %RequestContext{} = context) do
with {:ok, protected_filters} <- protected_filters() do
if protected_subject?(capability, subject, protected_filters) do
authorize_subject(capability, subject, context)
else
:ok
end
end
end
defp list_rules_for_capability(capability) do defp list_rules_for_capability(capability) do
Storage.acl().list_rules(%{}, principal_type: :pubkey, capability: capability) Storage.acl().list_rules(%{}, principal_type: :pubkey, capability: capability)
end end

View File

@@ -41,11 +41,14 @@ defmodule Parrhesia.API.ACLTest do
authenticated_pubkey = String.duplicate("b", 64) authenticated_pubkey = String.duplicate("b", 64)
assert {:error, :auth_required} = assert {:error, :auth_required} =
ACL.check(:sync_read, filter, context: %RequestContext{}) ACL.check(:sync_read, filter, context: %RequestContext{caller: :websocket})
assert {:error, :sync_read_not_allowed} = assert {:error, :sync_read_not_allowed} =
ACL.check(:sync_read, filter, ACL.check(:sync_read, filter,
context: %RequestContext{authenticated_pubkeys: MapSet.new([authenticated_pubkey])} context: %RequestContext{
caller: :websocket,
authenticated_pubkeys: MapSet.new([authenticated_pubkey])
}
) )
assert :ok = assert :ok =
@@ -58,7 +61,10 @@ defmodule Parrhesia.API.ACLTest do
assert :ok = assert :ok =
ACL.check(:sync_read, filter, ACL.check(:sync_read, filter,
context: %RequestContext{authenticated_pubkeys: MapSet.new([authenticated_pubkey])} context: %RequestContext{
caller: :websocket,
authenticated_pubkeys: MapSet.new([authenticated_pubkey])
}
) )
end end
@@ -75,7 +81,38 @@ defmodule Parrhesia.API.ACLTest do
assert {:error, :sync_read_not_allowed} = assert {:error, :sync_read_not_allowed} =
ACL.check(:sync_read, %{"kinds" => [5000]}, ACL.check(:sync_read, %{"kinds" => [5000]},
context: %RequestContext{authenticated_pubkeys: MapSet.new([principal])} context: %RequestContext{
caller: :websocket,
authenticated_pubkeys: MapSet.new([principal])
}
)
end
test "check/3 bypasses protected sync ACL for local callers" do
protected_filter = %{"kinds" => [5000], "#r" => ["tribes.accounts.user"]}
assert :ok =
ACL.check(:sync_read, %{"ids" => [String.duplicate("d", 64)]},
context: %RequestContext{caller: :local}
)
assert :ok =
ACL.check(
:sync_write,
%{
"id" => String.duplicate("e", 64),
"kind" => 5000,
"tags" => [["r", "tribes.accounts.user"]]
},
context: %RequestContext{caller: :local}
)
assert {:error, :sync_read_not_allowed} =
ACL.check(:sync_read, protected_filter,
context: %RequestContext{
caller: :websocket,
authenticated_pubkeys: MapSet.new([String.duplicate("f", 64)])
}
) )
end end
end end

View File

@@ -92,6 +92,37 @@ defmodule Parrhesia.API.EventsTest do
) )
end end
test "local query can read protected sync events without ACL grants or kind scoping" 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)
protected_event =
valid_event(%{
"kind" => 5000,
"tags" => [["r", "tribes.accounts.user"]],
"content" => "protected"
})
assert {:ok, %{accepted: true}} =
Events.publish(protected_event, context: %RequestContext{caller: :local})
assert {:ok, [stored_event]} =
Events.query([%{"ids" => [protected_event["id"]]}],
context: %RequestContext{caller: :local}
)
assert stored_event["id"] == protected_event["id"]
end
defp with_sync_relay_guard(enabled?) when is_boolean(enabled?) do defp with_sync_relay_guard(enabled?) when is_boolean(enabled?) do
[{:config, previous}] = :ets.lookup(Parrhesia.Config, :config) [{:config, previous}] = :ets.lookup(Parrhesia.Config, :config)