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:
2025-12-01 21:39:28 +00:00
parent 2166ff7013
commit 5631c162d9
5 changed files with 234 additions and 1 deletions

View File

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

View File

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

View File

@@ -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": []

View File

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

View File

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