From 7a27c44bc9814d195a71c87f1b1062520f33bb4f Mon Sep 17 00:00:00 2001 From: mleku Date: Wed, 3 Dec 2025 19:19:36 +0000 Subject: [PATCH] Enhance policy system tests and documentation. Added extensive tests for default-permissive access control, read/write follow whitelists, and privileged-only fields. Updated policy documentation with new configuration examples, access control reference, and logging details. --- .claude/settings.local.json | 4 +- CLAUDE.md | 132 +++- pkg/policy/README.md | 114 +++- pkg/policy/composition.go | 4 +- pkg/policy/default_permissive_test.go | 686 +++++++++++++++++++ pkg/policy/new_fields_test.go | 38 +- pkg/policy/policy.go | 916 ++++++++++++++++++-------- pkg/version/version | 2 +- 8 files changed, 1584 insertions(+), 312 deletions(-) create mode 100644 pkg/policy/default_permissive_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f13eaad..8f43297 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -174,7 +174,9 @@ "Bash(GOOS=js GOARCH=wasm go build:*)", "Bash(go mod graph:*)", "Bash(xxd:*)", - "Bash(CGO_ENABLED=0 go mod tidy:*)" + "Bash(CGO_ENABLED=0 go mod tidy:*)", + "WebFetch(domain:git.mleku.dev)", + "Bash(CGO_ENABLED=0 LOG_LEVEL=trace go test:*)" ], "deny": [], "ask": [] diff --git a/CLAUDE.md b/CLAUDE.md index 7f70828..6f51fbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -601,7 +601,76 @@ sudo journalctl -u orly -f - `github.com/templexxx/xhex` - SIMD hex encoding - `github.com/ebitengine/purego` - CGO-free C library loading - `go-simpler.org/env` - Environment variable configuration -- `lol.mleku.dev` - Custom logging library +- `lol.mleku.dev` - Custom logging library (see Logging section below) + +## Logging (lol.mleku.dev) + +The project uses `lol.mleku.dev` (Log Of Location), a simple logging library that prints timestamps and source code locations. + +### Log Levels (lowest to highest verbosity) +| Level | Constant | Emoji | Usage | +|-------|----------|-------|-------| +| Off | `Off` | (none) | Disables all logging | +| Fatal | `Fatal` | â˜ ī¸ | Unrecoverable errors, program exits | +| Error | `Error` | 🚨 | Errors that need attention | +| Warn | `Warn` | âš ī¸ | Warnings, non-critical issues | +| Info | `Info` | â„šī¸ | General information (default) | +| Debug | `Debug` | 🔎 | Debug information for development | +| Trace | `Trace` | đŸ‘ģ | Very detailed tracing, most verbose | + +### Environment Variable +Set log level via `LOG_LEVEL` environment variable: +```bash +export LOG_LEVEL=trace # Most verbose +export LOG_LEVEL=debug # Development debugging +export LOG_LEVEL=info # Default +export LOG_LEVEL=warn # Only warnings and errors +export LOG_LEVEL=error # Only errors +export LOG_LEVEL=off # Silent +``` + +**Note**: ORLY uses `ORLY_LOG_LEVEL` which is mapped to the underlying `LOG_LEVEL`. + +### Usage in Code +Import and use the log package: +```go +import "lol.mleku.dev/log" + +// Log methods (each has .Ln, .F, .S, .C variants) +log.T.F("trace: %s", msg) // Trace level - very detailed +log.D.F("debug: %s", msg) // Debug level +log.I.F("info: %s", msg) // Info level +log.W.F("warn: %s", msg) // Warning level +log.E.F("error: %s", msg) // Error level +log.F.F("fatal: %s", msg) // Fatal level + +// Check errors (prints if error is not nil, returns bool) +import "lol.mleku.dev/chk" +if chk.E(err) { // chk.E = Error level check + return // Error was logged +} +if chk.D(err) { // chk.D = Debug level check + // ... +} +``` + +### Log Printer Variants +Each level has these printer types: +- `.Ln(a...)` - Print items with spaces between +- `.F(format, a...)` - Printf-style formatting +- `.S(a...)` - Spew dump (detailed struct output) +- `.C(func() string)` - Lazy evaluation (only runs closure if level is enabled) +- `.Chk(error) bool` - Returns true if error is not nil, logs if so +- `.Err(format, a...) error` - Logs and returns an error + +### Output Format +``` +1764783029014485đŸ‘ģ message text /path/to/file.go:123 +``` +- Unix microsecond timestamp +- Level emoji +- Message text +- Source file:line location ## Testing Guidelines @@ -709,12 +778,71 @@ The Neo4j backend (`pkg/neo4j/`) includes Web of Trust (WoT) extensions: - **Schema Modifications**: See `pkg/neo4j/MODIFYING_SCHEMA.md` for how to update ### Policy System Enhancements +- **Default-Permissive Model**: Read and write are allowed by default unless restrictions are configured - **Write-Only Validation**: Size, age, tag validations apply ONLY to writes -- **Read-Only Filtering**: `read_allow`, `read_deny`, `privileged` apply ONLY to reads +- **Read-Only Filtering**: `read_allow`, `read_follows_whitelist`, `privileged` apply ONLY to reads +- **Separate Follows Whitelists**: `read_follows_whitelist` and `write_follows_whitelist` for fine-grained control - **Scripts**: Policy scripts execute ONLY for write operations - **Reference Documentation**: `docs/POLICY_CONFIGURATION_REFERENCE.md` provides authoritative read vs write applicability - See also: `pkg/policy/README.md` for quick reference +### Policy JSON Configuration Quick Reference + +```json +{ + "default_policy": "allow|deny", + "kind": { + "whitelist": [1, 3, 4], // Only these kinds allowed + "blacklist": [4] // These kinds denied (ignored if whitelist set) + }, + "global": { + // Rule fields applied to ALL events + "size_limit": 100000, // Max event size (bytes) + "content_limit": 50000, // Max content size (bytes) + "max_age_of_event": 86400, // Max age (seconds) + "max_age_event_in_future": 300, // Max future time (seconds) + "max_expiry_duration": "P7D", // ISO-8601 expiry limit + "must_have_tags": ["d", "t"], // Required tag keys + "protected_required": false, // Require NIP-70 "-" tag + "identifier_regex": "^[a-z0-9-]{1,64}$", // Regex for "d" tags + "tag_validation": {"t": "^[a-z0-9]+$"}, // Regex for any tag + "privileged": false, // READ-ONLY: party-involved check + "write_allow": ["pubkey_hex"], // Pubkeys allowed to write + "write_deny": ["pubkey_hex"], // Pubkeys denied from writing + "read_allow": ["pubkey_hex"], // Pubkeys allowed to read + "read_deny": ["pubkey_hex"], // Pubkeys denied from reading + "read_follows_whitelist": ["pubkey_hex"], // Pubkeys whose follows can read + "write_follows_whitelist": ["pubkey_hex"], // Pubkeys whose follows can write + "script": "/path/to/script.sh" // External validation script + }, + "rules": { + "1": { /* Same fields as global, for kind 1 */ }, + "30023": { /* Same fields as global, for kind 30023 */ } + }, + "policy_admins": ["pubkey_hex"], // Can update via kind 12345 + "owners": ["pubkey_hex"], // Full policy control + "policy_follow_whitelist_enabled": false // Enable legacy write_allow_follows +} +``` + +**Access Control Summary:** +| Restriction Field | Applies To | When Set | +|-------------------|------------|----------| +| `read_allow` | READ | Only listed pubkeys can read | +| `read_deny` | READ | Listed pubkeys denied (if no read_allow) | +| `read_follows_whitelist` | READ | Named pubkeys + their follows can read | +| `write_allow` | WRITE | Only listed pubkeys can write | +| `write_deny` | WRITE | Listed pubkeys denied (if no write_allow) | +| `write_follows_whitelist` | WRITE | Named pubkeys + their follows can write | +| `privileged` | READ | Only author + p-tag recipients can read | + +**Nil Policy Error Handling:** +- If `ORLY_POLICY_ENABLED=true` but the policy fails to load (nil policy), the relay will: + - Log a FATAL error message indicating misconfiguration + - Return an error for all `CheckPolicy` calls + - Deny all events until the configuration is fixed +- This is a safety measure - a nil policy with policy enabled indicates configuration error + ### Authentication Modes - `ORLY_AUTH_REQUIRED=true`: Require authentication for ALL requests - `ORLY_AUTH_TO_WRITE=true`: Require authentication only for writes (allow anonymous reads) diff --git a/pkg/policy/README.md b/pkg/policy/README.md index 5945815..e2bc0c2 100644 --- a/pkg/policy/README.md +++ b/pkg/policy/README.md @@ -264,8 +264,10 @@ Validates that tag values match the specified regex patterns. Only validates tag | Field | Type | Description | |-------|------|-------------| -| `write_allow_follows` | boolean | Grant read+write access to policy admin follows | -| `follows_whitelist_admins` | array | Per-rule admin pubkeys whose follows are whitelisted | +| `write_allow_follows` | boolean | **DEPRECATED.** Grant read+write access to policy admin follows | +| `follows_whitelist_admins` | array | **DEPRECATED.** Per-rule admin pubkeys whose follows are whitelisted | +| `read_follows_whitelist` | array | Pubkeys whose follows can READ events. Restricts read access when set. | +| `write_follows_whitelist` | array | Pubkeys whose follows can WRITE events. Restricts write access when set. | See [Follows-Based Whitelisting](#follows-based-whitelisting) for details. @@ -350,26 +352,47 @@ P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S ## Access Control -### Write Access Evaluation +### Default-Permissive Access Model + +The policy system uses a **default-permissive** model for both read and write access: + +- **Read**: Allowed by default unless a read restriction is configured +- **Write**: Allowed by default unless a write restriction is configured + +Restrictions become active when any of the following fields are set: + +| Access | Restrictions | +|--------|--------------| +| Read | `read_allow`, `read_follows_whitelist`, or `privileged` | +| Write | `write_allow`, `write_follows_whitelist` | + +**Important**: `privileged` ONLY applies to READ operations. + +### Write Access Evaluation (Default-Permissive) ``` -1. If write_allow is set and pubkey NOT in list → DENY -2. If write_deny is set and pubkey IN list → DENY +1. Universal constraints (size, tags, age) - must pass +2. If pubkey in write_deny → DENY 3. If write_allow_follows enabled and pubkey in admin follows → ALLOW -4. If follows_whitelist_admins set and pubkey in rule follows → ALLOW -5. Continue to other checks... +4. If write_follows_whitelist set and pubkey in follows → ALLOW +5. If write_allow set and pubkey in list → ALLOW +6. If ANY write restriction is set → DENY (not in any whitelist) +7. Otherwise → ALLOW (default-permissive) ``` -### Read Access Evaluation +### Read Access Evaluation (Default-Permissive) ``` -1. If read_allow is set and pubkey NOT in list → DENY -2. If read_deny is set and pubkey IN list → DENY -3. If privileged is true and pubkey NOT party to event → DENY -4. Continue to other checks... +1. If pubkey in read_deny → DENY +2. If read_allow_follows enabled and pubkey in admin follows → ALLOW +3. If read_follows_whitelist set and pubkey in follows → ALLOW +4. If read_allow set and pubkey in list → ALLOW +5. If privileged set and pubkey is party to event → ALLOW +6. If ANY read restriction is set → DENY (not in any whitelist) +7. Otherwise → ALLOW (default-permissive) ``` -### Privileged Events +### Privileged Events (Read-Only) When `privileged: true`, only the author and p-tag recipients can access the event: @@ -386,9 +409,37 @@ When `privileged: true`, only the author and p-tag recipients can access the eve ## Follows-Based Whitelisting -There are two mechanisms for follows-based access control: +The policy system supports whitelisting pubkeys based on follow lists (kind 3 events). There are two approaches: -### 1. Global Policy Admin Follows +### 1. Separate Read/Write Follows Whitelists (Recommended) + +Use `read_follows_whitelist` and `write_follows_whitelist` for fine-grained control: + +```json +{ + "global": { + "read_follows_whitelist": ["curator_pubkey_hex"], + "write_follows_whitelist": ["moderator_pubkey_hex"] + }, + "rules": { + "30023": { + "description": "Articles - curated reading, moderated writing", + "read_follows_whitelist": ["article_curator_hex"], + "write_follows_whitelist": ["article_moderator_hex"] + } + } +} +``` + +**How it works:** +- The pubkeys listed AND their follows (from kind 3 events) can access the events +- `read_follows_whitelist`: Restricts WHO can read (when set) +- `write_follows_whitelist`: Restricts WHO can write (when set) +- If not set, the default-permissive behavior applies + +**Important:** The relay will fail to start if the named pubkeys don't have kind 3 follow list events in the database. This ensures the follow lists are available for access control. + +### 2. Legacy: Global Policy Admin Follows (DEPRECATED) Enable whitelisting for all pubkeys followed by policy admins: @@ -406,7 +457,7 @@ Enable whitelisting for all pubkeys followed by policy admins: When `write_allow_follows` is true, pubkeys in the policy admins' kind 3 follow lists get both read AND write access. -### 2. Per-Rule Follows Whitelist +### 3. Legacy: Per-Rule Follows Whitelist (DEPRECATED) Configure specific admins per rule: @@ -423,18 +474,33 @@ Configure specific admins per rule: This allows different rules to use different admin follow lists. -### Loading Follow Lists +### Loading Follow Lists at Startup -The application must load follow lists at startup: +The application must load follow lists at startup. The new API provides separate methods: ```go -// Get all admin pubkeys that need follow lists loaded -admins := policy.GetAllFollowsWhitelistAdmins() +// Get all pubkeys that need follow lists loaded (combines read + write + legacy) +allPubkeys := policy.GetAllFollowsWhitelistPubkeys() -// For each admin, load their kind 3 event and update the whitelist -for _, adminHex := range admins { - follows := loadFollowsFromKind3(adminHex) - policy.UpdateRuleFollowsWhitelist(kind, follows) +// Or get them separately +readPubkeys := policy.GetAllReadFollowsWhitelistPubkeys() +writePubkeys := policy.GetAllWriteFollowsWhitelistPubkeys() +legacyAdmins := policy.GetAllFollowsWhitelistAdmins() + +// Load follows and update the policy +for _, pubkeyHex := range readPubkeys { + follows := loadFollowsFromKind3(pubkeyHex) + // Update read follows whitelist for specific kinds + policy.UpdateRuleReadFollowsWhitelist(kind, follows) + // Or for global rule + policy.UpdateGlobalReadFollowsWhitelist(follows) +} + +for _, pubkeyHex := range writePubkeys { + follows := loadFollowsFromKind3(pubkeyHex) + policy.UpdateRuleWriteFollowsWhitelist(kind, follows) + // Or for global rule + policy.UpdateGlobalWriteFollowsWhitelist(follows) } ``` diff --git a/pkg/policy/composition.go b/pkg/policy/composition.go index b821348..c894950 100644 --- a/pkg/policy/composition.go +++ b/pkg/policy/composition.go @@ -485,8 +485,8 @@ func (p *P) IsOwner(pubkey []byte) bool { return false } - p.policyFollowsMx.RLock() - defer p.policyFollowsMx.RUnlock() + p.followsMx.RLock() + defer p.followsMx.RUnlock() for _, owner := range p.ownersBin { if utils.FastEqual(owner, pubkey) { diff --git a/pkg/policy/default_permissive_test.go b/pkg/policy/default_permissive_test.go new file mode 100644 index 0000000..c2be38d --- /dev/null +++ b/pkg/policy/default_permissive_test.go @@ -0,0 +1,686 @@ +package policy + +import ( + "testing" + "time" + + "git.mleku.dev/mleku/nostr/encoders/event" + "git.mleku.dev/mleku/nostr/encoders/hex" + "git.mleku.dev/mleku/nostr/encoders/tag" + "git.mleku.dev/mleku/nostr/interfaces/signer/p8k" + "lol.mleku.dev/chk" +) + +// ============================================================================= +// Default-Permissive Access Control Tests +// ============================================================================= + +// TestDefaultPermissiveRead tests that read access is allowed by default +// when no read restrictions are configured. +func TestDefaultPermissiveRead(t *testing.T) { + // No read restrictions configured + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "1": { + "description": "No read restrictions" + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + authorSigner, authorPubkey := generateTestKeypair(t) + _, readerPubkey := generateTestKeypair(t) + _, randomPubkey := generateTestKeypair(t) + + ev := createTestEvent(t, authorSigner, "test content", 1) + + tests := []struct { + name string + pubkey []byte + expectAllow bool + }{ + { + name: "author can read (default permissive)", + pubkey: authorPubkey, + expectAllow: true, + }, + { + name: "reader can read (default permissive)", + pubkey: readerPubkey, + expectAllow: true, + }, + { + name: "random user can read (default permissive)", + pubkey: randomPubkey, + expectAllow: true, + }, + { + name: "nil pubkey can read (default permissive)", + pubkey: nil, + expectAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allowed, err := policy.CheckPolicy("read", ev, tt.pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed != tt.expectAllow { + t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) + } + }) + } +} + +// TestDefaultPermissiveWrite tests that write access is allowed by default +// when no write restrictions are configured. +func TestDefaultPermissiveWrite(t *testing.T) { + // No write restrictions configured + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "1": { + "description": "No write restrictions" + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + writerSigner, writerPubkey := generateTestKeypair(t) + _, randomPubkey := generateTestKeypair(t) + + tests := []struct { + name string + signer *p8k.Signer + pubkey []byte + expectAllow bool + }{ + { + name: "writer can write (default permissive)", + signer: writerSigner, + pubkey: writerPubkey, + expectAllow: true, + }, + { + name: "random user can write (default permissive)", + signer: writerSigner, + pubkey: randomPubkey, + expectAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := createTestEvent(t, tt.signer, "test content", 1) + allowed, err := policy.CheckPolicy("write", ev, tt.pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed != tt.expectAllow { + t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) + } + }) + } +} + +// TestReadFollowsWhitelist tests the read_follows_whitelist field. +func TestReadFollowsWhitelist(t *testing.T) { + _, curatorPubkey := generateTestKeypair(t) + _, followedPubkey := generateTestKeypair(t) + _, unfollowedPubkey := generateTestKeypair(t) + authorSigner, authorPubkey := generateTestKeypair(t) + + curatorHex := hex.Enc(curatorPubkey) + + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "1": { + "description": "Only curator follows can read", + "read_follows_whitelist": ["` + curatorHex + `"] + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Simulate loading curator's follows (includes followed user and curator themselves) + policy.UpdateRuleReadFollowsWhitelist(1, [][]byte{followedPubkey}) + + ev := createTestEvent(t, authorSigner, "test content", 1) + + tests := []struct { + name string + pubkey []byte + expectAllow bool + }{ + { + name: "curator can read (is in whitelist pubkeys)", + pubkey: curatorPubkey, + expectAllow: true, + }, + { + name: "followed user can read", + pubkey: followedPubkey, + expectAllow: true, + }, + { + name: "unfollowed user denied", + pubkey: unfollowedPubkey, + expectAllow: false, + }, + { + name: "author cannot read (not in follows)", + pubkey: authorPubkey, + expectAllow: false, + }, + { + name: "nil pubkey denied", + pubkey: nil, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allowed, err := policy.CheckPolicy("read", ev, tt.pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed != tt.expectAllow { + t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) + } + }) + } + + // Verify write is still default-permissive (no write restriction) + t.Run("write is still default permissive", func(t *testing.T) { + allowed, err := policy.CheckPolicy("write", ev, unfollowedPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected write to be allowed (no write restriction)") + } + }) +} + +// TestWriteFollowsWhitelist tests the write_follows_whitelist field. +func TestWriteFollowsWhitelist(t *testing.T) { + moderatorSigner, moderatorPubkey := generateTestKeypair(t) + followedSigner, followedPubkey := generateTestKeypair(t) + unfollowedSigner, unfollowedPubkey := generateTestKeypair(t) + + moderatorHex := hex.Enc(moderatorPubkey) + + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "1": { + "description": "Only moderator follows can write", + "write_follows_whitelist": ["` + moderatorHex + `"] + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Simulate loading moderator's follows + policy.UpdateRuleWriteFollowsWhitelist(1, [][]byte{followedPubkey}) + + tests := []struct { + name string + signer *p8k.Signer + pubkey []byte + expectAllow bool + }{ + { + name: "moderator can write (is in whitelist pubkeys)", + signer: moderatorSigner, + pubkey: moderatorPubkey, + expectAllow: true, + }, + { + name: "followed user can write", + signer: followedSigner, + pubkey: followedPubkey, + expectAllow: true, + }, + { + name: "unfollowed user denied", + signer: unfollowedSigner, + pubkey: unfollowedPubkey, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := createTestEvent(t, tt.signer, "test content", 1) + allowed, err := policy.CheckPolicy("write", ev, tt.pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed != tt.expectAllow { + t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) + } + }) + } + + // Verify read is still default-permissive (no read restriction) + t.Run("read is still default permissive", func(t *testing.T) { + ev := createTestEvent(t, unfollowedSigner, "test content", 1) + allowed, err := policy.CheckPolicy("read", ev, unfollowedPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected read to be allowed (no read restriction)") + } + }) +} + +// TestGlobalReadFollowsWhitelist tests read_follows_whitelist in global rule. +func TestGlobalReadFollowsWhitelist(t *testing.T) { + _, curatorPubkey := generateTestKeypair(t) + _, followedPubkey := generateTestKeypair(t) + _, unfollowedPubkey := generateTestKeypair(t) + authorSigner, _ := generateTestKeypair(t) + + curatorHex := hex.Enc(curatorPubkey) + + policyJSON := []byte(`{ + "default_policy": "deny", + "global": { + "description": "Global read follows whitelist", + "read_follows_whitelist": ["` + curatorHex + `"] + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Update global read follows whitelist + policy.UpdateGlobalReadFollowsWhitelist([][]byte{followedPubkey}) + + // Test with kind 1 + t.Run("kind 1", func(t *testing.T) { + ev := createTestEvent(t, authorSigner, "test content", 1) + + // Followed user can read + allowed, err := policy.CheckPolicy("read", ev, followedPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected followed user to be allowed to read") + } + + // Unfollowed user denied + allowed, err = policy.CheckPolicy("read", ev, unfollowedPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed { + t.Error("Expected unfollowed user to be denied") + } + }) +} + +// TestGlobalWriteFollowsWhitelist tests write_follows_whitelist in global rule. +func TestGlobalWriteFollowsWhitelist(t *testing.T) { + _, moderatorPubkey := generateTestKeypair(t) + followedSigner, followedPubkey := generateTestKeypair(t) + unfollowedSigner, unfollowedPubkey := generateTestKeypair(t) + + moderatorHex := hex.Enc(moderatorPubkey) + + policyJSON := []byte(`{ + "default_policy": "deny", + "global": { + "description": "Global write follows whitelist", + "write_follows_whitelist": ["` + moderatorHex + `"] + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Update global write follows whitelist + policy.UpdateGlobalWriteFollowsWhitelist([][]byte{followedPubkey}) + + // Test with kind 1 + t.Run("kind 1", func(t *testing.T) { + // Followed user can write + ev := createTestEvent(t, followedSigner, "test content", 1) + allowed, err := policy.CheckPolicy("write", ev, followedPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected followed user to be allowed to write") + } + + // Unfollowed user denied + ev = createTestEvent(t, unfollowedSigner, "test content", 1) + allowed, err = policy.CheckPolicy("write", ev, unfollowedPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed { + t.Error("Expected unfollowed user to be denied") + } + }) +} + +// TestPrivilegedOnlyAppliesToReadDP tests that privileged only affects read access. +func TestPrivilegedOnlyAppliesToReadDP(t *testing.T) { + authorSigner, authorPubkey := generateTestKeypair(t) + _, recipientPubkey := generateTestKeypair(t) + thirdPartySigner, thirdPartyPubkey := generateTestKeypair(t) + + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "4": { + "description": "Encrypted DMs - privileged", + "privileged": true + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Create event with p-tag for recipient + ev := event.New() + ev.Kind = 4 + ev.Content = []byte("encrypted content") + ev.CreatedAt = time.Now().Unix() + ev.Tags = tag.NewS() + pTag := tag.NewFromAny("p", hex.Enc(recipientPubkey)) + ev.Tags.Append(pTag) + if err := ev.Sign(authorSigner); chk.E(err) { + t.Fatalf("Failed to sign event: %v", err) + } + + // READ tests + t.Run("author can read", func(t *testing.T) { + allowed, err := policy.CheckPolicy("read", ev, authorPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected author to be allowed to read") + } + }) + + t.Run("recipient can read", func(t *testing.T) { + allowed, err := policy.CheckPolicy("read", ev, recipientPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected recipient to be allowed to read") + } + }) + + t.Run("third party cannot read", func(t *testing.T) { + allowed, err := policy.CheckPolicy("read", ev, thirdPartyPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed { + t.Error("Expected third party to be denied read access") + } + }) + + // WRITE tests - privileged should NOT affect write + t.Run("third party CAN write (privileged doesn't affect write)", func(t *testing.T) { + ev := createTestEvent(t, thirdPartySigner, "test content", 4) + allowed, err := policy.CheckPolicy("write", ev, thirdPartyPubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if !allowed { + t.Error("Expected third party to be allowed to write (privileged doesn't restrict write)") + } + }) +} + +// TestCombinedReadWriteFollowsWhitelists tests using both whitelists on same rule. +func TestCombinedReadWriteFollowsWhitelists(t *testing.T) { + _, curatorPubkey := generateTestKeypair(t) + _, moderatorPubkey := generateTestKeypair(t) + readerSigner, readerPubkey := generateTestKeypair(t) + writerSigner, writerPubkey := generateTestKeypair(t) + _, outsiderPubkey := generateTestKeypair(t) + + curatorHex := hex.Enc(curatorPubkey) + moderatorHex := hex.Enc(moderatorPubkey) + + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "30023": { + "description": "Articles - different read/write follows", + "read_follows_whitelist": ["` + curatorHex + `"], + "write_follows_whitelist": ["` + moderatorHex + `"] + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + // Curator follows reader, moderator follows writer + policy.UpdateRuleReadFollowsWhitelist(30023, [][]byte{readerPubkey}) + policy.UpdateRuleWriteFollowsWhitelist(30023, [][]byte{writerPubkey}) + + tests := []struct { + name string + access string + signer *p8k.Signer + pubkey []byte + expectAllow bool + }{ + // Read tests + { + name: "reader can read", + access: "read", + signer: readerSigner, + pubkey: readerPubkey, + expectAllow: true, + }, + { + name: "writer cannot read (not in read follows)", + access: "read", + signer: writerSigner, + pubkey: writerPubkey, + expectAllow: false, + }, + { + name: "outsider cannot read", + access: "read", + signer: readerSigner, + pubkey: outsiderPubkey, + expectAllow: false, + }, + // Write tests + { + name: "writer can write", + access: "write", + signer: writerSigner, + pubkey: writerPubkey, + expectAllow: true, + }, + { + name: "reader cannot write (not in write follows)", + access: "write", + signer: readerSigner, + pubkey: readerPubkey, + expectAllow: false, + }, + { + name: "outsider cannot write", + access: "write", + signer: readerSigner, + pubkey: outsiderPubkey, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := createTestEvent(t, tt.signer, "test content", 30023) + allowed, err := policy.CheckPolicy(tt.access, ev, tt.pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed != tt.expectAllow { + t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) + } + }) + } +} + +// TestReadAllowWithReadFollowsWhitelist tests combining read_allow and read_follows_whitelist. +func TestReadAllowWithReadFollowsWhitelist(t *testing.T) { + _, curatorPubkey := generateTestKeypair(t) + _, followedPubkey := generateTestKeypair(t) + _, explicitPubkey := generateTestKeypair(t) + _, outsiderPubkey := generateTestKeypair(t) + authorSigner, _ := generateTestKeypair(t) + + curatorHex := hex.Enc(curatorPubkey) + explicitHex := hex.Enc(explicitPubkey) + + policyJSON := []byte(`{ + "default_policy": "deny", + "rules": { + "1": { + "description": "Read via follows OR explicit allow", + "read_follows_whitelist": ["` + curatorHex + `"], + "read_allow": ["` + explicitHex + `"] + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + policy.UpdateRuleReadFollowsWhitelist(1, [][]byte{followedPubkey}) + + ev := createTestEvent(t, authorSigner, "test content", 1) + + tests := []struct { + name string + pubkey []byte + expectAllow bool + }{ + { + name: "followed user can read", + pubkey: followedPubkey, + expectAllow: true, + }, + { + name: "explicit allow user can read", + pubkey: explicitPubkey, + expectAllow: true, + }, + { + name: "curator can read (is whitelist pubkey)", + pubkey: curatorPubkey, + expectAllow: true, + }, + { + name: "outsider denied", + pubkey: outsiderPubkey, + expectAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allowed, err := policy.CheckPolicy("read", ev, tt.pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + if allowed != tt.expectAllow { + t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow) + } + }) + } +} + +// TestGetAllFollowsWhitelistPubkeysDP tests the combined pubkey retrieval. +func TestGetAllFollowsWhitelistPubkeysDP(t *testing.T) { + read1 := "1111111111111111111111111111111111111111111111111111111111111111" + read2 := "2222222222222222222222222222222222222222222222222222222222222222" + write1 := "3333333333333333333333333333333333333333333333333333333333333333" + legacy := "4444444444444444444444444444444444444444444444444444444444444444" + + policyJSON := []byte(`{ + "default_policy": "allow", + "global": { + "read_follows_whitelist": ["` + read1 + `"], + "write_follows_whitelist": ["` + write1 + `"] + }, + "rules": { + "1": { + "read_follows_whitelist": ["` + read2 + `"], + "follows_whitelist_admins": ["` + legacy + `"] + } + } + }`) + + policy, err := New(policyJSON) + if err != nil { + t.Fatalf("Failed to create policy: %v", err) + } + + allPubkeys := policy.GetAllFollowsWhitelistPubkeys() + if len(allPubkeys) != 4 { + t.Errorf("Expected 4 unique pubkeys, got %d", len(allPubkeys)) + } + + // Check each is present + pubkeySet := make(map[string]bool) + for _, pk := range allPubkeys { + pubkeySet[pk] = true + } + + expected := []string{read1, read2, write1, legacy} + for _, exp := range expected { + if !pubkeySet[exp] { + t.Errorf("Expected pubkey %s not found", exp) + } + } +} diff --git a/pkg/policy/new_fields_test.go b/pkg/policy/new_fields_test.go index ad5e774..a467164 100644 --- a/pkg/policy/new_fields_test.go +++ b/pkg/policy/new_fields_test.go @@ -1091,9 +1091,12 @@ func TestAllNewFieldsCombined(t *testing.T) { } // Test new fields in global rule +// Global rule is ONLY used as fallback when NO kind-specific rule exists. +// If a kind-specific rule exists (even if empty), it takes precedence and global is ignored. func TestNewFieldsInGlobalRule(t *testing.T) { signer, pubkey := generateTestKeypair(t) + // Policy with global constraints and a kind-specific rule for kind 1 policyJSON := []byte(`{ "default_policy": "allow", "global": { @@ -1102,7 +1105,7 @@ func TestNewFieldsInGlobalRule(t *testing.T) { }, "rules": { "1": { - "description": "Kind 1 events" + "description": "Kind 1 events - has specific rule, so global is ignored" } } }`) @@ -1112,7 +1115,8 @@ func TestNewFieldsInGlobalRule(t *testing.T) { t.Fatalf("Failed to create policy: %v", err) } - // Event without protected tag should fail global rule + // Kind 1 has a specific rule, so global protected_required is IGNORED + // Event should be ALLOWED even without protected tag ev := createTestEventForNewFields(t, signer, "test", 1) addTagString(ev, "expiration", int64ToString(ev.CreatedAt+3600)) if err := ev.Sign(signer); chk.E(err) { @@ -1124,23 +1128,39 @@ func TestNewFieldsInGlobalRule(t *testing.T) { t.Fatalf("CheckPolicy error: %v", err) } - if allowed { - t.Error("Global protected_required should deny event without - tag") + if !allowed { + t.Error("Kind 1 has specific rule - global protected_required should be ignored, event should be allowed") } - // Add protected tag - addTagString(ev, "-", "") - if err := ev.Sign(signer); chk.E(err) { + // Now test kind 999 which has NO specific rule - global should apply + ev2 := createTestEventForNewFields(t, signer, "test", 999) + addTagString(ev2, "expiration", int64ToString(ev2.CreatedAt+3600)) + if err := ev2.Sign(signer); chk.E(err) { t.Fatalf("Failed to sign: %v", err) } - allowed, err = policy.CheckPolicy("write", ev, pubkey, "127.0.0.1") + allowed, err = policy.CheckPolicy("write", ev2, pubkey, "127.0.0.1") + if err != nil { + t.Fatalf("CheckPolicy error: %v", err) + } + + if allowed { + t.Error("Kind 999 has NO specific rule - global protected_required should apply, event should be denied") + } + + // Add protected tag to kind 999 event - should now be allowed + addTagString(ev2, "-", "") + if err := ev2.Sign(signer); chk.E(err) { + t.Fatalf("Failed to sign: %v", err) + } + + allowed, err = policy.CheckPolicy("write", ev2, pubkey, "127.0.0.1") if err != nil { t.Fatalf("CheckPolicy error: %v", err) } if !allowed { - t.Error("Should allow event with protected tag and valid expiry") + t.Error("Kind 999 with protected tag and valid expiry should be allowed by global rule") } } diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 1f9080b..1533375 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -115,8 +115,21 @@ type Rule struct { // Unlike WriteAllowFollows which uses the global PolicyAdmins, this allows per-rule admin configuration. // If set, the relay will fail to start if these admins don't have follow list events (kind 3) in the database. // This provides explicit control over which admin's follow list controls access for specific kinds. + // DEPRECATED: Use ReadFollowsWhitelist and WriteFollowsWhitelist instead. FollowsWhitelistAdmins []string `json:"follows_whitelist_admins,omitempty"` + // ReadFollowsWhitelist specifies pubkeys (hex-encoded) whose follows are allowed to READ events. + // The relay will fail to start if these pubkeys don't have follow list events (kind 3) in the database. + // When present, only the follows of these pubkeys (plus the pubkeys themselves) can read. + // This restricts read access - without it, read is permissive by default (except for privileged events). + ReadFollowsWhitelist []string `json:"read_follows_whitelist,omitempty"` + + // WriteFollowsWhitelist specifies pubkeys (hex-encoded) whose follows are allowed to WRITE events. + // The relay will fail to start if these pubkeys don't have follow list events (kind 3) in the database. + // When present, only the follows of these pubkeys (plus the pubkeys themselves) can write. + // Without this, write permission is allowed by default. + WriteFollowsWhitelist []string `json:"write_follows_whitelist,omitempty"` + // TagValidation is a map of tag_name -> regex pattern for validating tag values. // Each tag present in the event must match its corresponding regex pattern. // Example: {"d": "^[a-z0-9-]{1,64}$", "t": "^[a-z0-9-]{1,32}$"} @@ -139,8 +152,14 @@ type Rule struct { readDenyBin [][]byte maxExpirySeconds *int64 // Parsed from MaxExpiryDuration or copied from MaxExpiry identifierRegexCache *regexp.Regexp // Compiled regex for IdentifierRegex - followsWhitelistAdminsBin [][]byte // Binary cache for FollowsWhitelistAdmins pubkeys - followsWhitelistFollowsBin [][]byte // Cached follow list from FollowsWhitelistAdmins (loaded at startup) + followsWhitelistAdminsBin [][]byte // Binary cache for FollowsWhitelistAdmins pubkeys (DEPRECATED) + followsWhitelistFollowsBin [][]byte // Cached follow list from FollowsWhitelistAdmins (loaded at startup, DEPRECATED) + + // Binary caches for ReadFollowsWhitelist and WriteFollowsWhitelist + readFollowsWhitelistBin [][]byte // Binary cache for ReadFollowsWhitelist pubkeys + writeFollowsWhitelistBin [][]byte // Binary cache for WriteFollowsWhitelist pubkeys + readFollowsFollowsBin [][]byte // Cached follow list from ReadFollowsWhitelist pubkeys + writeFollowsFollowsBin [][]byte // Cached follow list from WriteFollowsWhitelist pubkeys } // hasAnyRules checks if the rule has any constraints configured @@ -156,6 +175,8 @@ func (r *Rule) hasAnyRules() bool { len(r.MustHaveTags) > 0 || r.Script != "" || r.Privileged || r.WriteAllowFollows || len(r.FollowsWhitelistAdmins) > 0 || + len(r.ReadFollowsWhitelist) > 0 || len(r.WriteFollowsWhitelist) > 0 || + len(r.readFollowsWhitelistBin) > 0 || len(r.writeFollowsWhitelistBin) > 0 || len(r.TagValidation) > 0 || r.ProtectedRequired || r.IdentifierRegex != "" } @@ -241,7 +262,7 @@ func (r *Rule) populateBinaryCache() error { } } - // Convert FollowsWhitelistAdmins hex strings to binary + // Convert FollowsWhitelistAdmins hex strings to binary (DEPRECATED) if len(r.FollowsWhitelistAdmins) > 0 { r.followsWhitelistAdminsBin = make([][]byte, 0, len(r.FollowsWhitelistAdmins)) for _, hexPubkey := range r.FollowsWhitelistAdmins { @@ -254,6 +275,32 @@ func (r *Rule) populateBinaryCache() error { } } + // Convert ReadFollowsWhitelist hex strings to binary + if len(r.ReadFollowsWhitelist) > 0 { + r.readFollowsWhitelistBin = make([][]byte, 0, len(r.ReadFollowsWhitelist)) + for _, hexPubkey := range r.ReadFollowsWhitelist { + binPubkey, decErr := hex.Dec(hexPubkey) + if decErr != nil { + log.W.F("failed to decode ReadFollowsWhitelist pubkey %q: %v", hexPubkey, decErr) + continue + } + r.readFollowsWhitelistBin = append(r.readFollowsWhitelistBin, binPubkey) + } + } + + // Convert WriteFollowsWhitelist hex strings to binary + if len(r.WriteFollowsWhitelist) > 0 { + r.writeFollowsWhitelistBin = make([][]byte, 0, len(r.WriteFollowsWhitelist)) + for _, hexPubkey := range r.WriteFollowsWhitelist { + binPubkey, decErr := hex.Dec(hexPubkey) + if decErr != nil { + log.W.F("failed to decode WriteFollowsWhitelist pubkey %q: %v", hexPubkey, decErr) + continue + } + r.writeFollowsWhitelistBin = append(r.writeFollowsWhitelistBin, binPubkey) + } + } + return err } @@ -283,10 +330,91 @@ func (r *Rule) GetFollowsWhitelistAdminsBin() [][]byte { } // HasFollowsWhitelistAdmins returns true if this rule has FollowsWhitelistAdmins configured. +// DEPRECATED: Use HasReadFollowsWhitelist and HasWriteFollowsWhitelist instead. func (r *Rule) HasFollowsWhitelistAdmins() bool { return len(r.FollowsWhitelistAdmins) > 0 } +// HasReadFollowsWhitelist returns true if this rule has ReadFollowsWhitelist configured. +func (r *Rule) HasReadFollowsWhitelist() bool { + return len(r.ReadFollowsWhitelist) > 0 +} + +// HasWriteFollowsWhitelist returns true if this rule has WriteFollowsWhitelist configured. +func (r *Rule) HasWriteFollowsWhitelist() bool { + return len(r.WriteFollowsWhitelist) > 0 +} + +// GetReadFollowsWhitelistBin returns the binary-encoded pubkeys for ReadFollowsWhitelist. +func (r *Rule) GetReadFollowsWhitelistBin() [][]byte { + return r.readFollowsWhitelistBin +} + +// GetWriteFollowsWhitelistBin returns the binary-encoded pubkeys for WriteFollowsWhitelist. +func (r *Rule) GetWriteFollowsWhitelistBin() [][]byte { + return r.writeFollowsWhitelistBin +} + +// UpdateReadFollowsWhitelist sets the follows list for this rule's ReadFollowsWhitelist. +// The follows should be binary pubkeys ([]byte), not hex-encoded. +func (r *Rule) UpdateReadFollowsWhitelist(follows [][]byte) { + r.readFollowsFollowsBin = follows +} + +// UpdateWriteFollowsWhitelist sets the follows list for this rule's WriteFollowsWhitelist. +// The follows should be binary pubkeys ([]byte), not hex-encoded. +func (r *Rule) UpdateWriteFollowsWhitelist(follows [][]byte) { + r.writeFollowsFollowsBin = follows +} + +// IsInReadFollowsWhitelist checks if the given pubkey is in this rule's read follows whitelist. +// The pubkey parameter should be binary ([]byte), not hex-encoded. +// Returns true if either: +// 1. The pubkey is one of the ReadFollowsWhitelist pubkeys themselves, OR +// 2. The pubkey is in the follows list of the ReadFollowsWhitelist pubkeys. +func (r *Rule) IsInReadFollowsWhitelist(pubkey []byte) bool { + if len(pubkey) == 0 { + return false + } + // Check if pubkey is one of the whitelist pubkeys themselves + for _, wlPubkey := range r.readFollowsWhitelistBin { + if utils.FastEqual(pubkey, wlPubkey) { + return true + } + } + // Check if pubkey is in the follows list + for _, follow := range r.readFollowsFollowsBin { + if utils.FastEqual(pubkey, follow) { + return true + } + } + return false +} + +// IsInWriteFollowsWhitelist checks if the given pubkey is in this rule's write follows whitelist. +// The pubkey parameter should be binary ([]byte), not hex-encoded. +// Returns true if either: +// 1. The pubkey is one of the WriteFollowsWhitelist pubkeys themselves, OR +// 2. The pubkey is in the follows list of the WriteFollowsWhitelist pubkeys. +func (r *Rule) IsInWriteFollowsWhitelist(pubkey []byte) bool { + if len(pubkey) == 0 { + return false + } + // Check if pubkey is one of the whitelist pubkeys themselves + for _, wlPubkey := range r.writeFollowsWhitelistBin { + if utils.FastEqual(pubkey, wlPubkey) { + return true + } + } + // Check if pubkey is in the follows list + for _, follow := range r.writeFollowsFollowsBin { + if utils.FastEqual(pubkey, follow) { + return true + } + } + return false +} + // PolicyEvent represents an event with additional context for policy scripts. // It embeds the Nostr event and adds authentication and network context. type PolicyEvent struct { @@ -401,10 +529,15 @@ type P struct { Owners []string `json:"owners,omitempty"` // Unexported binary caches for faster comparison (populated from hex strings above) - policyAdminsBin [][]byte // Binary cache for policy admin pubkeys - policyFollows [][]byte // Cached follow list from policy admins (kind 3 events) - policyFollowsMx sync.RWMutex // Protect follows list access - ownersBin [][]byte // Binary cache for policy-defined owner pubkeys + policyAdminsBin [][]byte // Binary cache for policy admin pubkeys + policyFollows [][]byte // Cached follow list from policy admins (kind 3 events) + ownersBin [][]byte // Binary cache for policy-defined owner pubkeys + + // followsMx protects all follows-related caches from concurrent access. + // This includes policyFollows, Global.readFollowsFollowsBin, Global.writeFollowsFollowsBin, + // and rule-specific follows whitelists. + // Use RLock for reads (CheckPolicy) and Lock for writes (Update*Follows*). + followsMx sync.RWMutex // manager handles policy script execution. // Unexported to enforce use of public API methods (CheckPolicy, IsEnabled). @@ -1115,73 +1248,97 @@ func (p *P) LoadFromFile(configPath string) error { // CheckPolicy checks if an event is allowed based on the policy configuration. // The access parameter should be "write" for accepting events or "read" for filtering events. // Returns true if the event is allowed, false if denied, and an error if validation fails. -// Policy evaluation order: global rules → kind filtering → specific rules → default policy. +// +// Policy evaluation order (more specific rules take precedence): +// 1. Kinds whitelist/blacklist - if kind is blocked, deny immediately +// 2. Kind-specific rule - if exists for this kind, use it exclusively +// 3. Global rule - fallback if no kind-specific rule exists +// 4. Default policy - fallback if no rules apply +// +// Thread-safety: Uses followsMx.RLock to protect reads of follows whitelists during policy checks. +// Write operations (Update*) acquire the write lock, which blocks concurrent reads. func (p *P) CheckPolicy( access string, ev *event.E, loggedInPubkey []byte, ipAddress string, ) (allowed bool, err error) { + // Handle nil policy - this should not happen if policy is enabled + // If policy is enabled but p is nil, it's a configuration error + if p == nil { + log.F.Ln("FATAL: CheckPolicy called on nil policy - this indicates misconfiguration. " + + "If ORLY_POLICY_ENABLED=true, ensure policy configuration is valid.") + return false, fmt.Errorf("policy is nil but policy checking is enabled - check configuration") + } + // Handle nil event if ev == nil { return false, fmt.Errorf("event cannot be nil") } - // First check global rule filter (applies to all events) - if !p.checkGlobalRulePolicy(access, ev, loggedInPubkey) { - return false, nil - } + // Acquire read lock to protect follows whitelists during policy check + p.followsMx.RLock() + defer p.followsMx.RUnlock() - // Then check kinds white/blacklist + // ========================================================================== + // STEP 1: Check kinds whitelist/blacklist (applies before any rule checks) + // ========================================================================== if !p.checkKindsPolicy(ev.Kind) { return false, nil } - // Get rule for this kind - rule, hasRule := p.rules[int(ev.Kind)] - if !hasRule { - // No specific rule for this kind, use default policy - return p.getDefaultPolicyAction(), nil - } - - // Check if script is present and enabled - if rule.Script != "" && p.manager != nil { - if p.manager.IsEnabled() { - // Check if script file exists before trying to use it - if _, err := os.Stat(rule.Script); err == nil { - // Script exists, try to use it - log.D.F( - "using policy script for kind %d: %s", ev.Kind, rule.Script, - ) - allowed, err := p.checkScriptPolicy( - access, ev, rule.Script, loggedInPubkey, ipAddress, - ) - if err == nil { - // Script ran successfully, return its decision - return allowed, nil + // ========================================================================== + // STEP 2: Check KIND-SPECIFIC rule FIRST (more specific = higher priority) + // ========================================================================== + // If kind-specific rule exists and accepts, that's final - global is ignored. + rule, hasKindRule := p.rules[int(ev.Kind)] + if hasKindRule { + // Check if script is present and enabled for this kind + if rule.Script != "" && p.manager != nil { + if p.manager.IsEnabled() { + // Check if script file exists before trying to use it + if _, err := os.Stat(rule.Script); err == nil { + // Script exists, try to use it + log.D.F("using policy script for kind %d: %s", ev.Kind, rule.Script) + allowed, err := p.checkScriptPolicy( + access, ev, rule.Script, loggedInPubkey, ipAddress, + ) + if err == nil { + // Script ran successfully, return its decision + return allowed, nil + } + // Script failed, fall through to apply other criteria + log.W.F("policy script check failed for kind %d: %v, applying other criteria", + ev.Kind, err) + } else { + // Script configured but doesn't exist + log.W.F("policy script configured for kind %d but not found at %s: %v, applying other criteria", + ev.Kind, rule.Script, err) } - // Script failed, fall through to apply other criteria - log.W.F( - "policy script check failed for kind %d: %v, applying other criteria", - ev.Kind, err, - ) + // Script doesn't exist or failed, fall through to apply other criteria } else { - // Script configured but doesn't exist - log.W.F( - "policy script configured for kind %d but not found at %s: %v, applying other criteria", - ev.Kind, rule.Script, err, - ) + // Policy manager is disabled, fall back to default policy + log.D.F("policy manager is disabled for kind %d, falling back to default policy (%s)", + ev.Kind, p.DefaultPolicy) + return p.getDefaultPolicyAction(), nil } - // Script doesn't exist or failed, fall through to apply other criteria - } else { - // Policy manager is disabled, fall back to default policy - log.D.F( - "policy manager is disabled for kind %d, falling back to default policy (%s)", - ev.Kind, p.DefaultPolicy, - ) - return p.getDefaultPolicyAction(), nil } + + // Apply kind-specific rule-based filtering + return p.checkRulePolicy(access, ev, rule, loggedInPubkey) } - // Apply rule-based filtering - return p.checkRulePolicy(access, ev, rule, loggedInPubkey) + // ========================================================================== + // STEP 3: No kind-specific rule - check GLOBAL rule as fallback + // ========================================================================== + + // Check if global rule has any configuration + if p.Global.hasAnyRules() { + // Apply global rule filtering + return p.checkRulePolicy(access, ev, p.Global, loggedInPubkey) + } + + // ========================================================================== + // STEP 4: No kind-specific or global rules - use default policy + // ========================================================================== + return p.getDefaultPolicyAction(), nil } // checkKindsPolicy checks if the event kind is allowed. @@ -1216,6 +1373,7 @@ func (p *P) checkKindsPolicy(kind uint16) bool { // Behavior depends on whether default_policy is explicitly set: // - If default_policy is explicitly "allow", allow all kinds (rules add constraints, not restrictions) // - If default_policy is unset or "deny", use implicit whitelist (only allow kinds with rules) + // - If global rule has any configuration, allow kinds through for global rule checking if len(p.rules) > 0 { // If default_policy is explicitly "allow", don't use implicit whitelist if p.DefaultPolicy == "allow" { @@ -1223,13 +1381,63 @@ func (p *P) checkKindsPolicy(kind uint16) bool { } // Implicit whitelist mode - only allow kinds with specific rules _, hasRule := p.rules[int(kind)] - return hasRule + if hasRule { + return true + } + // No kind-specific rule, but check if global rule exists + if p.Global.hasAnyRules() { + return true // Allow through for global rule check + } + return false } - // No specific rules - fall back to default policy + // No kind-specific rules - check if global rule exists + if p.Global.hasAnyRules() { + return true // Allow through for global rule check + } + // No rules at all - fall back to default policy return p.getDefaultPolicyAction() } +// checkGlobalFollowsWhitelistAccess checks if the user is explicitly granted access +// via the global rule's follows whitelists (read_follows_whitelist or write_follows_whitelist). +// This grants access that bypasses the default policy for kinds without specific rules. +// Note: p should never be nil here - caller (CheckPolicy) already validates this. +func (p *P) checkGlobalFollowsWhitelistAccess(access string, loggedInPubkey []byte) bool { + if len(loggedInPubkey) == 0 { + return false + } + + if access == "read" { + // Check if user is in global read follows whitelist + if p.Global.HasReadFollowsWhitelist() && p.Global.IsInReadFollowsWhitelist(loggedInPubkey) { + return true + } + // Also check legacy WriteAllowFollows and FollowsWhitelistAdmins for read access + if p.Global.WriteAllowFollows && p.PolicyFollowWhitelistEnabled && p.IsPolicyFollow(loggedInPubkey) { + return true + } + if p.Global.HasFollowsWhitelistAdmins() && p.Global.IsInFollowsWhitelist(loggedInPubkey) { + return true + } + } else if access == "write" { + // Check if user is in global write follows whitelist + if p.Global.HasWriteFollowsWhitelist() && p.Global.IsInWriteFollowsWhitelist(loggedInPubkey) { + return true + } + // Also check legacy WriteAllowFollows and FollowsWhitelistAdmins for write access + if p.Global.WriteAllowFollows && p.PolicyFollowWhitelistEnabled && p.IsPolicyFollow(loggedInPubkey) { + return true + } + if p.Global.HasFollowsWhitelistAdmins() && p.Global.IsInFollowsWhitelist(loggedInPubkey) { + return true + } + } + + return false +} + // checkGlobalRulePolicy checks if the event passes the global rule filter +// Note: p should never be nil here - caller (CheckPolicy) already validates this. func (p *P) checkGlobalRulePolicy( access string, ev *event.E, loggedInPubkey []byte, ) bool { @@ -1247,150 +1455,152 @@ func (p *P) checkGlobalRulePolicy( return allowed } -// checkRulePolicy evaluates rule-based access control with corrected evaluation order. -// Evaluation order: -// 1. Universal constraints (size, tags, age) - apply to everyone -// 2. Explicit denials (deny lists) - highest priority blacklist -// 3. Privileged access - parties involved get special access (ONLY if no allow lists) -// 4. Explicit allows (allow lists) - exclusive and authoritative when present -// 5. Default policy - fallback when no rules apply +// checkRulePolicy evaluates rule-based access control with the following logic: // -// IMPORTANT: When both privileged AND allow lists are specified, allow lists are -// authoritative - even parties involved must be in the allow list. +// READ ACCESS (default-permissive): +// - Denied if in read_deny list +// - If read_allow, read_follows_whitelist, or privileged is set, user must pass one of those checks +// - Otherwise, read is allowed by default +// +// WRITE ACCESS (default-permissive): +// - Denied if in write_deny list +// - Universal constraints (size, tags, age) apply to writes only +// - If write_allow or write_follows_whitelist is set, user must pass one of those checks +// - Otherwise, write is allowed by default +// +// PRIVILEGED: Only applies to READ operations (party-involved check) func (p *P) checkRulePolicy( access string, ev *event.E, rule Rule, loggedInPubkey []byte, ) (allowed bool, err error) { + log.T.F("checkRulePolicy: access=%s kind=%d readFollowsFollowsBin_len=%d readFollowsWhitelistBin_len=%d HasReadFollowsWhitelist=%v", + access, ev.Kind, len(rule.readFollowsFollowsBin), len(rule.readFollowsWhitelistBin), rule.HasReadFollowsWhitelist()) + // =================================================================== - // STEP 1: Universal Constraints (apply to everyone) + // STEP 1: Universal Constraints (WRITE ONLY - apply to everyone) // =================================================================== - // Check size limits - if rule.SizeLimit != nil { - eventSize := int64(len(ev.Serialize())) - if eventSize > *rule.SizeLimit { - return false, nil - } - } - - if rule.ContentLimit != nil { - contentSize := int64(len(ev.Content)) - if contentSize > *rule.ContentLimit { - return false, nil - } - } - - // Check required tags - // Only apply for write access - we validate what goes in, not what comes out - if access == "write" && len(rule.MustHaveTags) > 0 { - for _, requiredTag := range rule.MustHaveTags { - if ev.Tags.GetFirst([]byte(requiredTag)) == nil { + if access == "write" { + // Check size limits + if rule.SizeLimit != nil { + eventSize := int64(len(ev.Serialize())) + if eventSize > *rule.SizeLimit { return false, nil } } - } - // Check expiry time (uses maxExpirySeconds which is parsed from MaxExpiryDuration or MaxExpiry) - // Only apply for write access - we validate what goes in, not what comes out - if access == "write" && rule.maxExpirySeconds != nil && *rule.maxExpirySeconds > 0 { - expiryTag := ev.Tags.GetFirst([]byte("expiration")) - if expiryTag == nil { - return false, nil // Must have expiry if max_expiry is set - } - // Parse expiry timestamp and validate it's within allowed duration from created_at - expiryStr := string(expiryTag.Value()) - expiryTs, parseErr := strconv.ParseInt(expiryStr, 10, 64) - if parseErr != nil { - log.D.F("invalid expiration tag value %q: %v", expiryStr, parseErr) - return false, nil // Invalid expiry format - } - maxAllowedExpiry := ev.CreatedAt + *rule.maxExpirySeconds - if expiryTs >= maxAllowedExpiry { - log.D.F("expiration %d exceeds max allowed %d (created_at %d + max_expiry %d)", - expiryTs, maxAllowedExpiry, ev.CreatedAt, *rule.maxExpirySeconds) - return false, nil // Expiry too far in the future - } - } - - // Check ProtectedRequired (NIP-70: events must have "-" tag) - // Only apply for write access - we validate what goes in, not what comes out - if access == "write" && rule.ProtectedRequired { - protectedTag := ev.Tags.GetFirst([]byte("-")) - if protectedTag == nil { - log.D.F("protected_required: event missing '-' tag (NIP-70)") - return false, nil // Must have protected tag - } - } - - // Check IdentifierRegex (validates "d" tag values) - // Only apply for write access - we validate what goes in, not what comes out - if access == "write" && rule.identifierRegexCache != nil { - dTags := ev.Tags.GetAll([]byte("d")) - if len(dTags) == 0 { - log.D.F("identifier_regex: event missing 'd' tag") - return false, nil // Must have d tag if identifier_regex is set - } - for _, dTag := range dTags { - value := string(dTag.Value()) - if !rule.identifierRegexCache.MatchString(value) { - log.D.F("identifier_regex: d tag value %q does not match pattern %q", - value, rule.IdentifierRegex) + if rule.ContentLimit != nil { + contentSize := int64(len(ev.Content)) + if contentSize > *rule.ContentLimit { return false, nil } } - } - // Check MaxAgeOfEvent (maximum age of event in seconds) - // Only apply for write access - we validate what goes in, not what comes out - if access == "write" && rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 { - currentTime := time.Now().Unix() - maxAllowedTime := currentTime - *rule.MaxAgeOfEvent - if ev.CreatedAt < maxAllowedTime { - return false, nil // Event is too old - } - } - - // Check MaxAgeEventInFuture (maximum time event can be in the future in seconds) - // Only apply for write access - we validate what goes in, not what comes out - if access == "write" && rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 { - currentTime := time.Now().Unix() - maxFutureTime := currentTime + *rule.MaxAgeEventInFuture - if ev.CreatedAt > maxFutureTime { - return false, nil // Event is too far in the future - } - } - - // Check tag validation rules (regex patterns) - // Only apply for write access - we validate what goes in, not what comes out - // NOTE: TagValidation only validates tags that ARE present on the event. - // To REQUIRE a tag to exist, use MustHaveTags instead. - if access == "write" && len(rule.TagValidation) > 0 { - for tagName, regexPattern := range rule.TagValidation { - // Compile regex pattern (errors should have been caught in ValidateJSON) - regex, compileErr := regexp.Compile(regexPattern) - if compileErr != nil { - log.E.F("invalid regex pattern for tag %q: %v (skipping validation)", tagName, compileErr) - continue - } - - // Get all tags with this name - tags := ev.Tags.GetAll([]byte(tagName)) - - // If no tags found, skip validation for this tag type - // (TagValidation validates format, not presence - use MustHaveTags for presence) - if len(tags) == 0 { - continue - } - - // Validate each tag value against regex - for _, t := range tags { - value := string(t.Value()) - if !regex.MatchString(value) { - log.D.F("tag validation failed: tag %q value %q does not match pattern %q", - tagName, value, regexPattern) + // Check required tags + if len(rule.MustHaveTags) > 0 { + for _, requiredTag := range rule.MustHaveTags { + if ev.Tags.GetFirst([]byte(requiredTag)) == nil { return false, nil } } } + + // Check expiry time (uses maxExpirySeconds which is parsed from MaxExpiryDuration or MaxExpiry) + if rule.maxExpirySeconds != nil && *rule.maxExpirySeconds > 0 { + expiryTag := ev.Tags.GetFirst([]byte("expiration")) + if expiryTag == nil { + return false, nil // Must have expiry if max_expiry is set + } + // Parse expiry timestamp and validate it's within allowed duration from created_at + expiryStr := string(expiryTag.Value()) + expiryTs, parseErr := strconv.ParseInt(expiryStr, 10, 64) + if parseErr != nil { + log.D.F("invalid expiration tag value %q: %v", expiryStr, parseErr) + return false, nil // Invalid expiry format + } + maxAllowedExpiry := ev.CreatedAt + *rule.maxExpirySeconds + if expiryTs >= maxAllowedExpiry { + log.D.F("expiration %d exceeds max allowed %d (created_at %d + max_expiry %d)", + expiryTs, maxAllowedExpiry, ev.CreatedAt, *rule.maxExpirySeconds) + return false, nil // Expiry too far in the future + } + } + + // Check ProtectedRequired (NIP-70: events must have "-" tag) + if rule.ProtectedRequired { + protectedTag := ev.Tags.GetFirst([]byte("-")) + if protectedTag == nil { + log.D.F("protected_required: event missing '-' tag (NIP-70)") + return false, nil // Must have protected tag + } + } + + // Check IdentifierRegex (validates "d" tag values) + if rule.identifierRegexCache != nil { + dTags := ev.Tags.GetAll([]byte("d")) + if len(dTags) == 0 { + log.D.F("identifier_regex: event missing 'd' tag") + return false, nil // Must have d tag if identifier_regex is set + } + for _, dTag := range dTags { + value := string(dTag.Value()) + if !rule.identifierRegexCache.MatchString(value) { + log.D.F("identifier_regex: d tag value %q does not match pattern %q", + value, rule.IdentifierRegex) + return false, nil + } + } + } + + // Check MaxAgeOfEvent (maximum age of event in seconds) + if rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 { + currentTime := time.Now().Unix() + maxAllowedTime := currentTime - *rule.MaxAgeOfEvent + if ev.CreatedAt < maxAllowedTime { + return false, nil // Event is too old + } + } + + // Check MaxAgeEventInFuture (maximum time event can be in the future in seconds) + if rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 { + currentTime := time.Now().Unix() + maxFutureTime := currentTime + *rule.MaxAgeEventInFuture + if ev.CreatedAt > maxFutureTime { + return false, nil // Event is too far in the future + } + } + + // Check tag validation rules (regex patterns) + // NOTE: TagValidation only validates tags that ARE present on the event. + // To REQUIRE a tag to exist, use MustHaveTags instead. + if len(rule.TagValidation) > 0 { + for tagName, regexPattern := range rule.TagValidation { + // Compile regex pattern (errors should have been caught in ValidateJSON) + regex, compileErr := regexp.Compile(regexPattern) + if compileErr != nil { + log.E.F("invalid regex pattern for tag %q: %v (skipping validation)", tagName, compileErr) + continue + } + + // Get all tags with this name + tags := ev.Tags.GetAll([]byte(tagName)) + + // If no tags found, skip validation for this tag type + // (TagValidation validates format, not presence - use MustHaveTags for presence) + if len(tags) == 0 { + continue + } + + // Validate each tag value against regex + for _, t := range tags { + value := string(t.Value()) + if !regex.MatchString(value) { + log.D.F("tag validation failed: tag %q value %q does not match pattern %q", + tagName, value, regexPattern) + return false, nil + } + } + } + } } // =================================================================== @@ -1434,11 +1644,11 @@ func (p *P) checkRulePolicy( } // =================================================================== - // STEP 2.5: Write Allow Follows (grants BOTH read AND write access) + // STEP 3: Legacy WriteAllowFollows (grants BOTH read AND write access) // =================================================================== // WriteAllowFollows grants both read and write access to policy admin follows - // This check applies to BOTH read and write access types + // This check applies to BOTH read and write access types (legacy behavior) if rule.WriteAllowFollows && p.PolicyFollowWhitelistEnabled { if p.IsPolicyFollow(loggedInPubkey) { log.D.F("policy admin follow granted %s access for kind %d", access, ev.Kind) @@ -1447,7 +1657,7 @@ func (p *P) checkRulePolicy( } // FollowsWhitelistAdmins grants access to follows of specific admin pubkeys for this rule - // This is a per-rule alternative to WriteAllowFollows which uses global PolicyAdmins + // This is a per-rule alternative to WriteAllowFollows which uses global PolicyAdmins (DEPRECATED) if rule.HasFollowsWhitelistAdmins() { if rule.IsInFollowsWhitelist(loggedInPubkey) { log.D.F("follows_whitelist_admins granted %s access for kind %d", access, ev.Kind) @@ -1456,16 +1666,43 @@ func (p *P) checkRulePolicy( } // =================================================================== - // STEP 3: Check Read Access with OR Logic (Allow List OR Privileged) + // STEP 4: New Follows Whitelist Checks (separate read/write) // =================================================================== - // For read operations, check if user has access via allow list OR privileged if access == "read" { - hasAllowList := len(rule.readAllowBin) > 0 || len(rule.ReadAllow) > 0 - userInAllowList := false + // Check ReadFollowsWhitelist - if set, it acts as a whitelist + if rule.HasReadFollowsWhitelist() { + if rule.IsInReadFollowsWhitelist(loggedInPubkey) { + log.D.F("read_follows_whitelist granted read access for kind %d", ev.Kind) + return true, nil + } + // ReadFollowsWhitelist is set but user is not in it + // Continue to check other access methods (privileged, read_allow) + } + } else if access == "write" { + // Check WriteFollowsWhitelist - if set, it acts as a whitelist + if rule.HasWriteFollowsWhitelist() { + if rule.IsInWriteFollowsWhitelist(loggedInPubkey) { + log.D.F("write_follows_whitelist granted write access for kind %d", ev.Kind) + return true, nil + } + // WriteFollowsWhitelist is set but user is not in it - must check write_allow too + } + } + + // =================================================================== + // STEP 5: Read Access Control + // =================================================================== + + if access == "read" { + hasReadAllowList := len(rule.readAllowBin) > 0 || len(rule.ReadAllow) > 0 + hasReadFollowsWhitelist := rule.HasReadFollowsWhitelist() + // Include deprecated FollowsWhitelistAdmins for backward compatibility (it grants read+write) + hasLegacyFollowsWhitelist := rule.HasFollowsWhitelistAdmins() userIsPrivileged := rule.Privileged && IsPartyInvolved(ev, loggedInPubkey) // Check if user is in read allow list + userInAllowList := false if len(rule.readAllowBin) > 0 { for _, allowedPubkey := range rule.readAllowBin { if utils.FastEqual(loggedInPubkey, allowedPubkey) { @@ -1483,111 +1720,78 @@ func (p *P) checkRulePolicy( } } - // Handle different cases: - // 1. If there's an allow list: use OR logic (in list OR privileged) - // 2. If no allow list but privileged: only involved parties allowed - // 3. If no allow list and not privileged: continue to other checks + // Determine if any read whitelist restriction is active + // Note: Legacy FollowsWhitelistAdmins also counts as a read restriction for backward compatibility + hasReadRestriction := hasReadAllowList || hasReadFollowsWhitelist || hasLegacyFollowsWhitelist || rule.Privileged - if hasAllowList { - // OR logic when allow list exists - if userInAllowList || userIsPrivileged { + if hasReadRestriction { + // User must pass one of the configured access methods + if userInAllowList { return true, nil } - // Not in allow list AND not privileged -> deny - return false, nil - } else if rule.Privileged { - // No allow list but privileged -> only involved parties if userIsPrivileged { return true, nil } - // Not involved in privileged event -> deny + // User is in ReadFollowsWhitelist was already checked in STEP 4 + // User in legacy FollowsWhitelistAdmins was already checked in STEP 3 + // If we reach here with a read restriction, deny access return false, nil } - // No allow list and not privileged -> continue to other checks + + // No read restriction configured - read is permissive by default + return true, nil } // =================================================================== - // STEP 4: Explicit Allows (exclusive access - ONLY these users) + // STEP 6: Write Access Control // =================================================================== if access == "write" { - // Check write allow list (exclusive - ONLY these users can write) - // Special case: empty list (but not nil) means allow all - if rule.WriteAllow != nil && len(rule.WriteAllow) == 0 && len(rule.writeAllowBin) == 0 { - // Empty allow list explicitly set - allow all writers - return true, nil - } + hasWriteAllowList := len(rule.writeAllowBin) > 0 || len(rule.WriteAllow) > 0 + hasWriteFollowsWhitelist := rule.HasWriteFollowsWhitelist() + // Include deprecated FollowsWhitelistAdmins for backward compatibility + hasLegacyFollowsWhitelist := rule.HasFollowsWhitelistAdmins() + // Check if user is in write allow list + userInAllowList := false if len(rule.writeAllowBin) > 0 { - // Check if logged-in user (submitter) is allowed to write - allowed = false for _, allowedPubkey := range rule.writeAllowBin { if utils.FastEqual(loggedInPubkey, allowedPubkey) { - allowed = true + userInAllowList = true break } } - if !allowed { - return false, nil // Submitter not in exclusive allow list - } - // Submitter is in allow list - return true, nil } else if len(rule.WriteAllow) > 0 { - // Fallback: binary cache not populated, use hex comparison - // Check if logged-in user (submitter) is allowed to write loggedInPubkeyHex := hex.Enc(loggedInPubkey) - allowed = false for _, allowedPubkey := range rule.WriteAllow { if loggedInPubkeyHex == allowedPubkey { - allowed = true + userInAllowList = true break } } - if !allowed { - return false, nil // Submitter not in exclusive allow list + } + + // Determine if any write whitelist restriction is active + // Note: Legacy FollowsWhitelistAdmins also counts as a write restriction for backward compatibility + hasWriteRestriction := hasWriteAllowList || hasWriteFollowsWhitelist || hasLegacyFollowsWhitelist + + if hasWriteRestriction { + // User must pass one of the configured access methods + if userInAllowList { + return true, nil } - // Submitter is in allow list - return true, nil + // User in WriteFollowsWhitelist was already checked in STEP 4 + // User in legacy FollowsWhitelistAdmins was already checked in STEP 3 + // If we reach here with a write restriction, deny access + return false, nil } - // If we have ONLY a deny list (no allow list), and user is not denied, allow - if (len(rule.WriteDeny) > 0 || len(rule.writeDenyBin) > 0) && - len(rule.WriteAllow) == 0 && len(rule.writeAllowBin) == 0 { - // Only deny list exists, user wasn't denied above, so allow - return true, nil - } - } else if access == "read" { - // Read access already handled in STEP 3 with OR logic (allow list OR privileged) - // Only need to handle special cases here - - // Special case: empty list (but not nil) means allow all - // BUT if privileged, still need to check if user is involved - if rule.ReadAllow != nil && len(rule.ReadAllow) == 0 && len(rule.readAllowBin) == 0 { - if rule.Privileged { - // Empty allow list with privileged - only involved parties - return IsPartyInvolved(ev, loggedInPubkey), nil - } - // Empty allow list without privileged - allow all readers - return true, nil - } - - // If we have ONLY a deny list (no allow list), and user is not denied, allow - if (len(rule.ReadDeny) > 0 || len(rule.readDenyBin) > 0) && - len(rule.ReadAllow) == 0 && len(rule.readAllowBin) == 0 { - // Only deny list exists, user wasn't denied above, so allow - return true, nil - } + // No write restriction configured - write is permissive by default + return true, nil } // =================================================================== - // STEP 5: No Additional Privileged Check Needed - // =================================================================== - - // Privileged access for read operations is already handled in STEP 3 with OR logic - // No additional check needed here - - // =================================================================== - // STEP 6: Default Policy + // STEP 7: Default Policy (fallback) // =================================================================== // If no specific rules matched, use the configured default policy @@ -1875,7 +2079,7 @@ func (p *P) Reload(policyJSON []byte, configPath string) error { } // Step 4: Apply the new configuration (preserve manager reference) - p.policyFollowsMx.Lock() + p.followsMx.Lock() p.Kind = tempPolicy.Kind p.rules = tempPolicy.rules p.Global = tempPolicy.Global @@ -1886,7 +2090,7 @@ func (p *P) Reload(policyJSON []byte, configPath string) error { p.policyAdminsBin = tempPolicy.policyAdminsBin p.ownersBin = tempPolicy.ownersBin // Note: policyFollows is NOT reset here - it will be refreshed separately - p.policyFollowsMx.Unlock() + p.followsMx.Unlock() // Step 5: Populate binary caches for all rules p.Global.populateBinaryCache() @@ -2009,8 +2213,8 @@ func (p *P) IsPolicyAdmin(pubkey []byte) bool { return false } - p.policyFollowsMx.RLock() - defer p.policyFollowsMx.RUnlock() + p.followsMx.RLock() + defer p.followsMx.RUnlock() for _, admin := range p.policyAdminsBin { if utils.FastEqual(admin, pubkey) { @@ -2027,8 +2231,8 @@ func (p *P) IsPolicyFollow(pubkey []byte) bool { return false } - p.policyFollowsMx.RLock() - defer p.policyFollowsMx.RUnlock() + p.followsMx.RLock() + defer p.followsMx.RUnlock() for _, follow := range p.policyFollows { if utils.FastEqual(pubkey, follow) { @@ -2042,8 +2246,8 @@ func (p *P) IsPolicyFollow(pubkey []byte) bool { // This is called when policy admins update their follow lists (kind 3 events). // The pubkeys should be binary ([]byte), not hex-encoded. func (p *P) UpdatePolicyFollows(follows [][]byte) { - p.policyFollowsMx.Lock() - defer p.policyFollowsMx.Unlock() + p.followsMx.Lock() + defer p.followsMx.Unlock() p.policyFollows = follows log.I.F("policy follows list updated with %d pubkeys", len(follows)) @@ -2052,8 +2256,8 @@ func (p *P) UpdatePolicyFollows(follows [][]byte) { // GetPolicyAdminsBin returns a copy of the binary policy admin pubkeys. // Used for checking if an event author is a policy admin. func (p *P) GetPolicyAdminsBin() [][]byte { - p.policyFollowsMx.RLock() - defer p.policyFollowsMx.RUnlock() + p.followsMx.RLock() + defer p.followsMx.RUnlock() // Return a copy to prevent external modification result := make([][]byte, len(p.policyAdminsBin)) @@ -2073,8 +2277,8 @@ func (p *P) GetOwnersBin() [][]byte { return nil } - p.policyFollowsMx.RLock() - defer p.policyFollowsMx.RUnlock() + p.followsMx.RLock() + defer p.followsMx.RUnlock() // Return a copy to prevent external modification result := make([][]byte, len(p.ownersBin)) @@ -2154,10 +2358,13 @@ func (p *P) GetRuleForKind(kind int) *Rule { // UpdateRuleFollowsWhitelist updates the follows whitelist for a specific kind's rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. +// Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateRuleFollowsWhitelist(kind int, follows [][]byte) { if p == nil || p.rules == nil { return } + p.followsMx.Lock() + defer p.followsMx.Unlock() if rule, exists := p.rules[kind]; exists { rule.UpdateFollowsWhitelist(follows) p.rules[kind] = rule @@ -2166,11 +2373,16 @@ func (p *P) UpdateRuleFollowsWhitelist(kind int, follows [][]byte) { // UpdateGlobalFollowsWhitelist updates the follows whitelist for the global rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. +// Note: We directly modify p.Global's unexported field because Global is a value type (not *Rule), +// so calling p.Global.UpdateFollowsWhitelist() would operate on a copy and discard changes. +// Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateGlobalFollowsWhitelist(follows [][]byte) { if p == nil { return } - p.Global.UpdateFollowsWhitelist(follows) + p.followsMx.Lock() + defer p.followsMx.Unlock() + p.Global.followsWhitelistFollowsBin = follows } // GetGlobalRule returns a pointer to the global rule for modification. @@ -2194,6 +2406,164 @@ func (p *P) GetRulesKinds() []int { return kinds } +// ============================================================================= +// ReadFollowsWhitelist and WriteFollowsWhitelist Methods +// ============================================================================= + +// GetAllReadFollowsWhitelistPubkeys returns all unique pubkeys from ReadFollowsWhitelist +// across all rules (including global). Returns hex-encoded pubkeys. +// This is used at startup to validate that kind 3 events exist for these pubkeys. +func (p *P) GetAllReadFollowsWhitelistPubkeys() []string { + if p == nil { + return nil + } + + // Use map to deduplicate + pubkeys := make(map[string]struct{}) + + // Check global rule + for _, pk := range p.Global.ReadFollowsWhitelist { + pubkeys[pk] = struct{}{} + } + + // Check all kind-specific rules + for _, rule := range p.rules { + for _, pk := range rule.ReadFollowsWhitelist { + pubkeys[pk] = struct{}{} + } + } + + // Convert map to slice + result := make([]string, 0, len(pubkeys)) + for pk := range pubkeys { + result = append(result, pk) + } + return result +} + +// GetAllWriteFollowsWhitelistPubkeys returns all unique pubkeys from WriteFollowsWhitelist +// across all rules (including global). Returns hex-encoded pubkeys. +// This is used at startup to validate that kind 3 events exist for these pubkeys. +func (p *P) GetAllWriteFollowsWhitelistPubkeys() []string { + if p == nil { + return nil + } + + // Use map to deduplicate + pubkeys := make(map[string]struct{}) + + // Check global rule + for _, pk := range p.Global.WriteFollowsWhitelist { + pubkeys[pk] = struct{}{} + } + + // Check all kind-specific rules + for _, rule := range p.rules { + for _, pk := range rule.WriteFollowsWhitelist { + pubkeys[pk] = struct{}{} + } + } + + // Convert map to slice + result := make([]string, 0, len(pubkeys)) + for pk := range pubkeys { + result = append(result, pk) + } + return result +} + +// GetAllFollowsWhitelistPubkeys returns all unique pubkeys from both ReadFollowsWhitelist +// and WriteFollowsWhitelist across all rules (including global). Returns hex-encoded pubkeys. +// This is a convenience method for startup validation to check all required kind 3 events. +func (p *P) GetAllFollowsWhitelistPubkeys() []string { + if p == nil { + return nil + } + + // Use map to deduplicate + pubkeys := make(map[string]struct{}) + + // Get read follows whitelist pubkeys + for _, pk := range p.GetAllReadFollowsWhitelistPubkeys() { + pubkeys[pk] = struct{}{} + } + + // Get write follows whitelist pubkeys + for _, pk := range p.GetAllWriteFollowsWhitelistPubkeys() { + pubkeys[pk] = struct{}{} + } + + // Also include deprecated FollowsWhitelistAdmins for backward compatibility + for _, pk := range p.GetAllFollowsWhitelistAdmins() { + pubkeys[pk] = struct{}{} + } + + // Convert map to slice + result := make([]string, 0, len(pubkeys)) + for pk := range pubkeys { + result = append(result, pk) + } + return result +} + +// UpdateRuleReadFollowsWhitelist updates the read follows whitelist for a specific kind's rule. +// The follows should be binary pubkeys ([]byte), not hex-encoded. +// Thread-safe: uses followsMx to protect concurrent access. +func (p *P) UpdateRuleReadFollowsWhitelist(kind int, follows [][]byte) { + if p == nil || p.rules == nil { + return + } + p.followsMx.Lock() + defer p.followsMx.Unlock() + if rule, exists := p.rules[kind]; exists { + rule.UpdateReadFollowsWhitelist(follows) + p.rules[kind] = rule + } +} + +// UpdateRuleWriteFollowsWhitelist updates the write follows whitelist for a specific kind's rule. +// The follows should be binary pubkeys ([]byte), not hex-encoded. +// Thread-safe: uses followsMx to protect concurrent access. +func (p *P) UpdateRuleWriteFollowsWhitelist(kind int, follows [][]byte) { + if p == nil || p.rules == nil { + return + } + p.followsMx.Lock() + defer p.followsMx.Unlock() + if rule, exists := p.rules[kind]; exists { + rule.UpdateWriteFollowsWhitelist(follows) + p.rules[kind] = rule + } +} + +// UpdateGlobalReadFollowsWhitelist updates the read follows whitelist for the global rule. +// The follows should be binary pubkeys ([]byte), not hex-encoded. +// Note: We directly modify p.Global's unexported field because Global is a value type (not *Rule), +// so calling p.Global.UpdateReadFollowsWhitelist() would operate on a copy and discard changes. +// Thread-safe: uses followsMx to protect concurrent access. +func (p *P) UpdateGlobalReadFollowsWhitelist(follows [][]byte) { + if p == nil { + return + } + p.followsMx.Lock() + defer p.followsMx.Unlock() + p.Global.readFollowsFollowsBin = follows +} + +// UpdateGlobalWriteFollowsWhitelist updates the write follows whitelist for the global rule. +// The follows should be binary pubkeys ([]byte), not hex-encoded. +// Note: We directly modify p.Global's unexported field because Global is a value type (not *Rule), +// so calling p.Global.UpdateWriteFollowsWhitelist() would operate on a copy and discard changes. +// Thread-safe: uses followsMx to protect concurrent access. +func (p *P) UpdateGlobalWriteFollowsWhitelist(follows [][]byte) { + if p == nil { + return + } + p.followsMx.Lock() + defer p.followsMx.Unlock() + p.Global.writeFollowsFollowsBin = follows +} + // ============================================================================= // Owner vs Policy Admin Update Validation // ============================================================================= diff --git a/pkg/version/version b/pkg/version/version index bb8e4e7..35d830c 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.32.3 \ No newline at end of file +v0.32.4 \ No newline at end of file