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.
This commit is contained in:
21
app/main.go
21
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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user