Enhance policy system tests and documentation.
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:
2025-12-03 19:19:36 +00:00
parent 6bd56a30c9
commit 7a27c44bc9
8 changed files with 1584 additions and 312 deletions

View File

@@ -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
View File

@@ -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)

View File

@@ -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)
}
```

View File

@@ -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) {

View 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)
}
}
}

View File

@@ -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")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1 @@
v0.32.3
v0.32.4