Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11d959d0bd | |||
| 8a4ec953b4 | |||
| 39282c8a59 |
25
LICENSE
Normal file
25
LICENSE
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
BSD 2-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2026, Steffen Beyer <steffen@beyer.io>
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
@@ -668,5 +668,3 @@ For Marmot client end-to-end checks (TypeScript/Node suite using `marmot-ts`, in
|
|||||||
```bash
|
```bash
|
||||||
just e2e marmot
|
just e2e marmot
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
|
||||||
|
|||||||
32
devenv.lock
32
devenv.lock
@@ -3,10 +3,10 @@
|
|||||||
"devenv": {
|
"devenv": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"dir": "src/modules",
|
"dir": "src/modules",
|
||||||
"lastModified": 1768736080,
|
"lastModified": 1774475276,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv",
|
"repo": "devenv",
|
||||||
"rev": "efa86311444852d24137d14964b449075522d489",
|
"rev": "f8ca2c061ec2feceee1cf1c5e52c92f58b6aec9c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -40,10 +40,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767281941,
|
"lastModified": 1774104215,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "git-hooks.nix",
|
"repo": "git-hooks.nix",
|
||||||
"rev": "f0927703b7b1c8d97511c4116eb9b4ec6645a0fa",
|
"rev": "f799ae951fde0627157f40aec28dec27b22076d0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -73,11 +73,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-src": "nixpkgs-src"
|
||||||
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1767052823,
|
"lastModified": 1774287239,
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "devenv-nixpkgs",
|
"repo": "devenv-nixpkgs",
|
||||||
"rev": "538a5124359f0b3d466e1160378c87887e3b51a4",
|
"rev": "fa7125ea7f1ae5430010a6e071f68375a39bd24c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -87,6 +90,23 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs-src": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1769922788,
|
||||||
|
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nostr-bench-src": {
|
"nostr-bench-src": {
|
||||||
"flake": false,
|
"flake": false,
|
||||||
"locked": {
|
"locked": {
|
||||||
|
|||||||
15
devenv.nix
15
devenv.nix
@@ -189,6 +189,21 @@ in {
|
|||||||
|
|
||||||
# https://devenv.sh/scripts/
|
# https://devenv.sh/scripts/
|
||||||
enterShell = ''
|
enterShell = ''
|
||||||
|
cleanup_stale_git_hook_legacy() {
|
||||||
|
hooks_dir="$(git rev-parse --git-path hooks 2>/dev/null)" || return 0
|
||||||
|
|
||||||
|
for legacy_hook in "$hooks_dir"/*.legacy; do
|
||||||
|
[ -e "$legacy_hook" ] || continue
|
||||||
|
|
||||||
|
if grep -Fq "File generated by pre-commit: https://pre-commit.com" "$legacy_hook"; then
|
||||||
|
rm -f "$legacy_hook"
|
||||||
|
echo "Removed stale legacy git hook: $legacy_hook"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_stale_git_hook_legacy
|
||||||
|
|
||||||
echo
|
echo
|
||||||
elixir --version
|
elixir --version
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
26
mix.exs
26
mix.exs
@@ -10,7 +10,9 @@ defmodule Parrhesia.MixProject do
|
|||||||
start_permanent: Mix.env() == :prod,
|
start_permanent: Mix.env() == :prod,
|
||||||
deps: deps(),
|
deps: deps(),
|
||||||
aliases: aliases(),
|
aliases: aliases(),
|
||||||
docs: docs()
|
docs: docs(),
|
||||||
|
description: description(),
|
||||||
|
package: package()
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,15 +54,17 @@ defmodule Parrhesia.MixProject do
|
|||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:telemetry_metrics_prometheus, "~> 1.1"},
|
{:telemetry_metrics_prometheus, "~> 1.1"},
|
||||||
|
|
||||||
|
# Runtime: outbound WebSocket client (sync transport)
|
||||||
|
{:websockex, "~> 0.4"},
|
||||||
|
|
||||||
# Test tooling
|
# Test tooling
|
||||||
{:stream_data, "~> 1.0", only: :test},
|
{:stream_data, "~> 1.0", only: :test},
|
||||||
{:websockex, "~> 0.4"},
|
|
||||||
|
|
||||||
# Project tooling
|
# Project tooling
|
||||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||||
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
|
{:ex_doc, "~> 0.34", only: :dev, runtime: false},
|
||||||
{:deps_changelog, "~> 0.3"},
|
{:deps_changelog, "~> 0.3", only: :dev, runtime: false},
|
||||||
{:igniter, "~> 0.6", only: [:dev, :test]}
|
{:igniter, "~> 0.6", only: [:dev, :test], runtime: false}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -82,6 +86,17 @@ defmodule Parrhesia.MixProject do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp description do
|
||||||
|
"Nostr event relay with WebSocket fanout, sync, and access control"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp package do
|
||||||
|
[
|
||||||
|
licenses: ["BSD-2-Clause"],
|
||||||
|
links: %{"Gitea" => "https://git.teralink.net/tribes/parrhesia"}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
defp docs do
|
defp docs do
|
||||||
[
|
[
|
||||||
main: "readme",
|
main: "readme",
|
||||||
@@ -91,8 +106,7 @@ defmodule Parrhesia.MixProject do
|
|||||||
"docs/LOCAL_API.md",
|
"docs/LOCAL_API.md",
|
||||||
"docs/SYNC.md",
|
"docs/SYNC.md",
|
||||||
"docs/ARCH.md",
|
"docs/ARCH.md",
|
||||||
"docs/CLUSTER.md",
|
"docs/CLUSTER.md"
|
||||||
"BENCHMARK.md"
|
|
||||||
],
|
],
|
||||||
groups_for_modules: [
|
groups_for_modules: [
|
||||||
"Embedded API": [
|
"Embedded API": [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user