diff --git a/pkg/policy/README.md b/pkg/policy/README.md index e2bc0c2..5eccf9d 100644 --- a/pkg/policy/README.md +++ b/pkg/policy/README.md @@ -19,6 +19,7 @@ The policy system provides fine-grained control over event storage and retrieval - [Dynamic Policy Updates](#dynamic-policy-updates) - [Evaluation Order](#evaluation-order) - [Examples](#examples) + - [Permissive Mode Examples](#permissive-mode-examples) ## Overview @@ -271,6 +272,38 @@ Validates that tag values match the specified regex patterns. Only validates tag See [Follows-Based Whitelisting](#follows-based-whitelisting) for details. +#### Permissive Mode Overrides + +| Field | Type | Description | +|-------|------|-------------| +| `read_allow_permissive` | boolean | Override kind whitelist for READ access (reads allowed for all kinds) | +| `write_allow_permissive` | boolean | Override kind whitelist for WRITE access (writes use global rule only) | + +These fields, when set on the **global** rule, allow independent control over read and write access relative to the kind whitelist/blacklist: + +```json +{ + "kind": { + "whitelist": [1, 3, 5, 7] + }, + "global": { + "read_allow_permissive": true, + "size_limit": 100000 + } +} +``` + +In this example: +- **READ**: Allowed for ALL kinds (permissive override ignores whitelist) +- **WRITE**: Only kinds 1, 3, 5, 7 can be written (whitelist applies) + +**Important constraints:** +- These flags only work on the **global** rule (ignored on kind-specific rules) +- You cannot enable BOTH `read_allow_permissive` AND `write_allow_permissive` when a kind whitelist/blacklist is configured (this would make the whitelist meaningless) +- Blacklists always take precedence—permissive flags do NOT override explicit blacklist entries + +See [Permissive Mode Examples](#permissive-mode-examples) for detailed use cases. + #### Rate Limiting | Field | Type | Unit | Description | @@ -809,6 +842,83 @@ access_allowed = ( } ``` +### Permissive Mode Examples + +#### Read-Permissive Relay (Write-Restricted) + +Allow anyone to read all events, but restrict writes to specific kinds: + +```json +{ + "default_policy": "allow", + "kind": { + "whitelist": [1, 3, 7, 9735] + }, + "global": { + "read_allow_permissive": true, + "size_limit": 100000 + } +} +``` + +**Behavior:** +- **READ**: Any kind can be read (permissive override) +- **WRITE**: Only kinds 1, 3, 7, 9735 can be written + +This is useful for relays that want to serve as aggregators (read any event type) but only accept specific event types from clients. + +#### Write-Permissive with Read Restrictions + +Allow writes of any kind (with global constraints), but restrict reads: + +```json +{ + "default_policy": "allow", + "kind": { + "whitelist": [0, 1, 3] + }, + "global": { + "write_allow_permissive": true, + "size_limit": 50000, + "max_age_of_event": 86400 + } +} +``` + +**Behavior:** +- **READ**: Only kinds 0, 1, 3 can be read (whitelist applies) +- **WRITE**: Any kind can be written (with size and age limits from global rule) + +This is useful for relays that want to accept any event type but only serve a curated subset. + +#### Archive Relay (Read Any, Accept Specific) + +Perfect for archive/backup relays: + +```json +{ + "default_policy": "allow", + "kind": { + "whitelist": [0, 1, 3, 4, 7, 30023] + }, + "global": { + "read_allow_permissive": true, + "size_limit": 500000 + }, + "rules": { + "30023": { + "description": "Long-form articles with validation", + "identifier_regex": "^[a-z0-9-]{1,64}$", + "max_expiry_duration": "P365D" + } + } +} +``` + +**Behavior:** +- **READ**: All kinds can be read (historical data) +- **WRITE**: Only whitelisted kinds accepted, with specific rules for articles + ## Testing ### Run Policy Tests diff --git a/pkg/policy/benchmark_test.go b/pkg/policy/benchmark_test.go index 02586dd..7414d0d 100644 --- a/pkg/policy/benchmark_test.go +++ b/pkg/policy/benchmark_test.go @@ -40,7 +40,7 @@ func BenchmarkCheckKindsPolicy(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { - policy.checkKindsPolicy(1) + policy.checkKindsPolicy("write", 1) } } diff --git a/pkg/policy/bug_reproduction_test.go b/pkg/policy/bug_reproduction_test.go index 4545c9c..22bd036 100644 --- a/pkg/policy/bug_reproduction_test.go +++ b/pkg/policy/bug_reproduction_test.go @@ -168,8 +168,8 @@ func TestBugReproduction_DebugPolicyFlow(t *testing.T) { t.Logf("=== Policy Check Flow ===") // Step 1: Check kinds policy - kindsAllowed := policy.checkKindsPolicy(event.Kind) - t.Logf("1. checkKindsPolicy(kind=%d) returned: %v", event.Kind, kindsAllowed) + kindsAllowed := policy.checkKindsPolicy("write", event.Kind) + t.Logf("1. checkKindsPolicy(access=write, kind=%d) returned: %v", event.Kind, kindsAllowed) // Full policy check allowed, err := policy.CheckPolicy("write", event, testPubkey, "127.0.0.1") diff --git a/pkg/policy/new_fields_test.go b/pkg/policy/new_fields_test.go index a467164..a50b686 100644 --- a/pkg/policy/new_fields_test.go +++ b/pkg/policy/new_fields_test.go @@ -1351,6 +1351,57 @@ func TestValidateJSONNewFields(t *testing.T) { }`, expectError: false, }, + // Tests for read_allow_permissive and write_allow_permissive + { + name: "valid read_allow_permissive alone with whitelist", + json: `{ + "kind": {"whitelist": [1, 3, 5]}, + "global": {"read_allow_permissive": true} + }`, + expectError: false, + }, + { + name: "valid write_allow_permissive alone with whitelist", + json: `{ + "kind": {"whitelist": [1, 3, 5]}, + "global": {"write_allow_permissive": true} + }`, + expectError: false, + }, + { + name: "invalid both permissive flags with whitelist", + json: `{ + "kind": {"whitelist": [1, 3, 5]}, + "global": { + "read_allow_permissive": true, + "write_allow_permissive": true + } + }`, + expectError: true, + errorMatch: "read_allow_permissive and write_allow_permissive cannot be enabled together", + }, + { + name: "invalid both permissive flags with blacklist", + json: `{ + "kind": {"blacklist": [2, 4, 6]}, + "global": { + "read_allow_permissive": true, + "write_allow_permissive": true + } + }`, + expectError: true, + errorMatch: "read_allow_permissive and write_allow_permissive cannot be enabled together", + }, + { + name: "valid both permissive flags without any kind restriction", + json: `{ + "global": { + "read_allow_permissive": true, + "write_allow_permissive": true + } + }`, + expectError: false, + }, } for _, tt := range tests { diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 1533375..31800e2 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -144,6 +144,23 @@ type Rule struct { // Example: "^[a-z0-9-]{1,64}$" requires lowercase alphanumeric with hyphens, max 64 chars. IdentifierRegex string `json:"identifier_regex,omitempty"` + // ReadAllowPermissive when set on a GLOBAL rule, allows read access for ALL kinds, + // even when a kind whitelist is configured. This allows the kind whitelist to + // restrict WRITE operations while keeping reads permissive. + // When true: + // - READ: Allowed for all kinds (global rule still applies for other read restrictions) + // - WRITE: Kind whitelist/blacklist applies as normal + // Only meaningful on the Global rule - ignored on kind-specific rules. + ReadAllowPermissive bool `json:"read_allow_permissive,omitempty"` + + // WriteAllowPermissive when set on a GLOBAL rule, allows write access for kinds + // that don't have specific rules defined, bypassing the implicit kind whitelist. + // When true: + // - Kinds without specific rules apply global rule constraints only + // - Kind whitelist still blocks reads for unlisted kinds (unless ReadAllowPermissive is also set) + // Only meaningful on the Global rule - ignored on kind-specific rules. + WriteAllowPermissive bool `json:"write_allow_permissive,omitempty"` + // Binary caches for faster comparison (populated from hex strings above) // These are not exported and not serialized to JSON writeAllowBin [][]byte @@ -178,7 +195,8 @@ func (r *Rule) hasAnyRules() bool { len(r.ReadFollowsWhitelist) > 0 || len(r.WriteFollowsWhitelist) > 0 || len(r.readFollowsWhitelistBin) > 0 || len(r.writeFollowsWhitelistBin) > 0 || len(r.TagValidation) > 0 || - r.ProtectedRequired || r.IdentifierRegex != "" + r.ProtectedRequired || r.IdentifierRegex != "" || + r.ReadAllowPermissive || r.WriteAllowPermissive } // populateBinaryCache converts hex-encoded pubkey strings to binary for faster comparison. @@ -1280,7 +1298,7 @@ func (p *P) CheckPolicy( // ========================================================================== // STEP 1: Check kinds whitelist/blacklist (applies before any rule checks) // ========================================================================== - if !p.checkKindsPolicy(ev.Kind) { + if !p.checkKindsPolicy(access, ev.Kind) { return false, nil } @@ -1341,19 +1359,32 @@ func (p *P) CheckPolicy( return p.getDefaultPolicyAction(), nil } -// checkKindsPolicy checks if the event kind is allowed. +// checkKindsPolicy checks if the event kind is allowed for the given access type. // Logic: -// 1. If explicit whitelist exists, use it (backwards compatibility) -// 2. If explicit blacklist exists, use it (backwards compatibility) -// 3. Otherwise, kinds with defined rules are implicitly allowed, others denied -func (p *P) checkKindsPolicy(kind uint16) bool { - // If whitelist is present, only allow whitelisted kinds +// 1. If explicit whitelist exists, use it (but respect permissive flags for read/write) +// 2. If explicit blacklist exists, use it (but respect permissive flags for read/write) +// 3. Otherwise, kinds with defined rules are implicitly allowed, others denied (with permissive overrides) +// +// Permissive flags (set on Global rule): +// - ReadAllowPermissive: Allows READ access for kinds not in whitelist (write still restricted) +// - WriteAllowPermissive: Allows WRITE access for kinds not in whitelist (uses global rule constraints) +func (p *P) checkKindsPolicy(access string, kind uint16) bool { + // If whitelist is present, only allow whitelisted kinds (with permissive overrides) if len(p.Kind.Whitelist) > 0 { for _, allowedKind := range p.Kind.Whitelist { if kind == uint16(allowedKind) { return true } } + // Kind not in whitelist - check permissive flags + if access == "read" && p.Global.ReadAllowPermissive { + log.D.F("read_allow_permissive: allowing read for kind %d not in whitelist", kind) + return true // Allow read even though kind not whitelisted + } + if access == "write" && p.Global.WriteAllowPermissive { + log.D.F("write_allow_permissive: allowing write for kind %d not in whitelist (global rules apply)", kind) + return true // Allow write even though kind not whitelisted, global rule will be applied + } return false } @@ -1361,12 +1392,25 @@ func (p *P) checkKindsPolicy(kind uint16) bool { if len(p.Kind.Blacklist) > 0 { for _, deniedKind := range p.Kind.Blacklist { if kind == uint16(deniedKind) { + // Kind is explicitly blacklisted - permissive flags don't override blacklist return false } } // Not in blacklist - check if rule exists for implicit whitelist _, hasRule := p.rules[int(kind)] - return hasRule // Only allow if there's a rule defined + if hasRule { + return true + } + // No kind-specific rule - check permissive flags + if access == "read" && p.Global.ReadAllowPermissive { + log.D.F("read_allow_permissive: allowing read for kind %d (not blacklisted, no rule)", kind) + return true + } + if access == "write" && p.Global.WriteAllowPermissive { + log.D.F("write_allow_permissive: allowing write for kind %d (not blacklisted, no rule)", kind) + return true + } + return false // Only allow if there's a rule defined } // No explicit whitelist or blacklist @@ -1374,6 +1418,7 @@ func (p *P) checkKindsPolicy(kind uint16) bool { // - 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 + // - Permissive flags can override implicit whitelist behavior if len(p.rules) > 0 { // If default_policy is explicitly "allow", don't use implicit whitelist if p.DefaultPolicy == "allow" { @@ -1388,6 +1433,15 @@ func (p *P) checkKindsPolicy(kind uint16) bool { if p.Global.hasAnyRules() { return true // Allow through for global rule check } + // Check permissive flags for implicit whitelist override + if access == "read" && p.Global.ReadAllowPermissive { + log.D.F("read_allow_permissive: allowing read for kind %d (implicit whitelist override)", kind) + return true + } + if access == "write" && p.Global.WriteAllowPermissive { + log.D.F("write_allow_permissive: allowing write for kind %d (implicit whitelist override)", kind) + return true + } return false } // No kind-specific rules - check if global rule exists @@ -2052,6 +2106,13 @@ func (p *P) ValidateJSON(policyJSON []byte) error { return fmt.Errorf("invalid default_policy value: %q (must be \"allow\" or \"deny\")", tempPolicy.DefaultPolicy) } + // Validate permissive flags: if both read_allow_permissive AND write_allow_permissive are set + // with a kind whitelist or blacklist, this makes the whitelist/blacklist meaningless + hasKindRestriction := len(tempPolicy.Kind.Whitelist) > 0 || len(tempPolicy.Kind.Blacklist) > 0 + if hasKindRestriction && tempPolicy.Global.ReadAllowPermissive && tempPolicy.Global.WriteAllowPermissive { + return fmt.Errorf("invalid policy: both read_allow_permissive and write_allow_permissive cannot be enabled together with a kind whitelist or blacklist (this would make the kind restriction meaningless)") + } + log.D.F("policy JSON validation passed") return nil } diff --git a/pkg/policy/policy_test.go b/pkg/policy/policy_test.go index e70a67e..ea532f0 100644 --- a/pkg/policy/policy_test.go +++ b/pkg/policy/policy_test.go @@ -146,6 +146,7 @@ func TestCheckKindsPolicy(t *testing.T) { tests := []struct { name string policy *P + access string // "read" or "write" kind uint16 expected bool }{ @@ -155,6 +156,7 @@ func TestCheckKindsPolicy(t *testing.T) { Kind: Kinds{}, rules: map[int]Rule{}, // No rules defined }, + access: "write", kind: 1, expected: true, // Should be allowed (no rules = allow all kinds) }, @@ -166,6 +168,7 @@ func TestCheckKindsPolicy(t *testing.T) { 2: {Description: "Rule for kind 2"}, }, }, + access: "write", kind: 1, expected: false, // Should be denied (implicit whitelist, no rule for kind 1) }, @@ -177,6 +180,7 @@ func TestCheckKindsPolicy(t *testing.T) { 1: {Description: "Rule for kind 1"}, }, }, + access: "write", kind: 1, expected: true, // Should be allowed (has rule) }, @@ -189,6 +193,7 @@ func TestCheckKindsPolicy(t *testing.T) { }, rules: map[int]Rule{}, // No specific rules }, + access: "write", kind: 1, expected: true, // Should be allowed (global rule exists) }, @@ -199,6 +204,7 @@ func TestCheckKindsPolicy(t *testing.T) { Whitelist: []int{1, 3, 5}, }, }, + access: "write", kind: 1, expected: true, }, @@ -209,6 +215,7 @@ func TestCheckKindsPolicy(t *testing.T) { Whitelist: []int{1, 3, 5}, }, }, + access: "write", kind: 2, expected: false, }, @@ -222,6 +229,7 @@ func TestCheckKindsPolicy(t *testing.T) { 3: {Description: "Rule for kind 3"}, // Has at least one rule }, }, + access: "write", kind: 1, expected: false, // Should be denied (not blacklisted but no rule for kind 1) }, @@ -235,6 +243,7 @@ func TestCheckKindsPolicy(t *testing.T) { 1: {Description: "Rule for kind 1"}, }, }, + access: "write", kind: 1, expected: true, // Should be allowed (not blacklisted and has rule) }, @@ -245,6 +254,7 @@ func TestCheckKindsPolicy(t *testing.T) { Blacklist: []int{2, 4, 6}, }, }, + access: "write", kind: 2, expected: false, }, @@ -256,14 +266,87 @@ func TestCheckKindsPolicy(t *testing.T) { Blacklist: []int{1, 2, 3}, }, }, + access: "write", kind: 1, expected: true, }, + // Tests for new permissive flags + { + name: "read_allow_permissive - allows read for non-whitelisted kind", + policy: &P{ + Kind: Kinds{ + Whitelist: []int{1, 3, 5}, + }, + Global: Rule{ + ReadAllowPermissive: true, + }, + }, + access: "read", + kind: 2, + expected: true, // Should be allowed (read permissive overrides whitelist) + }, + { + name: "read_allow_permissive - write still blocked for non-whitelisted kind", + policy: &P{ + Kind: Kinds{ + Whitelist: []int{1, 3, 5}, + }, + Global: Rule{ + ReadAllowPermissive: true, + }, + }, + access: "write", + kind: 2, + expected: false, // Should be denied (only read is permissive) + }, + { + name: "write_allow_permissive - allows write for non-whitelisted kind", + policy: &P{ + Kind: Kinds{ + Whitelist: []int{1, 3, 5}, + }, + Global: Rule{ + WriteAllowPermissive: true, + }, + }, + access: "write", + kind: 2, + expected: true, // Should be allowed (write permissive overrides whitelist) + }, + { + name: "write_allow_permissive - read still blocked for non-whitelisted kind", + policy: &P{ + Kind: Kinds{ + Whitelist: []int{1, 3, 5}, + }, + Global: Rule{ + WriteAllowPermissive: true, + }, + }, + access: "read", + kind: 2, + expected: false, // Should be denied (only write is permissive) + }, + { + name: "blacklist - permissive flags do NOT override blacklist", + policy: &P{ + Kind: Kinds{ + Blacklist: []int{2, 4, 6}, + }, + Global: Rule{ + ReadAllowPermissive: true, + WriteAllowPermissive: true, + }, + }, + access: "write", + kind: 2, + expected: false, // Should be denied (blacklist always applies) + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tt.policy.checkKindsPolicy(tt.kind) + result := tt.policy.checkKindsPolicy(tt.access, tt.kind) if result != tt.expected { t.Errorf("Expected %v, got %v", tt.expected, result) } @@ -996,19 +1079,19 @@ func TestEdgeCasesWhitelistBlacklistConflict(t *testing.T) { } // Test kind in both whitelist and blacklist - whitelist should win - allowed := policy.checkKindsPolicy(1) + allowed := policy.checkKindsPolicy("write", 1) if !allowed { t.Error("Expected whitelist to override blacklist") } // Test kind in blacklist but not whitelist - allowed = policy.checkKindsPolicy(2) + allowed = policy.checkKindsPolicy("write", 2) if allowed { t.Error("Expected kind in blacklist but not whitelist to be blocked") } // Test kind in whitelist but not blacklist - allowed = policy.checkKindsPolicy(5) + allowed = policy.checkKindsPolicy("write", 5) if !allowed { t.Error("Expected kind in whitelist to be allowed") } diff --git a/pkg/version/version b/pkg/version/version index 26f36ee..b6b0ff1 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.32.6 \ No newline at end of file +v0.32.7 \ No newline at end of file