From 5631c162d98ef1d0e3b96a620865ab6c9639d773 Mon Sep 17 00:00:00 2001 From: mleku Date: Mon, 1 Dec 2025 21:39:28 +0000 Subject: [PATCH] Add default security configuration and policy recipes Introduced default security settings with stricter access control, including policies requiring owner/admin privileges by default. Added multiple pre-configured policy recipes, custom validator support, and extended documentation for security, configurations, and use cases. --- app/main.go | 21 +++++++ docs/POLICY_USAGE_GUIDE.md | 43 ++++++++++++++- docs/example-policy.json | 3 + pkg/policy/hotreload_test.go | 104 +++++++++++++++++++++++++++++++++++ pkg/policy/policy.go | 64 +++++++++++++++++++++ 5 files changed, 234 insertions(+), 1 deletion(-) diff --git a/app/main.go b/app/main.go index 04260e7..498f3c5 100644 --- a/app/main.go +++ b/app/main.go @@ -85,6 +85,27 @@ func Run( // Initialize policy manager l.policyManager = policy.NewWithManager(ctx, cfg.AppName, cfg.PolicyEnabled) + // Merge policy-defined owners with environment-defined owners + // This allows cloud deployments to add owners via policy.json when env vars cannot be modified + if l.policyManager != nil { + policyOwners := l.policyManager.GetOwnersBin() + if len(policyOwners) > 0 { + // Deduplicate when merging + existingOwners := make(map[string]struct{}) + for _, owner := range l.Owners { + existingOwners[string(owner)] = struct{}{} + } + for _, policyOwner := range policyOwners { + if _, exists := existingOwners[string(policyOwner)]; !exists { + l.Owners = append(l.Owners, policyOwner) + existingOwners[string(policyOwner)] = struct{}{} + } + } + log.I.F("merged %d policy-defined owners with %d environment-defined owners (total: %d unique owners)", + len(policyOwners), len(ownerKeys), len(l.Owners)) + } + } + // Initialize policy follows from database (load follow lists of policy admins) // This must be done after policy manager initialization but before accepting connections if err := l.InitializePolicyFollows(); err != nil { diff --git a/docs/POLICY_USAGE_GUIDE.md b/docs/POLICY_USAGE_GUIDE.md index c52c22f..9ff298e 100644 --- a/docs/POLICY_USAGE_GUIDE.md +++ b/docs/POLICY_USAGE_GUIDE.md @@ -64,7 +64,10 @@ sudo systemctl restart orly "blacklist": [] }, "global": { ... }, - "rules": { ... } + "rules": { ... }, + "owners": ["hex_pubkey_1", "hex_pubkey_2"], + "policy_admins": ["hex_pubkey_1", "hex_pubkey_2"], + "policy_follow_whitelist_enabled": true } ``` @@ -90,6 +93,44 @@ Controls which event kinds are processed: - `blacklist`: These kinds are denied (if present) - Empty arrays allow all kinds +### owners + +Specifies relay owners via the policy configuration file. This is particularly useful for **cloud deployments** where environment variables cannot be modified at runtime. + +```json +{ + "owners": [ + "4a93c5ac0c6f49d2c7e7a5b8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8", + "5b84d6bd1d7e5a3d8e8b6c9e0f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0" + ] +} +``` + +**Key points:** +- Pubkeys must be in **hex format** (64 characters), not npub format +- Policy-defined owners are **merged** with environment-defined owners (`ORLY_OWNERS`) +- Duplicate pubkeys are automatically deduplicated during merge +- Owners have full control of the relay (delete any events, restart, wipe, etc.) + +**Example use case - Cloud deployment:** + +When deploying to a cloud platform where you cannot set environment variables: + +1. Create `~/.config/ORLY/policy.json`: +```json +{ + "default_policy": "allow", + "owners": ["your_hex_pubkey_here"] +} +``` + +2. Enable the policy system: +```bash +export ORLY_POLICY_ENABLED=true +``` + +The relay will recognize your pubkey as an owner, granting full administrative access. + ### Global Rules Rules that apply to **all events** regardless of kind: diff --git a/docs/example-policy.json b/docs/example-policy.json index 8f67fb2..16c7148 100644 --- a/docs/example-policy.json +++ b/docs/example-policy.json @@ -1,5 +1,8 @@ { "default_policy": "allow", + "owners": [], + "policy_admins": [], + "policy_follow_whitelist_enabled": false, "kind": { "whitelist": [0, 1, 3, 4, 5, 6, 7, 40, 41, 42, 43, 44, 9735], "blacklist": [] diff --git a/pkg/policy/hotreload_test.go b/pkg/policy/hotreload_test.go index cfdfe39..d55011c 100644 --- a/pkg/policy/hotreload_test.go +++ b/pkg/policy/hotreload_test.go @@ -161,6 +161,48 @@ func TestValidateJSON(t *testing.T) { }`), expectError: false, }, + { + name: "valid owners - single owner", + json: []byte(`{ + "owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"] + }`), + expectError: false, + }, + { + name: "valid owners - multiple owners", + json: []byte(`{ + "owners": [ + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + ] + }`), + expectError: false, + }, + { + name: "invalid owners - wrong length", + json: []byte(`{ + "owners": ["not-64-chars"] + }`), + expectError: true, + errorSubstr: "invalid owner pubkey", + }, + { + name: "invalid owners - non-hex characters", + json: []byte(`{ + "owners": ["zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"] + }`), + expectError: true, + errorSubstr: "invalid owner pubkey", + }, + { + name: "valid policy with both owners and policy_admins", + json: []byte(`{ + "owners": ["0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"], + "policy_admins": ["fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"], + "policy_follow_whitelist_enabled": true + }`), + expectError: false, + }, } for _, tt := range tests { @@ -401,3 +443,65 @@ func TestReloadPreservesExistingOnFailure(t *testing.T) { func containsSubstring(s, substr string) bool { return strings.Contains(strings.ToLower(s), strings.ToLower(substr)) } + +// TestGetOwnersBin tests the GetOwnersBin method for policy-defined owners +func TestGetOwnersBin(t *testing.T) { + policy, cleanup := setupHotreloadTestPolicy(t, "test-get-owners-bin") + defer cleanup() + + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "policy.json") + + // Test 1: Policy with no owners + emptyJSON := []byte(`{"default_policy": "allow"}`) + if err := policy.Reload(emptyJSON, configPath); err != nil { + t.Fatalf("Failed to reload policy: %v", err) + } + + owners := policy.GetOwnersBin() + if len(owners) != 0 { + t.Errorf("Expected 0 owners, got %d", len(owners)) + } + + // Test 2: Policy with owners + ownerHex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + withOwnersJSON := []byte(`{ + "default_policy": "allow", + "owners": ["` + ownerHex + `"] + }`) + if err := policy.Reload(withOwnersJSON, configPath); err != nil { + t.Fatalf("Failed to reload policy with owners: %v", err) + } + + owners = policy.GetOwnersBin() + if len(owners) != 1 { + t.Errorf("Expected 1 owner, got %d", len(owners)) + } + if len(owners) > 0 && len(owners[0]) != 32 { + t.Errorf("Expected owner binary to be 32 bytes, got %d", len(owners[0])) + } + + // Test 3: GetOwners returns hex strings + hexOwners := policy.GetOwners() + if len(hexOwners) != 1 { + t.Errorf("Expected 1 hex owner, got %d", len(hexOwners)) + } + if len(hexOwners) > 0 && hexOwners[0] != ownerHex { + t.Errorf("Expected owner %q, got %q", ownerHex, hexOwners[0]) + } + + // Test 4: Policy with multiple owners + owner2Hex := "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + multiOwnersJSON := []byte(`{ + "default_policy": "allow", + "owners": ["` + ownerHex + `", "` + owner2Hex + `"] + }`) + if err := policy.Reload(multiOwnersJSON, configPath); err != nil { + t.Fatalf("Failed to reload policy with multiple owners: %v", err) + } + + owners = policy.GetOwnersBin() + if len(owners) != 2 { + t.Errorf("Expected 2 owners, got %d", len(owners)) + } +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index df7d05f..d6522bc 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -395,10 +395,16 @@ type P struct { // When true and a rule has WriteAllowFollows=true, policy admin follows get read+write access. PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"` + // Owners is a list of hex-encoded pubkeys that have full control of the relay. + // These are merged with owners from the ORLY_OWNERS environment variable. + // Useful for cloud deployments where environment variables cannot be modified. + Owners []string `json:"owners,omitempty"` + // Unexported binary caches for faster comparison (populated from hex strings above) policyAdminsBin [][]byte // Binary cache for policy admin pubkeys policyFollows [][]byte // Cached follow list from policy admins (kind 3 events) policyFollowsMx sync.RWMutex // Protect follows list access + ownersBin [][]byte // Binary cache for policy-defined owner pubkeys // manager handles policy script execution. // Unexported to enforce use of public API methods (CheckPolicy, IsEnabled). @@ -413,6 +419,7 @@ type pJSON struct { DefaultPolicy string `json:"default_policy"` PolicyAdmins []string `json:"policy_admins,omitempty"` PolicyFollowWhitelistEnabled bool `json:"policy_follow_whitelist_enabled,omitempty"` + Owners []string `json:"owners,omitempty"` } // UnmarshalJSON implements custom JSON unmarshalling to handle unexported fields. @@ -427,6 +434,7 @@ func (p *P) UnmarshalJSON(data []byte) error { p.DefaultPolicy = shadow.DefaultPolicy p.PolicyAdmins = shadow.PolicyAdmins p.PolicyFollowWhitelistEnabled = shadow.PolicyFollowWhitelistEnabled + p.Owners = shadow.Owners // Populate binary cache for policy admins if len(p.PolicyAdmins) > 0 { @@ -441,6 +449,19 @@ func (p *P) UnmarshalJSON(data []byte) error { } } + // Populate binary cache for policy-defined owners + if len(p.Owners) > 0 { + p.ownersBin = make([][]byte, 0, len(p.Owners)) + for _, hexPubkey := range p.Owners { + binPubkey, err := hex.Dec(hexPubkey) + if err != nil { + log.W.F("failed to decode owner pubkey %q: %v", hexPubkey, err) + continue + } + p.ownersBin = append(p.ownersBin, binPubkey) + } + } + return nil } @@ -1735,6 +1756,16 @@ func (p *P) ValidateJSON(policyJSON []byte) error { } } + // Validate owners are valid hex pubkeys (64 characters) + for _, owner := range tempPolicy.Owners { + if len(owner) != 64 { + return fmt.Errorf("invalid owner pubkey length: %q (expected 64 hex characters)", owner) + } + if _, err := hex.Dec(owner); err != nil { + return fmt.Errorf("invalid owner pubkey format: %q: %v", owner, err) + } + } + // Validate regex patterns in tag_validation rules and new fields for kind, rule := range tempPolicy.rules { for tagName, pattern := range rule.TagValidation { @@ -1835,7 +1866,9 @@ func (p *P) Reload(policyJSON []byte, configPath string) error { p.DefaultPolicy = tempPolicy.DefaultPolicy p.PolicyAdmins = tempPolicy.PolicyAdmins p.PolicyFollowWhitelistEnabled = tempPolicy.PolicyFollowWhitelistEnabled + p.Owners = tempPolicy.Owners p.policyAdminsBin = tempPolicy.policyAdminsBin + p.ownersBin = tempPolicy.ownersBin // Note: policyFollows is NOT reset here - it will be refreshed separately p.policyFollowsMx.Unlock() @@ -1923,6 +1956,7 @@ func (p *P) SaveToFile(configPath string) error { DefaultPolicy: p.DefaultPolicy, PolicyAdmins: p.PolicyAdmins, PolicyFollowWhitelistEnabled: p.PolicyFollowWhitelistEnabled, + Owners: p.Owners, } // Marshal to JSON with indentation for readability @@ -2015,6 +2049,36 @@ func (p *P) GetPolicyAdminsBin() [][]byte { return result } +// GetOwnersBin returns a copy of the binary owner pubkeys defined in the policy. +// These are merged with environment-defined owners by the application layer. +// Useful for cloud deployments where environment variables cannot be modified. +func (p *P) GetOwnersBin() [][]byte { + if p == nil { + return nil + } + + p.policyFollowsMx.RLock() + defer p.policyFollowsMx.RUnlock() + + // Return a copy to prevent external modification + result := make([][]byte, len(p.ownersBin)) + for i, owner := range p.ownersBin { + ownerCopy := make([]byte, len(owner)) + copy(ownerCopy, owner) + result[i] = ownerCopy + } + return result +} + +// GetOwners returns the hex-encoded owner pubkeys defined in the policy. +// These are merged with environment-defined owners by the application layer. +func (p *P) GetOwners() []string { + if p == nil { + return nil + } + return p.Owners +} + // 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.