initial draft of hot reload policy
This commit is contained in:
339
pkg/policy/follows_test.go
Normal file
339
pkg/policy/follows_test.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"git.mleku.dev/mleku/nostr/encoders/hex"
|
||||
)
|
||||
|
||||
// setupTestPolicy creates a policy manager with a temporary config file.
|
||||
// Returns the policy and a cleanup function.
|
||||
func setupTestPolicy(t *testing.T, appName string) (*P, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Create config directory at XDG path
|
||||
configDir := filepath.Join(xdg.ConfigHome, appName)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
// Create default policy.json
|
||||
configPath := filepath.Join(configDir, "policy.json")
|
||||
defaultPolicy := []byte(`{"default_policy": "allow"}`)
|
||||
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
|
||||
t.Fatalf("Failed to write policy file: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
policy := NewWithManager(ctx, appName, true)
|
||||
if policy == nil {
|
||||
cancel()
|
||||
os.RemoveAll(configDir)
|
||||
t.Fatal("Failed to create policy manager")
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
cancel()
|
||||
os.RemoveAll(configDir)
|
||||
}
|
||||
|
||||
return policy, cleanup
|
||||
}
|
||||
|
||||
// TestIsPolicyAdmin tests the IsPolicyAdmin method
|
||||
func TestIsPolicyAdmin(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-policy-admin")
|
||||
defer cleanup()
|
||||
|
||||
// Set up policy with admins
|
||||
admin1Hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
nonAdminHex := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
policyJSON := []byte(`{
|
||||
"policy_admins": [
|
||||
"` + admin1Hex + `",
|
||||
"` + admin2Hex + `"
|
||||
]
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
// Convert hex to bytes for testing
|
||||
admin1Bin, _ := hex.Dec(admin1Hex)
|
||||
admin2Bin, _ := hex.Dec(admin2Hex)
|
||||
nonAdminBin, _ := hex.Dec(nonAdminHex)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pubkey []byte
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "first admin is recognized",
|
||||
pubkey: admin1Bin,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "second admin is recognized",
|
||||
pubkey: admin2Bin,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-admin is not recognized",
|
||||
pubkey: nonAdminBin,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil pubkey returns false",
|
||||
pubkey: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty pubkey returns false",
|
||||
pubkey: []byte{},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "wrong length pubkey returns false",
|
||||
pubkey: []byte{0x01, 0x02, 0x03},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := policy.IsPolicyAdmin(tt.pubkey)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsPolicyAdmin() = %v, expected %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPolicyFollow tests the IsPolicyFollow method
|
||||
func TestIsPolicyFollow(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-policy-follow")
|
||||
defer cleanup()
|
||||
|
||||
// Set up some follows
|
||||
follow1Hex := "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
follow2Hex := "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
nonFollowHex := "3333333333333333333333333333333333333333333333333333333333333333"
|
||||
|
||||
follow1Bin, _ := hex.Dec(follow1Hex)
|
||||
follow2Bin, _ := hex.Dec(follow2Hex)
|
||||
nonFollowBin, _ := hex.Dec(nonFollowHex)
|
||||
|
||||
// Update policy follows directly
|
||||
policy.UpdatePolicyFollows([][]byte{follow1Bin, follow2Bin})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pubkey []byte
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "first follow is recognized",
|
||||
pubkey: follow1Bin,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "second follow is recognized",
|
||||
pubkey: follow2Bin,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "non-follow is not recognized",
|
||||
pubkey: nonFollowBin,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "nil pubkey returns false",
|
||||
pubkey: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty pubkey returns false",
|
||||
pubkey: []byte{},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := policy.IsPolicyFollow(tt.pubkey)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsPolicyFollow() = %v, expected %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePolicyFollows tests the UpdatePolicyFollows method
|
||||
func TestUpdatePolicyFollows(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-update-follows")
|
||||
defer cleanup()
|
||||
|
||||
// Initially no follows
|
||||
testPubkey, _ := hex.Dec("1111111111111111111111111111111111111111111111111111111111111111")
|
||||
if policy.IsPolicyFollow(testPubkey) {
|
||||
t.Error("Expected no follows initially")
|
||||
}
|
||||
|
||||
// Add follows
|
||||
follows := [][]byte{testPubkey}
|
||||
policy.UpdatePolicyFollows(follows)
|
||||
|
||||
if !policy.IsPolicyFollow(testPubkey) {
|
||||
t.Error("Expected pubkey to be a follow after update")
|
||||
}
|
||||
|
||||
// Update with empty list
|
||||
policy.UpdatePolicyFollows([][]byte{})
|
||||
if policy.IsPolicyFollow(testPubkey) {
|
||||
t.Error("Expected pubkey to not be a follow after clearing")
|
||||
}
|
||||
|
||||
// Update with nil
|
||||
policy.UpdatePolicyFollows(nil)
|
||||
if policy.IsPolicyFollow(testPubkey) {
|
||||
t.Error("Expected pubkey to not be a follow after nil update")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPolicyFollowWhitelistEnabled tests the IsPolicyFollowWhitelistEnabled method
|
||||
func TestIsPolicyFollowWhitelistEnabled(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-whitelist-enabled")
|
||||
defer cleanup()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Test with disabled
|
||||
policyJSON := []byte(`{"policy_follow_whitelist_enabled": false}`)
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
if policy.IsPolicyFollowWhitelistEnabled() {
|
||||
t.Error("Expected follow whitelist to be disabled")
|
||||
}
|
||||
|
||||
// Test with enabled
|
||||
policyJSON = []byte(`{"policy_follow_whitelist_enabled": true}`)
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
if !policy.IsPolicyFollowWhitelistEnabled() {
|
||||
t.Error("Expected follow whitelist to be enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPolicyAdminsBin tests the GetPolicyAdminsBin method
|
||||
func TestGetPolicyAdminsBin(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-get-admins-bin")
|
||||
defer cleanup()
|
||||
|
||||
admin1Hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
admin2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
|
||||
policyJSON := []byte(`{
|
||||
"policy_admins": ["` + admin1Hex + `", "` + admin2Hex + `"]
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
admins := policy.GetPolicyAdminsBin()
|
||||
if len(admins) != 2 {
|
||||
t.Errorf("Expected 2 admins, got %d", len(admins))
|
||||
}
|
||||
|
||||
// Verify it's a copy (modification shouldn't affect original)
|
||||
if len(admins) > 0 {
|
||||
admins[0][0] = 0xFF
|
||||
originalAdmins := policy.GetPolicyAdminsBin()
|
||||
if originalAdmins[0][0] == 0xFF {
|
||||
t.Error("GetPolicyAdminsBin should return a copy, not the original slice")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFollowListConcurrency tests concurrent access to follow list
|
||||
func TestFollowListConcurrency(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-concurrency")
|
||||
defer cleanup()
|
||||
|
||||
testPubkey, _ := hex.Dec("1111111111111111111111111111111111111111111111111111111111111111")
|
||||
|
||||
// Run concurrent reads and writes
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
for j := 0; j < 100; j++ {
|
||||
policy.UpdatePolicyFollows([][]byte{testPubkey})
|
||||
_ = policy.IsPolicyFollow(testPubkey)
|
||||
_ = policy.IsPolicyAdmin(testPubkey)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// TestPolicyAdminAndFollowInteraction tests the interaction between admin and follow checks
|
||||
func TestPolicyAdminAndFollowInteraction(t *testing.T) {
|
||||
policy, cleanup := setupTestPolicy(t, "test-admin-follow-interaction")
|
||||
defer cleanup()
|
||||
|
||||
// An admin who is also followed
|
||||
adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
adminBin, _ := hex.Dec(adminHex)
|
||||
|
||||
policyJSON := []byte(`{
|
||||
"policy_admins": ["` + adminHex + `"],
|
||||
"policy_follow_whitelist_enabled": true
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
// Admin should be recognized as admin
|
||||
if !policy.IsPolicyAdmin(adminBin) {
|
||||
t.Error("Expected admin to be recognized as admin")
|
||||
}
|
||||
|
||||
// Admin is not automatically a follow
|
||||
if policy.IsPolicyFollow(adminBin) {
|
||||
t.Error("Admin should not automatically be a follow")
|
||||
}
|
||||
|
||||
// Now add admin as a follow
|
||||
policy.UpdatePolicyFollows([][]byte{adminBin})
|
||||
|
||||
// Should be both admin and follow
|
||||
if !policy.IsPolicyAdmin(adminBin) {
|
||||
t.Error("Expected admin to still be recognized as admin")
|
||||
}
|
||||
if !policy.IsPolicyFollow(adminBin) {
|
||||
t.Error("Expected admin to now be recognized as follow")
|
||||
}
|
||||
}
|
||||
403
pkg/policy/hotreload_test.go
Normal file
403
pkg/policy/hotreload_test.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
)
|
||||
|
||||
// setupHotreloadTestPolicy creates a policy manager with a temporary config file for hotreload tests.
|
||||
func setupHotreloadTestPolicy(t *testing.T, appName string) (*P, func()) {
|
||||
t.Helper()
|
||||
|
||||
configDir := filepath.Join(xdg.ConfigHome, appName)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "policy.json")
|
||||
defaultPolicy := []byte(`{"default_policy": "allow"}`)
|
||||
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
|
||||
t.Fatalf("Failed to write policy file: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
policy := NewWithManager(ctx, appName, true)
|
||||
if policy == nil {
|
||||
cancel()
|
||||
os.RemoveAll(configDir)
|
||||
t.Fatal("Failed to create policy manager")
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
cancel()
|
||||
os.RemoveAll(configDir)
|
||||
}
|
||||
|
||||
return policy, cleanup
|
||||
}
|
||||
|
||||
// TestValidateJSON tests the ValidateJSON method with various inputs
|
||||
func TestValidateJSON(t *testing.T) {
|
||||
policy, cleanup := setupHotreloadTestPolicy(t, "test-validate-json")
|
||||
defer cleanup()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
json []byte
|
||||
expectError bool
|
||||
errorSubstr string
|
||||
}{
|
||||
{
|
||||
name: "valid empty policy",
|
||||
json: []byte(`{}`),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid complete policy",
|
||||
json: []byte(`{
|
||||
"kind": {"whitelist": [1, 3, 7]},
|
||||
"global": {"size_limit": 65536},
|
||||
"rules": {
|
||||
"1": {"description": "Short text notes", "content_limit": 8192}
|
||||
},
|
||||
"default_policy": "allow",
|
||||
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
|
||||
"policy_follow_whitelist_enabled": true
|
||||
}`),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON syntax",
|
||||
json: []byte(`{"invalid": json}`),
|
||||
expectError: true,
|
||||
errorSubstr: "invalid character",
|
||||
},
|
||||
{
|
||||
name: "invalid JSON - missing closing brace",
|
||||
json: []byte(`{"kind": {"whitelist": [1]}`),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid policy_admins - wrong length",
|
||||
json: []byte(`{
|
||||
"policy_admins": ["not-64-chars"]
|
||||
}`),
|
||||
expectError: true,
|
||||
errorSubstr: "invalid policy_admin pubkey",
|
||||
},
|
||||
{
|
||||
name: "invalid policy_admins - non-hex characters",
|
||||
json: []byte(`{
|
||||
"policy_admins": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"]
|
||||
}`),
|
||||
expectError: true,
|
||||
errorSubstr: "invalid policy_admin pubkey",
|
||||
},
|
||||
{
|
||||
name: "valid policy_admins - multiple admins",
|
||||
json: []byte(`{
|
||||
"policy_admins": [
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
]
|
||||
}`),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tag_validation regex",
|
||||
json: []byte(`{
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"d": "[invalid(regex"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
expectError: true,
|
||||
errorSubstr: "invalid regex",
|
||||
},
|
||||
{
|
||||
name: "valid tag_validation regex",
|
||||
json: []byte(`{
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"d": "^[a-z0-9-]{1,64}$",
|
||||
"t": "^[a-z0-9-]{1,32}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid default_policy",
|
||||
json: []byte(`{
|
||||
"default_policy": "invalid"
|
||||
}`),
|
||||
expectError: true,
|
||||
errorSubstr: "default_policy",
|
||||
},
|
||||
{
|
||||
name: "valid default_policy allow",
|
||||
json: []byte(`{
|
||||
"default_policy": "allow"
|
||||
}`),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid default_policy deny",
|
||||
json: []byte(`{
|
||||
"default_policy": "deny"
|
||||
}`),
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := policy.ValidateJSON(tt.json)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
return
|
||||
}
|
||||
if tt.errorSubstr != "" && !containsSubstring(err.Error(), tt.errorSubstr) {
|
||||
t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReload tests the Reload method
|
||||
func TestReload(t *testing.T) {
|
||||
policy, cleanup := setupHotreloadTestPolicy(t, "test-reload")
|
||||
defer cleanup()
|
||||
|
||||
// Create temp directory for policy files
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "policy.json")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
initialJSON []byte
|
||||
reloadJSON []byte
|
||||
expectError bool
|
||||
checkAfter func(t *testing.T, p *P)
|
||||
}{
|
||||
{
|
||||
name: "reload with valid policy",
|
||||
initialJSON: []byte(`{"default_policy": "allow"}`),
|
||||
reloadJSON: []byte(`{
|
||||
"default_policy": "deny",
|
||||
"kind": {"whitelist": [1, 3]},
|
||||
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
|
||||
}`),
|
||||
expectError: false,
|
||||
checkAfter: func(t *testing.T, p *P) {
|
||||
if p.DefaultPolicy != "deny" {
|
||||
t.Errorf("Expected default_policy to be 'deny', got %q", p.DefaultPolicy)
|
||||
}
|
||||
if len(p.Kind.Whitelist) != 2 {
|
||||
t.Errorf("Expected 2 whitelisted kinds, got %d", len(p.Kind.Whitelist))
|
||||
}
|
||||
if len(p.PolicyAdmins) != 1 {
|
||||
t.Errorf("Expected 1 policy admin, got %d", len(p.PolicyAdmins))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reload with invalid JSON fails without changes",
|
||||
initialJSON: []byte(`{"default_policy": "allow"}`),
|
||||
reloadJSON: []byte(`{"invalid json`),
|
||||
expectError: true,
|
||||
checkAfter: func(t *testing.T, p *P) {
|
||||
// Policy should remain unchanged
|
||||
if p.DefaultPolicy != "allow" {
|
||||
t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reload with invalid admin pubkey fails without changes",
|
||||
initialJSON: []byte(`{"default_policy": "allow"}`),
|
||||
reloadJSON: []byte(`{
|
||||
"default_policy": "deny",
|
||||
"policy_admins": ["invalid-pubkey"]
|
||||
}`),
|
||||
expectError: true,
|
||||
checkAfter: func(t *testing.T, p *P) {
|
||||
// Policy should remain unchanged
|
||||
if p.DefaultPolicy != "allow" {
|
||||
t.Errorf("Expected default_policy to remain 'allow', got %q", p.DefaultPolicy)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Initialize policy with initial JSON
|
||||
if tt.initialJSON != nil {
|
||||
if err := policy.Reload(tt.initialJSON, configPath); err != nil {
|
||||
t.Fatalf("Failed to set initial policy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt reload
|
||||
err := policy.Reload(tt.reloadJSON, configPath)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run post-reload checks
|
||||
if tt.checkAfter != nil {
|
||||
tt.checkAfter(t, policy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveToFile tests atomic file writing
|
||||
func TestSaveToFile(t *testing.T) {
|
||||
policy, cleanup := setupHotreloadTestPolicy(t, "test-save-file")
|
||||
defer cleanup()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "policy.json")
|
||||
|
||||
// Load a policy
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"kind": {"whitelist": [1, 3, 7]},
|
||||
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"]
|
||||
}`)
|
||||
|
||||
if err := policy.Reload(policyJSON, configPath); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was saved
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
t.Errorf("Policy file was not created at %s", configPath)
|
||||
}
|
||||
|
||||
// Read and verify contents
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read policy file: %v", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
t.Error("Policy file is empty")
|
||||
}
|
||||
|
||||
// Verify it's valid JSON
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Errorf("Policy file contains invalid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPauseResume tests the Pause and Resume methods
|
||||
func TestPauseResume(t *testing.T) {
|
||||
policy, cleanup := setupHotreloadTestPolicy(t, "test-pause-resume")
|
||||
defer cleanup()
|
||||
|
||||
// Test Pause
|
||||
if err := policy.Pause(); err != nil {
|
||||
t.Errorf("Pause failed: %v", err)
|
||||
}
|
||||
|
||||
// Test Resume
|
||||
if err := policy.Resume(); err != nil {
|
||||
t.Errorf("Resume failed: %v", err)
|
||||
}
|
||||
|
||||
// Test multiple pause/resume cycles
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := policy.Pause(); err != nil {
|
||||
t.Errorf("Pause %d failed: %v", i, err)
|
||||
}
|
||||
if err := policy.Resume(); err != nil {
|
||||
t.Errorf("Resume %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReloadPreservesExistingOnFailure verifies that failed reloads don't corrupt state
|
||||
func TestReloadPreservesExistingOnFailure(t *testing.T) {
|
||||
policy, cleanup := setupHotreloadTestPolicy(t, "test-reload-preserve")
|
||||
defer cleanup()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "policy.json")
|
||||
|
||||
// Set up initial valid policy
|
||||
initialJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"kind": {"whitelist": [1, 3, 7]},
|
||||
"policy_admins": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"],
|
||||
"policy_follow_whitelist_enabled": true
|
||||
}`)
|
||||
|
||||
if err := policy.Reload(initialJSON, configPath); err != nil {
|
||||
t.Fatalf("Failed to set initial policy: %v", err)
|
||||
}
|
||||
|
||||
// Store initial state
|
||||
initialDefaultPolicy := policy.DefaultPolicy
|
||||
initialKindWhitelist := len(policy.Kind.Whitelist)
|
||||
initialAdminCount := len(policy.PolicyAdmins)
|
||||
initialFollowEnabled := policy.PolicyFollowWhitelistEnabled
|
||||
|
||||
// Attempt to reload with invalid JSON
|
||||
invalidJSON := []byte(`{"policy_admins": ["invalid"]}`)
|
||||
err := policy.Reload(invalidJSON, configPath)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid policy_admins but got none")
|
||||
}
|
||||
|
||||
// Verify state is preserved
|
||||
if policy.DefaultPolicy != initialDefaultPolicy {
|
||||
t.Errorf("DefaultPolicy changed from %q to %q after failed reload",
|
||||
initialDefaultPolicy, policy.DefaultPolicy)
|
||||
}
|
||||
if len(policy.Kind.Whitelist) != initialKindWhitelist {
|
||||
t.Errorf("Kind.Whitelist length changed from %d to %d after failed reload",
|
||||
initialKindWhitelist, len(policy.Kind.Whitelist))
|
||||
}
|
||||
if len(policy.PolicyAdmins) != initialAdminCount {
|
||||
t.Errorf("PolicyAdmins length changed from %d to %d after failed reload",
|
||||
initialAdminCount, len(policy.PolicyAdmins))
|
||||
}
|
||||
if policy.PolicyFollowWhitelistEnabled != initialFollowEnabled {
|
||||
t.Errorf("PolicyFollowWhitelistEnabled changed from %v to %v after failed reload",
|
||||
initialFollowEnabled, policy.PolicyFollowWhitelistEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// containsSubstring checks if a string contains a substring (case-insensitive)
|
||||
func containsSubstring(s, substr string) bool {
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -72,6 +73,15 @@ type Rule struct {
|
||||
// MaxAgeEventInFuture is the offset in seconds that is the newest timestamp allowed for an event's created_at time ahead of the current time.
|
||||
MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"`
|
||||
|
||||
// WriteAllowFollows grants BOTH read and write access to policy admin follows when enabled.
|
||||
// Requires PolicyFollowWhitelistEnabled=true at the policy level.
|
||||
WriteAllowFollows bool `json:"write_allow_follows,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}$"}
|
||||
TagValidation map[string]string `json:"tag_validation,omitempty"`
|
||||
|
||||
// Binary caches for faster comparison (populated from hex strings above)
|
||||
// These are not exported and not serialized to JSON
|
||||
writeAllowBin [][]byte
|
||||
@@ -90,7 +100,8 @@ func (r *Rule) hasAnyRules() bool {
|
||||
r.SizeLimit != nil || r.ContentLimit != nil ||
|
||||
r.MaxAgeOfEvent != nil || r.MaxAgeEventInFuture != nil ||
|
||||
r.MaxExpiry != nil || len(r.MustHaveTags) > 0 ||
|
||||
r.Script != "" || r.Privileged
|
||||
r.Script != "" || r.Privileged ||
|
||||
r.WriteAllowFollows || len(r.TagValidation) > 0
|
||||
}
|
||||
|
||||
// populateBinaryCache converts hex-encoded pubkey strings to binary for faster comparison.
|
||||
@@ -253,6 +264,19 @@ type P struct {
|
||||
Global Rule `json:"global"`
|
||||
// DefaultPolicy determines the default behavior when no rules deny an event ("allow" or "deny", defaults to "allow")
|
||||
DefaultPolicy string `json:"default_policy"`
|
||||
|
||||
// PolicyAdmins is a list of hex-encoded pubkeys that can update policy configuration via kind 12345 events.
|
||||
// These are SEPARATE from ACL relay admins - policy admins manage policy only.
|
||||
PolicyAdmins []string `json:"policy_admins,omitempty"`
|
||||
// PolicyFollowWhitelistEnabled enables automatic whitelisting of pubkeys followed by policy admins.
|
||||
// When true and a rule has WriteAllowFollows=true, policy admin follows get read+write access.
|
||||
PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"`
|
||||
|
||||
// Unexported binary caches for faster comparison (populated from hex strings above)
|
||||
policyAdminsBin [][]byte // Binary cache for policy admin pubkeys
|
||||
policyFollows [][]byte // Cached follow list from policy admins (kind 3 events)
|
||||
policyFollowsMx sync.RWMutex // Protect follows list access
|
||||
|
||||
// manager handles policy script execution.
|
||||
// Unexported to enforce use of public API methods (CheckPolicy, IsEnabled).
|
||||
manager *PolicyManager
|
||||
@@ -260,10 +284,12 @@ type P struct {
|
||||
|
||||
// pJSON is a shadow struct for JSON unmarshalling with exported fields.
|
||||
type pJSON struct {
|
||||
Kind Kinds `json:"kind"`
|
||||
Rules map[int]Rule `json:"rules"`
|
||||
Global Rule `json:"global"`
|
||||
DefaultPolicy string `json:"default_policy"`
|
||||
Kind Kinds `json:"kind"`
|
||||
Rules map[int]Rule `json:"rules"`
|
||||
Global Rule `json:"global"`
|
||||
DefaultPolicy string `json:"default_policy"`
|
||||
PolicyAdmins []string `json:"policy_admins,omitempty"`
|
||||
PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements custom JSON unmarshalling to handle unexported fields.
|
||||
@@ -276,6 +302,22 @@ func (p *P) UnmarshalJSON(data []byte) error {
|
||||
p.rules = shadow.Rules
|
||||
p.Global = shadow.Global
|
||||
p.DefaultPolicy = shadow.DefaultPolicy
|
||||
p.PolicyAdmins = shadow.PolicyAdmins
|
||||
p.PolicyFollowWhitelistEnabled = shadow.PolicyFollowWhitelistEnabled
|
||||
|
||||
// Populate binary cache for policy admins
|
||||
if len(p.PolicyAdmins) > 0 {
|
||||
p.policyAdminsBin = make([][]byte, 0, len(p.PolicyAdmins))
|
||||
for _, hexPubkey := range p.PolicyAdmins {
|
||||
binPubkey, err := hex.Dec(hexPubkey)
|
||||
if err != nil {
|
||||
log.W.F("failed to decode PolicyAdmin pubkey %q: %v", hexPubkey, err)
|
||||
continue
|
||||
}
|
||||
p.policyAdminsBin = append(p.policyAdminsBin, binPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1117,6 +1159,38 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// Check tag validation rules (regex patterns)
|
||||
// Only apply for write access - we validate what goes in, not what comes out
|
||||
if access == "write" && len(rule.TagValidation) > 0 {
|
||||
for tagName, regexPattern := range rule.TagValidation {
|
||||
// Compile regex pattern (errors should have been caught in ValidateJSON)
|
||||
regex, compileErr := regexp.Compile(regexPattern)
|
||||
if compileErr != nil {
|
||||
log.E.F("invalid regex pattern for tag %q: %v (skipping validation)", tagName, compileErr)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get all tags with this name
|
||||
tags := ev.Tags.GetAll([]byte(tagName))
|
||||
|
||||
// If no tags found and rule requires this tag, validation fails
|
||||
if len(tags) == 0 {
|
||||
log.D.F("tag validation failed: required tag %q not found", tagName)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Validate each tag value against regex
|
||||
for _, t := range tags {
|
||||
value := string(t.Value())
|
||||
if !regex.MatchString(value) {
|
||||
log.D.F("tag validation failed: tag %q value %q does not match pattern %q",
|
||||
tagName, value, regexPattern)
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STEP 2: Explicit Denials (highest priority blacklist)
|
||||
// ===================================================================
|
||||
@@ -1157,6 +1231,19 @@ func (p *P) checkRulePolicy(
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STEP 2.5: Write Allow Follows (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
|
||||
if rule.WriteAllowFollows && p.PolicyFollowWhitelistEnabled {
|
||||
if p.IsPolicyFollow(loggedInPubkey) {
|
||||
log.D.F("policy admin follow granted %s access for kind %d", access, ev.Kind)
|
||||
return true, nil // Allow access from policy admin follow
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// STEP 3: Check Read Access with OR Logic (Allow List OR Privileged)
|
||||
// ===================================================================
|
||||
@@ -1447,3 +1534,272 @@ func (pm *PolicyManager) Shutdown() {
|
||||
// Clear runners map
|
||||
pm.runners = make(map[string]*ScriptRunner)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Policy Hot Reload Methods
|
||||
// =============================================================================
|
||||
|
||||
// ValidateJSON validates policy JSON without applying changes.
|
||||
// This is called BEFORE any modifications to ensure JSON is valid.
|
||||
// Returns error if validation fails - no changes are made to current policy.
|
||||
func (p *P) ValidateJSON(policyJSON []byte) error {
|
||||
// Try to unmarshal into a temporary policy struct
|
||||
tempPolicy := &P{}
|
||||
if err := json.Unmarshal(policyJSON, tempPolicy); err != nil {
|
||||
return fmt.Errorf("invalid JSON syntax: %v", err)
|
||||
}
|
||||
|
||||
// Validate policy_admins are valid hex pubkeys (64 characters)
|
||||
for _, admin := range tempPolicy.PolicyAdmins {
|
||||
if len(admin) != 64 {
|
||||
return fmt.Errorf("invalid policy_admin pubkey length: %q (expected 64 hex characters)", admin)
|
||||
}
|
||||
if _, err := hex.Dec(admin); err != nil {
|
||||
return fmt.Errorf("invalid policy_admin pubkey format: %q: %v", admin, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate regex patterns in tag_validation rules
|
||||
for kind, rule := range tempPolicy.rules {
|
||||
for tagName, pattern := range rule.TagValidation {
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf("invalid regex pattern for tag %q in kind %d: %v", tagName, kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate global rule tag_validation patterns
|
||||
for tagName, pattern := range tempPolicy.Global.TagValidation {
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf("invalid regex pattern for tag %q in global rule: %v", tagName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate default_policy value
|
||||
if tempPolicy.DefaultPolicy != "" && tempPolicy.DefaultPolicy != "allow" && tempPolicy.DefaultPolicy != "deny" {
|
||||
return fmt.Errorf("invalid default_policy value: %q (must be \"allow\" or \"deny\")", tempPolicy.DefaultPolicy)
|
||||
}
|
||||
|
||||
log.D.F("policy JSON validation passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload loads policy from JSON bytes and applies it to the existing policy instance.
|
||||
// This validates JSON FIRST, then pauses the policy manager, updates configuration, and resumes.
|
||||
// Returns error if validation fails - no changes are made on validation failure.
|
||||
func (p *P) Reload(policyJSON []byte, configPath string) error {
|
||||
// Step 1: Validate JSON FIRST (before making any changes)
|
||||
if err := p.ValidateJSON(policyJSON); err != nil {
|
||||
return fmt.Errorf("validation failed: %v", err)
|
||||
}
|
||||
|
||||
// Step 2: Pause policy manager (stop script runners)
|
||||
if err := p.Pause(); err != nil {
|
||||
log.W.F("failed to pause policy manager (continuing anyway): %v", err)
|
||||
}
|
||||
|
||||
// Step 3: Unmarshal JSON into a temporary struct
|
||||
tempPolicy := &P{}
|
||||
if err := json.Unmarshal(policyJSON, tempPolicy); err != nil {
|
||||
// Resume before returning error
|
||||
p.Resume()
|
||||
return fmt.Errorf("failed to unmarshal policy JSON: %v", err)
|
||||
}
|
||||
|
||||
// Step 4: Apply the new configuration (preserve manager reference)
|
||||
p.policyFollowsMx.Lock()
|
||||
p.Kind = tempPolicy.Kind
|
||||
p.rules = tempPolicy.rules
|
||||
p.Global = tempPolicy.Global
|
||||
p.DefaultPolicy = tempPolicy.DefaultPolicy
|
||||
p.PolicyAdmins = tempPolicy.PolicyAdmins
|
||||
p.PolicyFollowWhitelistEnabled = tempPolicy.PolicyFollowWhitelistEnabled
|
||||
p.policyAdminsBin = tempPolicy.policyAdminsBin
|
||||
// Note: policyFollows is NOT reset here - it will be refreshed separately
|
||||
p.policyFollowsMx.Unlock()
|
||||
|
||||
// Step 5: Populate binary caches for all rules
|
||||
p.Global.populateBinaryCache()
|
||||
for kind := range p.rules {
|
||||
rule := p.rules[kind]
|
||||
rule.populateBinaryCache()
|
||||
p.rules[kind] = rule
|
||||
}
|
||||
|
||||
// Step 6: Save to file (atomic write)
|
||||
if err := p.SaveToFile(configPath); err != nil {
|
||||
log.E.F("failed to persist policy to disk: %v (policy was updated in memory)", err)
|
||||
// Continue anyway - policy is loaded in memory
|
||||
}
|
||||
|
||||
// Step 7: Resume policy manager (restart script runners)
|
||||
if err := p.Resume(); err != nil {
|
||||
log.W.F("failed to resume policy manager: %v", err)
|
||||
}
|
||||
|
||||
log.I.F("policy configuration reloaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pause pauses the policy manager and stops all script runners.
|
||||
func (p *P) Pause() error {
|
||||
if p.manager == nil {
|
||||
return fmt.Errorf("policy manager is not initialized")
|
||||
}
|
||||
|
||||
p.manager.mutex.Lock()
|
||||
defer p.manager.mutex.Unlock()
|
||||
|
||||
// Stop all running scripts
|
||||
for path, runner := range p.manager.runners {
|
||||
if runner.IsRunning() {
|
||||
log.I.F("pausing policy script: %s", path)
|
||||
if err := runner.Stop(); err != nil {
|
||||
log.W.F("failed to stop runner %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.I.F("policy manager paused")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resume resumes the policy manager and restarts script runners.
|
||||
func (p *P) Resume() error {
|
||||
if p.manager == nil {
|
||||
return fmt.Errorf("policy manager is not initialized")
|
||||
}
|
||||
|
||||
// Restart the default policy script if it exists
|
||||
go p.manager.startPolicyIfExists()
|
||||
|
||||
// Restart rule-specific scripts
|
||||
for _, rule := range p.rules {
|
||||
if rule.Script != "" {
|
||||
if _, err := os.Stat(rule.Script); err == nil {
|
||||
runner := p.manager.getOrCreateRunner(rule.Script)
|
||||
go func(r *ScriptRunner, script string) {
|
||||
if err := r.Start(); err != nil {
|
||||
log.W.F("failed to restart policy script %s: %v", script, err)
|
||||
}
|
||||
}(runner, rule.Script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.I.F("policy manager resumed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveToFile persists the current policy configuration to disk using atomic write.
|
||||
// Uses temp file + rename pattern to ensure atomic writes.
|
||||
func (p *P) SaveToFile(configPath string) error {
|
||||
// Create shadow struct for JSON marshalling
|
||||
shadow := pJSON{
|
||||
Kind: p.Kind,
|
||||
Rules: p.rules,
|
||||
Global: p.Global,
|
||||
DefaultPolicy: p.DefaultPolicy,
|
||||
PolicyAdmins: p.PolicyAdmins,
|
||||
PolicyFollowWhitelistEnabled: p.PolicyFollowWhitelistEnabled,
|
||||
}
|
||||
|
||||
// Marshal to JSON with indentation for readability
|
||||
jsonData, err := json.MarshalIndent(shadow, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal policy to JSON: %v", err)
|
||||
}
|
||||
|
||||
// Write to temp file first (atomic write pattern)
|
||||
tempPath := configPath + ".tmp"
|
||||
if err := os.WriteFile(tempPath, jsonData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %v", err)
|
||||
}
|
||||
|
||||
// Rename temp file to actual config file (atomic on most filesystems)
|
||||
if err := os.Rename(tempPath, configPath); err != nil {
|
||||
// Clean up temp file on failure
|
||||
os.Remove(tempPath)
|
||||
return fmt.Errorf("failed to rename temp file: %v", err)
|
||||
}
|
||||
|
||||
log.I.F("policy configuration saved to %s", configPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Policy Admin and Follow Checking Methods
|
||||
// =============================================================================
|
||||
|
||||
// IsPolicyAdmin checks if the given pubkey is in the policy_admins list.
|
||||
// The pubkey parameter should be binary ([]byte), not hex-encoded.
|
||||
func (p *P) IsPolicyAdmin(pubkey []byte) bool {
|
||||
if len(pubkey) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
p.policyFollowsMx.RLock()
|
||||
defer p.policyFollowsMx.RUnlock()
|
||||
|
||||
for _, admin := range p.policyAdminsBin {
|
||||
if utils.FastEqual(admin, pubkey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPolicyFollow checks if the given pubkey is in the policy admin follows list.
|
||||
// The pubkey parameter should be binary ([]byte), not hex-encoded.
|
||||
func (p *P) IsPolicyFollow(pubkey []byte) bool {
|
||||
if len(pubkey) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
p.policyFollowsMx.RLock()
|
||||
defer p.policyFollowsMx.RUnlock()
|
||||
|
||||
for _, follow := range p.policyFollows {
|
||||
if utils.FastEqual(pubkey, follow) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdatePolicyFollows replaces the policy follows list with a new set of pubkeys.
|
||||
// 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.policyFollows = follows
|
||||
log.I.F("policy follows list updated with %d pubkeys", len(follows))
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
// Return a copy to prevent external modification
|
||||
result := make([][]byte, len(p.policyAdminsBin))
|
||||
for i, admin := range p.policyAdminsBin {
|
||||
adminCopy := make([]byte, len(admin))
|
||||
copy(adminCopy, admin)
|
||||
result[i] = adminCopy
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsPolicyFollowWhitelistEnabled returns whether the policy follow whitelist feature is enabled.
|
||||
// When enabled, pubkeys followed by policy admins are automatically whitelisted for access
|
||||
// when rules have WriteAllowFollows=true.
|
||||
func (p *P) IsPolicyFollowWhitelistEnabled() bool {
|
||||
if p == nil {
|
||||
return false
|
||||
}
|
||||
return p.PolicyFollowWhitelistEnabled
|
||||
}
|
||||
|
||||
481
pkg/policy/tag_validation_test.go
Normal file
481
pkg/policy/tag_validation_test.go
Normal file
@@ -0,0 +1,481 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"git.mleku.dev/mleku/nostr/encoders/event"
|
||||
"git.mleku.dev/mleku/nostr/encoders/tag"
|
||||
"git.mleku.dev/mleku/nostr/interfaces/signer/p8k"
|
||||
"lol.mleku.dev/chk"
|
||||
)
|
||||
|
||||
// setupTagValidationTestPolicy creates a policy manager with a temporary config file for tag validation tests.
|
||||
func setupTagValidationTestPolicy(t *testing.T, appName string) (*P, func()) {
|
||||
t.Helper()
|
||||
|
||||
configDir := filepath.Join(xdg.ConfigHome, appName)
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create config dir: %v", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "policy.json")
|
||||
defaultPolicy := []byte(`{"default_policy": "allow"}`)
|
||||
if err := os.WriteFile(configPath, defaultPolicy, 0644); err != nil {
|
||||
t.Fatalf("Failed to write policy file: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
|
||||
policy := NewWithManager(ctx, appName, true)
|
||||
if policy == nil {
|
||||
cancel()
|
||||
os.RemoveAll(configDir)
|
||||
t.Fatal("Failed to create policy manager")
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
cancel()
|
||||
os.RemoveAll(configDir)
|
||||
}
|
||||
|
||||
return policy, cleanup
|
||||
}
|
||||
|
||||
// createSignedTestEvent creates a signed event for testing
|
||||
func createSignedTestEvent(t *testing.T, kind uint16, content string) (*event.E, *p8k.Signer) {
|
||||
signer := p8k.MustNew()
|
||||
if err := signer.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate keypair: %v", err)
|
||||
}
|
||||
|
||||
ev := event.New()
|
||||
ev.CreatedAt = time.Now().Unix()
|
||||
ev.Kind = kind
|
||||
ev.Content = []byte(content)
|
||||
ev.Tags = tag.NewS()
|
||||
|
||||
if err := ev.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
return ev, signer
|
||||
}
|
||||
|
||||
// addTagToEvent adds a tag to an event
|
||||
func addTagToEvent(ev *event.E, key, value string) {
|
||||
tagItem := tag.NewFromAny(key, value)
|
||||
ev.Tags.Append(tagItem)
|
||||
}
|
||||
|
||||
// TestTagValidationBasic tests basic tag validation with regex patterns
|
||||
func TestTagValidationBasic(t *testing.T) {
|
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-basic")
|
||||
defer cleanup()
|
||||
|
||||
// Policy with tag validation for kind 30023 (long-form content)
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"rules": {
|
||||
"30023": {
|
||||
"description": "Long-form content with tag validation",
|
||||
"tag_validation": {
|
||||
"d": "^[a-z0-9-]{1,64}$",
|
||||
"t": "^[a-z0-9-]{1,32}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
kind uint16
|
||||
tags map[string]string
|
||||
expectAllow bool
|
||||
}{
|
||||
{
|
||||
name: "valid d tag",
|
||||
kind: 30023,
|
||||
tags: map[string]string{
|
||||
"d": "my-article-slug",
|
||||
},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "valid d and t tags",
|
||||
kind: 30023,
|
||||
tags: map[string]string{
|
||||
"d": "my-article-slug",
|
||||
"t": "nostr",
|
||||
},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "invalid d tag - contains uppercase",
|
||||
kind: 30023,
|
||||
tags: map[string]string{
|
||||
"d": "My-Article-Slug",
|
||||
},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "invalid d tag - contains spaces",
|
||||
kind: 30023,
|
||||
tags: map[string]string{
|
||||
"d": "my article slug",
|
||||
},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "invalid d tag - too long",
|
||||
kind: 30023,
|
||||
tags: map[string]string{
|
||||
"d": "this-is-a-very-long-slug-that-exceeds-the-sixty-four-character-limit-set-in-policy",
|
||||
},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "invalid t tag - contains special chars",
|
||||
kind: 30023,
|
||||
tags: map[string]string{
|
||||
"d": "valid-slug",
|
||||
"t": "nostr@tag",
|
||||
},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "kind without tag validation - any tags allowed",
|
||||
kind: 1, // Kind 1 has no tag validation rules
|
||||
tags: map[string]string{
|
||||
"d": "ANYTHING_GOES!!!",
|
||||
"t": "spaces and Special Chars",
|
||||
},
|
||||
expectAllow: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev, signer := createSignedTestEvent(t, tt.kind, "test content")
|
||||
|
||||
// Add tags to event
|
||||
for key, value := range tt.tags {
|
||||
addTagToEvent(ev, key, value)
|
||||
}
|
||||
|
||||
// Re-sign after adding tags
|
||||
if err := ev.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to re-sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
if allowed != tt.expectAllow {
|
||||
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTagValidationMultipleSameTag tests validation when multiple tags have the same name
|
||||
func TestTagValidationMultipleSameTag(t *testing.T) {
|
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-multi")
|
||||
defer cleanup()
|
||||
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"t": "^[a-z0-9-]+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tags []string // Multiple t tags
|
||||
expectAllow bool
|
||||
}{
|
||||
{
|
||||
name: "all tags valid",
|
||||
tags: []string{"nostr", "bitcoin", "lightning"},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "one invalid tag among valid ones",
|
||||
tags: []string{"nostr", "INVALID", "lightning"},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "first tag invalid",
|
||||
tags: []string{"INVALID", "nostr", "bitcoin"},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "last tag invalid",
|
||||
tags: []string{"nostr", "bitcoin", "INVALID"},
|
||||
expectAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev, signer := createSignedTestEvent(t, 30023, "test content")
|
||||
|
||||
// Add multiple t tags
|
||||
for _, value := range tt.tags {
|
||||
addTagToEvent(ev, "t", value)
|
||||
}
|
||||
|
||||
// Re-sign
|
||||
if err := ev.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to re-sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
if allowed != tt.expectAllow {
|
||||
t.Errorf("CheckPolicy() = %v, expected %v", allowed, tt.expectAllow)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTagValidationInvalidRegex tests that invalid regex patterns are caught during validation
|
||||
func TestTagValidationInvalidRegex(t *testing.T) {
|
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-invalid-regex")
|
||||
defer cleanup()
|
||||
|
||||
invalidRegexPolicies := []struct {
|
||||
name string
|
||||
policy []byte
|
||||
}{
|
||||
{
|
||||
name: "unclosed bracket",
|
||||
policy: []byte(`{
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"d": "[invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
{
|
||||
name: "unclosed parenthesis",
|
||||
policy: []byte(`{
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"d": "(unclosed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
{
|
||||
name: "invalid escape sequence",
|
||||
policy: []byte(`{
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"d": "\\k"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range invalidRegexPolicies {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := policy.ValidateJSON(tt.policy)
|
||||
if err == nil {
|
||||
t.Error("Expected validation error for invalid regex, got none")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTagValidationEmptyTag tests behavior when a tag has no value
|
||||
func TestTagValidationEmptyTag(t *testing.T) {
|
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-empty")
|
||||
defer cleanup()
|
||||
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"rules": {
|
||||
"30023": {
|
||||
"tag_validation": {
|
||||
"d": "^[a-z0-9-]+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
// Create event with empty d tag value
|
||||
ev, signer := createSignedTestEvent(t, 30023, "test content")
|
||||
addTagToEvent(ev, "d", "")
|
||||
if err := ev.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
// Empty string doesn't match ^[a-z0-9-]+$ (+ requires at least one char)
|
||||
if allowed {
|
||||
t.Error("Expected empty tag value to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTagValidationWithWriteAllowFollows tests interaction between tag validation and follow whitelist
|
||||
func TestTagValidationWithWriteAllowFollows(t *testing.T) {
|
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-follows")
|
||||
defer cleanup()
|
||||
|
||||
// Create a test signer who will be a "follow"
|
||||
signer := p8k.MustNew()
|
||||
if err := signer.Generate(); chk.E(err) {
|
||||
t.Fatalf("Failed to generate keypair: %v", err)
|
||||
}
|
||||
|
||||
// Set up policy with tag validation AND write_allow_follows
|
||||
adminHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "deny",
|
||||
"policy_admins": ["` + adminHex + `"],
|
||||
"policy_follow_whitelist_enabled": true,
|
||||
"rules": {
|
||||
"30023": {
|
||||
"write_allow_follows": true,
|
||||
"tag_validation": {
|
||||
"d": "^[a-z0-9-]+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
// Add the signer as a follow
|
||||
policy.UpdatePolicyFollows([][]byte{signer.Pub()})
|
||||
|
||||
// Test: Follow with valid tag should be allowed
|
||||
ev := event.New()
|
||||
ev.CreatedAt = time.Now().Unix()
|
||||
ev.Kind = 30023
|
||||
ev.Content = []byte("test content")
|
||||
ev.Tags = tag.NewS()
|
||||
addTagToEvent(ev, "d", "valid-slug")
|
||||
if err := ev.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed, err := policy.CheckPolicy("write", ev, signer.Pub(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
t.Error("Expected follow with valid tag to be allowed")
|
||||
}
|
||||
|
||||
// Test: Follow with invalid tag should still be rejected (tag validation applies)
|
||||
ev2 := event.New()
|
||||
ev2.CreatedAt = time.Now().Unix()
|
||||
ev2.Kind = 30023
|
||||
ev2.Content = []byte("test content")
|
||||
ev2.Tags = tag.NewS()
|
||||
addTagToEvent(ev2, "d", "INVALID_SLUG")
|
||||
if err := ev2.Sign(signer); chk.E(err) {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed2, err := policy.CheckPolicy("write", ev2, signer.Pub(), "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckPolicy returned error: %v", err)
|
||||
}
|
||||
|
||||
if allowed2 {
|
||||
t.Error("Expected follow with invalid tag to be rejected (tag validation should still apply)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTagValidationGlobalRule tests tag validation in global rules
|
||||
func TestTagValidationGlobalRule(t *testing.T) {
|
||||
policy, cleanup := setupTagValidationTestPolicy(t, "test-tag-global")
|
||||
defer cleanup()
|
||||
|
||||
// Policy with global tag validation (applies to all kinds)
|
||||
policyJSON := []byte(`{
|
||||
"default_policy": "allow",
|
||||
"global": {
|
||||
"tag_validation": {
|
||||
"e": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
if err := policy.Reload(policyJSON, tmpDir+"/policy.json"); err != nil {
|
||||
t.Fatalf("Failed to reload policy: %v", err)
|
||||
}
|
||||
|
||||
// Valid e tag (64 hex chars)
|
||||
ev1, signer1 := createSignedTestEvent(t, 1, "test")
|
||||
addTagToEvent(ev1, "e", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
if err := ev1.Sign(signer1); chk.E(err) {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed1, _ := policy.CheckPolicy("write", ev1, signer1.Pub(), "127.0.0.1")
|
||||
if !allowed1 {
|
||||
t.Error("Expected valid e tag to be allowed")
|
||||
}
|
||||
|
||||
// Invalid e tag (not 64 hex chars)
|
||||
ev2, signer2 := createSignedTestEvent(t, 1, "test")
|
||||
addTagToEvent(ev2, "e", "not-a-valid-event-id")
|
||||
if err := ev2.Sign(signer2); chk.E(err) {
|
||||
t.Fatalf("Failed to sign event: %v", err)
|
||||
}
|
||||
|
||||
allowed2, _ := policy.CheckPolicy("write", ev2, signer2.Pub(), "127.0.0.1")
|
||||
if allowed2 {
|
||||
t.Error("Expected invalid e tag to be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user