Enhance policy system tests and documentation.
Some checks failed
Go / build-and-release (push) Has been cancelled
Some checks failed
Go / build-and-release (push) Has been cancelled
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.
This commit is contained in:
@@ -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": []
|
||||
|
||||
132
CLAUDE.md
132
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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
686
pkg/policy/default_permissive_test.go
Normal file
686
pkg/policy/default_permissive_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -403,9 +531,14 @@ type P struct {
|
||||
// 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
|
||||
|
||||
// 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).
|
||||
manager *PolicyManager
|
||||
@@ -1115,41 +1248,55 @@ 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
|
||||
// ==========================================================================
|
||||
// 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,
|
||||
)
|
||||
log.D.F("using policy script for kind %d: %s", ev.Kind, rule.Script)
|
||||
allowed, err := p.checkScriptPolicy(
|
||||
access, ev, rule.Script, loggedInPubkey, ipAddress,
|
||||
)
|
||||
@@ -1158,30 +1305,40 @@ func (p *P) CheckPolicy(
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
log.W.F("policy script configured for kind %d but not found at %s: %v, applying other criteria",
|
||||
ev.Kind, rule.Script, err)
|
||||
}
|
||||
// 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,
|
||||
)
|
||||
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 rule-based filtering
|
||||
// Apply kind-specific 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 specific rules - fall back to default policy
|
||||
// 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 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,23 +1455,31 @@ 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)
|
||||
// ===================================================================
|
||||
|
||||
if access == "write" {
|
||||
// Check size limits
|
||||
if rule.SizeLimit != nil {
|
||||
eventSize := int64(len(ev.Serialize()))
|
||||
@@ -1280,8 +1496,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// Check required tags
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && len(rule.MustHaveTags) > 0 {
|
||||
if len(rule.MustHaveTags) > 0 {
|
||||
for _, requiredTag := range rule.MustHaveTags {
|
||||
if ev.Tags.GetFirst([]byte(requiredTag)) == nil {
|
||||
return false, nil
|
||||
@@ -1290,8 +1505,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
@@ -1312,8 +1526,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if rule.ProtectedRequired {
|
||||
protectedTag := ev.Tags.GetFirst([]byte("-"))
|
||||
if protectedTag == nil {
|
||||
log.D.F("protected_required: event missing '-' tag (NIP-70)")
|
||||
@@ -1322,8 +1535,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if rule.identifierRegexCache != nil {
|
||||
dTags := ev.Tags.GetAll([]byte("d"))
|
||||
if len(dTags) == 0 {
|
||||
log.D.F("identifier_regex: event missing 'd' tag")
|
||||
@@ -1340,8 +1552,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 {
|
||||
currentTime := time.Now().Unix()
|
||||
maxAllowedTime := currentTime - *rule.MaxAgeOfEvent
|
||||
if ev.CreatedAt < maxAllowedTime {
|
||||
@@ -1350,8 +1561,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 {
|
||||
currentTime := time.Now().Unix()
|
||||
maxFutureTime := currentTime + *rule.MaxAgeEventInFuture
|
||||
if ev.CreatedAt > maxFutureTime {
|
||||
@@ -1360,10 +1570,9 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
@@ -1392,6 +1601,7 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STEP 2: Explicit Denials (highest priority blacklist)
|
||||
@@ -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
|
||||
}
|
||||
// Submitter is in allow list
|
||||
return true, 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
|
||||
// Determine if any write whitelist restriction is active
|
||||
// Note: Legacy FollowsWhitelistAdmins also counts as a write restriction for backward compatibility
|
||||
hasWriteRestriction := hasWriteAllowList || hasWriteFollowsWhitelist || hasLegacyFollowsWhitelist
|
||||
|
||||
// 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
|
||||
if hasWriteRestriction {
|
||||
// User must pass one of the configured access methods
|
||||
if userInAllowList {
|
||||
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.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
|
||||
// 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
|
||||
// =============================================================================
|
||||
|
||||
@@ -1 +1 @@
|
||||
v0.32.3
|
||||
v0.32.4
|
||||
Reference in New Issue
Block a user