Files
next.orly.dev/pkg/policy
mleku 746523ea78
Some checks failed
Go / build-and-release (push) Has been cancelled
Add support for read/write permissive overrides in policies
Introduce `read_allow_permissive` and `write_allow_permissive` flags in the global rule to override kind whitelists for read or write operations. These flags allow more flexible policy configurations while maintaining blacklist enforcement and preventing conflicting settings. Updated tests and documentation for clarity.
2025-12-03 20:26:49 +00:00
..

ORLY Policy System

The policy system provides fine-grained control over event storage and retrieval in the ORLY Nostr relay. It allows relay operators to define rules based on event kinds, pubkeys, content size, timestamps, tags, and custom scripts.

Table of Contents

Overview

The policy system evaluates every event against configured rules before allowing storage (write) or retrieval (read). Rules are evaluated as AND operations—all configured criteria must be satisfied for an event to be allowed.

Key capabilities:

  • Kind filtering: Whitelist or blacklist specific event kinds
  • Pubkey access control: Allow/deny lists for reading and writing
  • Size limits: Restrict total event size and content length
  • Timestamp validation: Reject events that are too old or too far in the future
  • Expiry enforcement: Require events to have expiration tags within limits
  • Tag validation: Enforce regex patterns on tag values
  • Protected events: Require NIP-70 protected event markers
  • Follows-based access: Whitelist pubkeys followed by admins
  • Custom scripts: External scripts for complex validation logic

Quick Start

1. Enable the Policy System

export ORLY_POLICY_ENABLED=true

2. Create a Policy Configuration

Create ~/.config/ORLY/policy.json:

{
  "default_policy": "allow",
  "global": {
    "max_age_of_event": 86400,
    "size_limit": 100000
  },
  "rules": {
    "1": {
      "description": "Text notes",
      "size_limit": 32000,
      "max_expiry_duration": "P7D"
    }
  }
}

3. Restart the Relay

sudo systemctl restart orly

Configuration Structure

{
  "default_policy": "allow|deny",
  "kind": {
    "whitelist": [1, 3, 4],
    "blacklist": []
  },
  "global": { /* Rule fields applied to all events */ },
  "rules": {
    "1": { /* Rule fields for kind 1 */ },
    "30023": { /* Rule fields for kind 30023 */ }
  },
  "policy_admins": ["hex_pubkey_1", "hex_pubkey_2"],
  "policy_follow_whitelist_enabled": false
}

Policy Fields Reference

Top-Level Fields

Field Type Default Description
default_policy string "allow" Fallback behavior when no rules match: "allow" or "deny"
kind object {} Kind whitelist/blacklist configuration
global object {} Rule applied to ALL events regardless of kind
rules object {} Map of kind number (as string) to rule configuration
policy_admins array [] Hex-encoded pubkeys that can update policy via kind 12345 events
policy_follow_whitelist_enabled boolean false Enable follows-based whitelisting for write_allow_follows

Kind Filtering

"kind": {
  "whitelist": [1, 3, 4, 7, 9735],
  "blacklist": [4]
}
Field Type Description
whitelist array Only these kinds are allowed. If present, all others are denied.
blacklist array These kinds are denied. Only evaluated if whitelist is empty.

Precedence: Whitelist takes precedence over blacklist. If whitelist has entries, blacklist is ignored.

Rule Fields

Rules can be applied globally (in global) or per-kind (in rules). All configured criteria are evaluated as AND operations.

Description

{
  "description": "Human-readable description of this rule"
}

Access Control Lists

Field Type Description
write_allow array Hex pubkeys allowed to write. If present, all others denied.
write_deny array Hex pubkeys denied from writing. Only evaluated if write_allow is empty.
read_allow array Hex pubkeys allowed to read. If present, all others denied.
read_deny array Hex pubkeys denied from reading. Only evaluated if read_allow is empty.
{
  "write_allow": ["npub1...", "npub2..."],
  "write_deny": ["npub3..."],
  "read_allow": [],
  "read_deny": ["npub4..."]
}

Size Limits

Field Type Unit Description
size_limit integer bytes Maximum total serialized event size
content_limit integer bytes Maximum content field size
{
  "size_limit": 100000,
  "content_limit": 50000
}

Timestamp Validation

Field Type Unit Description
max_age_of_event integer seconds Maximum age of event's created_at (prevents replay attacks)
max_age_event_in_future integer seconds Maximum time event can be in the future
{
  "max_age_of_event": 86400,
  "max_age_event_in_future": 300
}

Expiry Enforcement

Field Type Description
max_expiry integer Deprecated. Maximum expiry time in raw seconds.
max_expiry_duration string Maximum expiry time in ISO-8601 duration format. Takes precedence over max_expiry.

When set, events must have an expiration tag, and the expiry time must be within the specified duration from the event's created_at time.

{
  "max_expiry_duration": "P7D"
}

Required Tags

Field Type Description
must_have_tags array Tag key letters that must be present on the event
{
  "must_have_tags": ["d", "t"]
}

Privileged Events

Field Type Description
privileged boolean Only parties involved (author or p-tag recipients) can read/write
{
  "privileged": true
}

Protected Events (NIP-70)

Field Type Description
protected_required boolean Requires events to have a - tag (NIP-70 protected marker)

Protected events signal that they should only be published to relays that enforce access control.

{
  "protected_required": true
}

Identifier Regex

Field Type Description
identifier_regex string Regex pattern that d tag values must match

When set, events must have at least one d tag, and all d tags must match the pattern.

{
  "identifier_regex": "^[a-z0-9-]{1,64}$"
}

Tag Validation

Field Type Description
tag_validation object Map of tag name to regex pattern

Validates that tag values match the specified regex patterns. Only validates tags that are present—does not require tags to exist.

{
  "tag_validation": {
    "d": "^[a-z0-9-]{1,64}$",
    "t": "^[a-z0-9]+$"
  }
}

Follows-Based Whitelisting

Field Type Description
write_allow_follows boolean DEPRECATED. Grant read+write access to policy admin follows
follows_whitelist_admins array DEPRECATED. Per-rule admin pubkeys whose follows are whitelisted
read_follows_whitelist array Pubkeys whose follows can READ events. Restricts read access when set.
write_follows_whitelist array Pubkeys whose follows can WRITE events. Restricts write access when set.

See Follows-Based Whitelisting for details.

Permissive Mode Overrides

Field Type Description
read_allow_permissive boolean Override kind whitelist for READ access (reads allowed for all kinds)
write_allow_permissive boolean Override kind whitelist for WRITE access (writes use global rule only)

These fields, when set on the global rule, allow independent control over read and write access relative to the kind whitelist/blacklist:

{
  "kind": {
    "whitelist": [1, 3, 5, 7]
  },
  "global": {
    "read_allow_permissive": true,
    "size_limit": 100000
  }
}

In this example:

  • READ: Allowed for ALL kinds (permissive override ignores whitelist)
  • WRITE: Only kinds 1, 3, 5, 7 can be written (whitelist applies)

Important constraints:

  • These flags only work on the global rule (ignored on kind-specific rules)
  • You cannot enable BOTH read_allow_permissive AND write_allow_permissive when a kind whitelist/blacklist is configured (this would make the whitelist meaningless)
  • Blacklists always take precedence—permissive flags do NOT override explicit blacklist entries

See Permissive Mode Examples for detailed use cases.

Rate Limiting

Field Type Unit Description
rate_limit integer bytes/second Maximum data rate per authenticated connection
{
  "rate_limit": 10000
}

Custom Scripts

Field Type Description
script string Path to external validation script

See Policy Scripts for details.

ISO-8601 Duration Format

The max_expiry_duration field uses strict ISO-8601 duration format, parsed by the sosodev/duration library.

Format

P[n]Y[n]M[n]W[n]DT[n]H[n]M[n]S
Component Meaning Example
P Required prefix (Period) P1D
Y Years (~365.25 days) P1Y
M Months (~30.44 days) - date part P1M
W Weeks (7 days) P2W
D Days P7D
T Required separator before time PT1H
H Hours (requires T) PT2H
M Minutes (requires T) - time part PT30M
S Seconds (requires T) PT90S

Examples

Duration Meaning Seconds
P1D 1 day 86,400
P7D 7 days 604,800
P30D 30 days 2,592,000
PT1H 1 hour 3,600
PT30M 30 minutes 1,800
PT90S 90 seconds 90
P1DT12H 1 day 12 hours 129,600
P1DT2H30M 1 day 2 hours 30 minutes 95,400
P1W 1 week 604,800
P1M 1 month 2,628,000
P1Y 1 year 31,536,000
PT1.5H 1.5 hours 5,400
P0.5D 12 hours 43,200

Important Notes

  1. P prefix is required: 1D is invalid, use P1D
  2. T separator is required before time: P1H is invalid, use PT1H
  3. Date components before T: PT1D is invalid (D is a date component)
  4. Case insensitive: p1d and P1D are equivalent
  5. Fractional values supported: PT1.5H, P0.5D

Invalid Examples

Invalid Why Correct
1D Missing P prefix P1D
P1H H needs T separator PT1H
PT1D D is date component P1D
P30S S needs T separator PT30S
P-5D Negative not allowed P5D
PD Missing number P1D

Access Control

Default-Permissive Access Model

The policy system uses a default-permissive model for both read and write access:

  • Read: Allowed by default unless a read restriction is configured
  • Write: Allowed by default unless a write restriction is configured

Restrictions become active when any of the following fields are set:

Access Restrictions
Read read_allow, read_follows_whitelist, or privileged
Write write_allow, write_follows_whitelist

Important: privileged ONLY applies to READ operations.

Write Access Evaluation (Default-Permissive)

1. Universal constraints (size, tags, age) - must pass
2. If pubkey in write_deny → DENY
3. If write_allow_follows enabled and pubkey in admin follows → ALLOW
4. If write_follows_whitelist set and pubkey in follows → ALLOW
5. If write_allow set and pubkey in list → ALLOW
6. If ANY write restriction is set → DENY (not in any whitelist)
7. Otherwise → ALLOW (default-permissive)

Read Access Evaluation (Default-Permissive)

1. If pubkey in read_deny → DENY
2. If read_allow_follows enabled and pubkey in admin follows → ALLOW
3. If read_follows_whitelist set and pubkey in follows → ALLOW
4. If read_allow set and pubkey in list → ALLOW
5. If privileged set and pubkey is party to event → ALLOW
6. If ANY read restriction is set → DENY (not in any whitelist)
7. Otherwise → ALLOW (default-permissive)

Privileged Events (Read-Only)

When privileged: true, only the author and p-tag recipients can access the event:

{
  "rules": {
    "4": {
      "description": "Encrypted DMs",
      "privileged": true
    }
  }
}

Follows-Based Whitelisting

The policy system supports whitelisting pubkeys based on follow lists (kind 3 events). There are two approaches:

Use read_follows_whitelist and write_follows_whitelist for fine-grained control:

{
  "global": {
    "read_follows_whitelist": ["curator_pubkey_hex"],
    "write_follows_whitelist": ["moderator_pubkey_hex"]
  },
  "rules": {
    "30023": {
      "description": "Articles - curated reading, moderated writing",
      "read_follows_whitelist": ["article_curator_hex"],
      "write_follows_whitelist": ["article_moderator_hex"]
    }
  }
}

How it works:

  • The pubkeys listed AND their follows (from kind 3 events) can access the events
  • read_follows_whitelist: Restricts WHO can read (when set)
  • write_follows_whitelist: Restricts WHO can write (when set)
  • If not set, the default-permissive behavior applies

Important: The relay will fail to start if the named pubkeys don't have kind 3 follow list events in the database. This ensures the follow lists are available for access control.

2. Legacy: Global Policy Admin Follows (DEPRECATED)

Enable whitelisting for all pubkeys followed by policy admins:

{
  "policy_admins": ["admin_pubkey_hex"],
  "policy_follow_whitelist_enabled": true,
  "rules": {
    "1": {
      "write_allow_follows": true
    }
  }
}

When write_allow_follows is true, pubkeys in the policy admins' kind 3 follow lists get both read AND write access.

3. Legacy: Per-Rule Follows Whitelist (DEPRECATED)

Configure specific admins per rule:

{
  "rules": {
    "30023": {
      "description": "Long-form articles from curator's follows",
      "follows_whitelist_admins": ["curator_pubkey_hex"]
    }
  }
}

This allows different rules to use different admin follow lists.

Loading Follow Lists at Startup

The application must load follow lists at startup. The new API provides separate methods:

// Get all pubkeys that need follow lists loaded (combines read + write + legacy)
allPubkeys := policy.GetAllFollowsWhitelistPubkeys()

// Or get them separately
readPubkeys := policy.GetAllReadFollowsWhitelistPubkeys()
writePubkeys := policy.GetAllWriteFollowsWhitelistPubkeys()
legacyAdmins := policy.GetAllFollowsWhitelistAdmins()

// Load follows and update the policy
for _, pubkeyHex := range readPubkeys {
    follows := loadFollowsFromKind3(pubkeyHex)
    // Update read follows whitelist for specific kinds
    policy.UpdateRuleReadFollowsWhitelist(kind, follows)
    // Or for global rule
    policy.UpdateGlobalReadFollowsWhitelist(follows)
}

for _, pubkeyHex := range writePubkeys {
    follows := loadFollowsFromKind3(pubkeyHex)
    policy.UpdateRuleWriteFollowsWhitelist(kind, follows)
    // Or for global rule
    policy.UpdateGlobalWriteFollowsWhitelist(follows)
}

Tag Validation

Using tag_validation

Validate multiple tags with regex patterns:

{
  "rules": {
    "30023": {
      "tag_validation": {
        "d": "^[a-z0-9-]{1,64}$",
        "t": "^[a-z0-9]+$",
        "title": "^.{1,100}$"
      }
    }
  }
}
  • Only validates tags that are present on the event
  • Does not require tags to exist (use must_have_tags for that)
  • All values of a repeated tag must match the pattern

Using identifier_regex

Shorthand for d tag validation:

{
  "identifier_regex": "^[a-z0-9-]{1,64}$"
}

This is equivalent to:

{
  "tag_validation": {
    "d": "^[a-z0-9-]{1,64}$"
  }
}

Important: When identifier_regex is set, events must have at least one d tag.

Common Patterns

Pattern Description
^[a-z0-9-]{1,64}$ URL-friendly slug
^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$ UUID
^[a-zA-Z0-9_]+$ Alphanumeric with underscores
^.{1,100}$ Any characters, max 100

Policy Scripts

External scripts provide custom validation logic.

Script Interface

Input: JSON event objects on stdin (one per line):

{
  "id": "event_id_hex",
  "pubkey": "author_pubkey_hex",
  "kind": 1,
  "content": "Hello, world!",
  "tags": [["p", "recipient_hex"]],
  "created_at": 1640995200,
  "sig": "signature_hex",
  "logged_in_pubkey": "authenticated_user_hex",
  "ip_address": "127.0.0.1",
  "access_type": "write"
}

Output: JSON response on stdout:

{"id": "event_id_hex", "action": "accept", "msg": ""}

Actions

Action OK Response Effect
accept true Store/retrieve event normally
reject false Reject with error message
shadowReject true Silently drop (appears successful to client)

Script Requirements

  1. Long-lived process: Read stdin in a loop, don't exit after one event
  2. JSON only on stdout: Use stderr for debug logging
  3. Flush after each response: Call sys.stdout.flush() (Python) or equivalent
  4. Handle errors gracefully: Always return valid JSON

Example Script (Python)

#!/usr/bin/env python3
import json
import sys

def process_event(event):
    if 'spam' in event.get('content', '').lower():
        return {'id': event['id'], 'action': 'reject', 'msg': 'Spam detected'}
    return {'id': event['id'], 'action': 'accept', 'msg': ''}

for line in sys.stdin:
    if line.strip():
        try:
            event = json.loads(line)
            response = process_event(event)
            print(json.dumps(response))
            sys.stdout.flush()
        except json.JSONDecodeError:
            print(json.dumps({'id': '', 'action': 'reject', 'msg': 'Invalid JSON'}))
            sys.stdout.flush()

Configuration

{
  "rules": {
    "1": {
      "script": "/etc/orly/scripts/spam-filter.py"
    }
  }
}

Dynamic Policy Updates

Policy admins can update configuration at runtime by publishing kind 12345 events.

Setup

{
  "policy_admins": ["admin_pubkey_hex"],
  "default_policy": "allow"
}

Publishing Updates

Send a kind 12345 event with the new policy as JSON content:

{
  "kind": 12345,
  "content": "{\"default_policy\": \"deny\", \"kind\": {\"whitelist\": [1,3,7]}}",
  "tags": [],
  "created_at": 1234567890
}

Security

  • Only pubkeys in policy_admins can update policy
  • Invalid JSON or configuration is rejected (existing policy preserved)
  • All updates are logged for audit purposes

Evaluation Order

Events are evaluated in this order:

  1. Global Rules - Applied to all events first
  2. Kind Filtering - Whitelist/blacklist check
  3. Kind-Specific Rules - Rules for the event's kind
  4. Script Evaluation - If configured and running
  5. Default Policy - Fallback if no rules deny

The first rule that denies access stops evaluation. If all rules pass, the event is allowed.

Rule Criteria (AND Logic)

Within a rule, all configured criteria must be satisfied:

access_allowed = (
    pubkey_check_passed AND
    size_check_passed AND
    timestamp_check_passed AND
    expiry_check_passed AND
    tag_check_passed AND
    protected_check_passed AND
    script_check_passed
)

Examples

Open Relay with Size Limits

{
  "default_policy": "allow",
  "global": {
    "size_limit": 100000,
    "max_age_of_event": 86400,
    "max_age_event_in_future": 300
  }
}

Private Relay

{
  "default_policy": "deny",
  "global": {
    "write_allow": ["trusted_pubkey_1", "trusted_pubkey_2"],
    "read_allow": ["trusted_pubkey_1", "trusted_pubkey_2"]
  }
}

Ephemeral Events with Expiry

{
  "default_policy": "allow",
  "rules": {
    "20": {
      "description": "Ephemeral events must expire within 24 hours",
      "max_expiry_duration": "P1D"
    }
  }
}

Long-Form Content with Strict Validation

{
  "default_policy": "deny",
  "rules": {
    "30023": {
      "description": "Long-form articles with strict requirements",
      "max_expiry_duration": "P30D",
      "protected_required": true,
      "identifier_regex": "^[a-z0-9-]{1,64}$",
      "follows_whitelist_admins": ["curator_pubkey_hex"],
      "tag_validation": {
        "t": "^[a-z0-9-]{1,32}$"
      },
      "size_limit": 100000,
      "content_limit": 50000
    }
  }
}

Encrypted DMs with Privacy

{
  "default_policy": "allow",
  "rules": {
    "4": {
      "description": "Encrypted DMs - private and protected",
      "protected_required": true,
      "privileged": true
    }
  }
}

Community-Curated Content

{
  "default_policy": "deny",
  "policy_admins": ["community_admin_hex"],
  "policy_follow_whitelist_enabled": true,
  "rules": {
    "1": {
      "description": "Only community members can post",
      "write_allow_follows": true,
      "size_limit": 32000
    }
  }
}

Kind Whitelist with Global Limits

{
  "default_policy": "deny",
  "kind": {
    "whitelist": [0, 1, 3, 4, 7, 9735, 30023]
  },
  "global": {
    "size_limit": 100000,
    "max_age_of_event": 604800,
    "max_age_event_in_future": 60
  }
}

Permissive Mode Examples

Read-Permissive Relay (Write-Restricted)

Allow anyone to read all events, but restrict writes to specific kinds:

{
  "default_policy": "allow",
  "kind": {
    "whitelist": [1, 3, 7, 9735]
  },
  "global": {
    "read_allow_permissive": true,
    "size_limit": 100000
  }
}

Behavior:

  • READ: Any kind can be read (permissive override)
  • WRITE: Only kinds 1, 3, 7, 9735 can be written

This is useful for relays that want to serve as aggregators (read any event type) but only accept specific event types from clients.

Write-Permissive with Read Restrictions

Allow writes of any kind (with global constraints), but restrict reads:

{
  "default_policy": "allow",
  "kind": {
    "whitelist": [0, 1, 3]
  },
  "global": {
    "write_allow_permissive": true,
    "size_limit": 50000,
    "max_age_of_event": 86400
  }
}

Behavior:

  • READ: Only kinds 0, 1, 3 can be read (whitelist applies)
  • WRITE: Any kind can be written (with size and age limits from global rule)

This is useful for relays that want to accept any event type but only serve a curated subset.

Archive Relay (Read Any, Accept Specific)

Perfect for archive/backup relays:

{
  "default_policy": "allow",
  "kind": {
    "whitelist": [0, 1, 3, 4, 7, 30023]
  },
  "global": {
    "read_allow_permissive": true,
    "size_limit": 500000
  },
  "rules": {
    "30023": {
      "description": "Long-form articles with validation",
      "identifier_regex": "^[a-z0-9-]{1,64}$",
      "max_expiry_duration": "P365D"
    }
  }
}

Behavior:

  • READ: All kinds can be read (historical data)
  • WRITE: Only whitelisted kinds accepted, with specific rules for articles

Testing

Run Policy Tests

CGO_ENABLED=0 go test -v ./pkg/policy/...

Test Scripts Manually

echo '{"id":"test","kind":1,"content":"test"}' | ./policy-script.py

Expected output:

{"id":"test","action":"accept","msg":""}

Troubleshooting

Policy Not Loading

# Check file exists and is valid JSON
cat ~/.config/ORLY/policy.json | jq .

Script Not Working

# Check script is executable
ls -la /path/to/script.py

# Test script independently
echo '{"id":"test","kind":1}' | /path/to/script.py

Enable Debug Logging

export ORLY_LOG_LEVEL=debug

Common Issues

Issue Cause Solution
"invalid ISO-8601 duration" Wrong format Use P1D not 1d
"H requires T separator" Missing T Use PT1H not P1H
Script timeout Script not responding Ensure flush after each response
Broken pipe Script exited Script must run continuously