package policy import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "time" "next.orly.dev/pkg/nostr/encoders/event" "next.orly.dev/pkg/nostr/encoders/hex" "github.com/adrg/xdg" "github.com/sosodev/duration" "next.orly.dev/pkg/lol/chk" "next.orly.dev/pkg/lol/log" "next.orly.dev/pkg/utils" ) // parseDuration parses an ISO-8601 duration string into seconds. // ISO-8601 format: P[n]Y[n]M[n]DT[n]H[n]M[n]S // Examples: "P1D" (1 day), "PT1H" (1 hour), "P7DT12H" (7 days 12 hours), "PT30M" (30 minutes) // Uses the github.com/sosodev/duration library for strict ISO-8601 compliance. // Note: Years and Months are converted to approximate time.Duration values // (1 year ≈ 365.25 days, 1 month ≈ 30.44 days). func parseDuration(s string) (int64, error) { if s == "" { return 0, fmt.Errorf("empty duration string") } s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("empty duration string") } // Parse using the ISO-8601 duration library d, err := duration.Parse(s) if err != nil { return 0, fmt.Errorf("invalid ISO-8601 duration %q: %v", s, err) } // Convert to time.Duration and then to seconds timeDur := d.ToTimeDuration() return int64(timeDur.Seconds()), nil } // Kinds defines whitelist and blacklist policies for event kinds. // Whitelist takes precedence over blacklist - if whitelist is present, only whitelisted kinds are allowed. // If only blacklist is present, all kinds except blacklisted ones are allowed. type Kinds struct { // Whitelist is a list of event kinds that are allowed to be written to the relay. If any are present, implicitly all others are denied. Whitelist []int `json:"whitelist,omitempty"` // Blacklist is a list of event kinds that are not allowed to be written to the relay. If any are present, implicitly all others are allowed. Only takes effect in the absence of a Whitelist. Blacklist []int `json:"blacklist,omitempty"` } // Rule defines policy criteria for a specific event kind. // // Rules are evaluated in the following order: // 1. If Script is present and running, it determines the outcome // 2. If Script fails or is not running, falls back to default_policy // 3. Otherwise, all specified criteria are evaluated as AND operations // // For pubkey allow/deny lists: whitelist takes precedence over blacklist. // If whitelist has entries, only whitelisted pubkeys are allowed. // If only blacklist has entries, all pubkeys except blacklisted ones are allowed. // ============================================================================= // Rule Sub-Components (Value Objects) // ============================================================================= // AccessControl defines who can read/write events. // This is a value object that encapsulates access control configuration. type AccessControl struct { // WriteAllow is a list of pubkeys allowed to write. If any present, all others denied. WriteAllow []string `json:"write_allow,omitempty"` // WriteDeny is a list of pubkeys denied write. Only effective without WriteAllow. WriteDeny []string `json:"write_deny,omitempty"` // ReadAllow is a list of pubkeys allowed to read. If any present, all others denied. ReadAllow []string `json:"read_allow,omitempty"` // ReadDeny is a list of pubkeys denied read. Only effective without ReadAllow. ReadDeny []string `json:"read_deny,omitempty"` // WriteAllowFollows grants access to policy admin follows when enabled. WriteAllowFollows bool `json:"write_allow_follows,omitempty"` // FollowsWhitelistAdmins specifies admin pubkeys whose follows are whitelisted. // DEPRECATED: Use ReadFollowsWhitelist and WriteFollowsWhitelist instead. FollowsWhitelistAdmins []string `json:"follows_whitelist_admins,omitempty"` // ReadFollowsWhitelist specifies pubkeys whose follows can READ events. ReadFollowsWhitelist []string `json:"read_follows_whitelist,omitempty"` // WriteFollowsWhitelist specifies pubkeys whose follows can WRITE events. WriteFollowsWhitelist []string `json:"write_follows_whitelist,omitempty"` // ReadAllowPermissive allows read access for ALL kinds on GLOBAL rule. ReadAllowPermissive bool `json:"read_allow_permissive,omitempty"` // WriteAllowPermissive allows write access bypassing kind whitelist on GLOBAL rule. WriteAllowPermissive bool `json:"write_allow_permissive,omitempty"` // WriteAllowIfTagged is a list of pubkeys. Authors not in WriteAllow can still // publish if their event contains a p-tag referencing one of these pubkeys. WriteAllowIfTagged []string `json:"write_allow_if_tagged,omitempty"` // Binary caches (internal, not serialized) writeAllowBin [][]byte writeDenyBin [][]byte readAllowBin [][]byte readDenyBin [][]byte followsWhitelistAdminsBin [][]byte followsWhitelistFollowsBin [][]byte readFollowsWhitelistBin [][]byte writeFollowsWhitelistBin [][]byte readFollowsFollowsBin [][]byte writeFollowsFollowsBin [][]byte writeAllowIfTaggedBin [][]byte } // Constraints defines limits and restrictions on events. // This is a value object that encapsulates event constraints. type Constraints struct { // MaxExpiry is the maximum expiry time in seconds. // Deprecated: Use MaxExpiryDuration instead. MaxExpiry *int64 `json:"max_expiry,omitempty"` //nolint:staticcheck // MaxExpiryDuration is the max expiry in ISO-8601 duration format. MaxExpiryDuration string `json:"max_expiry_duration,omitempty"` // SizeLimit is the maximum total serialized size in bytes. SizeLimit *int64 `json:"size_limit,omitempty"` // ContentLimit is the maximum content field size in bytes. ContentLimit *int64 `json:"content_limit,omitempty"` // RateLimit is the write rate limit in bytes per second. RateLimit *int64 `json:"rate_limit,omitempty"` // MaxAgeOfEvent is the max age in seconds for created_at timestamps. MaxAgeOfEvent *int64 `json:"max_age_of_event,omitempty"` // MaxAgeEventInFuture is the max future offset for created_at timestamps. MaxAgeEventInFuture *int64 `json:"max_age_event_in_future,omitempty"` // ProtectedRequired requires events to have a "-" tag (NIP-70). ProtectedRequired bool `json:"protected_required,omitempty"` // Privileged means event is only sent to authenticated parties. Privileged bool `json:"privileged,omitempty"` // Parsed cache (internal, not serialized) maxExpirySeconds *int64 } // TagValidationConfig defines tag validation rules. // This is a value object that encapsulates tag validation configuration. type TagValidationConfig struct { // MustHaveTags is a list of tag key letters that must be present. MustHaveTags []string `json:"must_have_tags,omitempty"` // TagValidation is a map of tag_name -> regex pattern for validation. TagValidation map[string]string `json:"tag_validation,omitempty"` // IdentifierRegex is a regex pattern for "d" tag identifiers. IdentifierRegex string `json:"identifier_regex,omitempty"` // Compiled cache (internal, not serialized) identifierRegexCache *regexp.Regexp } // ============================================================================= // Rule (Composed from Sub-Components) // ============================================================================= // Rule defines policies for a specific event kind or as a global default. // It is composed of sub-value objects for cleaner organization. type Rule struct { // Description is a human-readable description of the rule. Description string `json:"description"` // Script is a path to a validation script. Script string `json:"script,omitempty"` // Embedded sub-components (fields are flattened in JSON for backward compatibility) AccessControl Constraints TagValidationConfig } // hasAnyRules checks if the rule has any constraints configured func (r *Rule) hasAnyRules() bool { // Check for any configured constraints return len(r.WriteAllow) > 0 || len(r.WriteDeny) > 0 || len(r.ReadAllow) > 0 || len(r.ReadDeny) > 0 || len(r.writeAllowBin) > 0 || len(r.writeDenyBin) > 0 || len(r.readAllowBin) > 0 || len(r.readDenyBin) > 0 || r.SizeLimit != nil || r.ContentLimit != nil || r.MaxAgeOfEvent != nil || r.MaxAgeEventInFuture != nil || r.MaxExpiry != nil || r.MaxExpiryDuration != "" || r.maxExpirySeconds != nil || //nolint:staticcheck // Backward compat len(r.MustHaveTags) > 0 || r.Script != "" || r.Privileged || r.WriteAllowFollows || len(r.FollowsWhitelistAdmins) > 0 || len(r.ReadFollowsWhitelist) > 0 || len(r.WriteFollowsWhitelist) > 0 || len(r.readFollowsWhitelistBin) > 0 || len(r.writeFollowsWhitelistBin) > 0 || len(r.TagValidation) > 0 || r.ProtectedRequired || r.IdentifierRegex != "" || r.ReadAllowPermissive || r.WriteAllowPermissive || len(r.WriteAllowIfTagged) > 0 || len(r.writeAllowIfTaggedBin) > 0 } // populateBinaryCache converts hex-encoded pubkey strings to binary for faster comparison. // This should be called after unmarshaling the policy from JSON. func (r *Rule) populateBinaryCache() error { var err error // Convert WriteAllow hex strings to binary if len(r.WriteAllow) > 0 { r.writeAllowBin = make([][]byte, 0, len(r.WriteAllow)) for _, hexPubkey := range r.WriteAllow { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode WriteAllow pubkey %q: %v", hexPubkey, decErr) continue } r.writeAllowBin = append(r.writeAllowBin, binPubkey) } } // Convert WriteDeny hex strings to binary if len(r.WriteDeny) > 0 { r.writeDenyBin = make([][]byte, 0, len(r.WriteDeny)) for _, hexPubkey := range r.WriteDeny { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode WriteDeny pubkey %q: %v", hexPubkey, decErr) continue } r.writeDenyBin = append(r.writeDenyBin, binPubkey) } } // Convert ReadAllow hex strings to binary if len(r.ReadAllow) > 0 { r.readAllowBin = make([][]byte, 0, len(r.ReadAllow)) for _, hexPubkey := range r.ReadAllow { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode ReadAllow pubkey %q: %v", hexPubkey, decErr) continue } r.readAllowBin = append(r.readAllowBin, binPubkey) } } // Convert ReadDeny hex strings to binary if len(r.ReadDeny) > 0 { r.readDenyBin = make([][]byte, 0, len(r.ReadDeny)) for _, hexPubkey := range r.ReadDeny { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode ReadDeny pubkey %q: %v", hexPubkey, decErr) continue } r.readDenyBin = append(r.readDenyBin, binPubkey) } } // Parse MaxExpiryDuration into maxExpirySeconds // MaxExpiryDuration takes precedence over MaxExpiry if both are set if r.MaxExpiryDuration != "" { seconds, parseErr := parseDuration(r.MaxExpiryDuration) if parseErr != nil { log.W.F("failed to parse MaxExpiryDuration %q: %v", r.MaxExpiryDuration, parseErr) } else { r.maxExpirySeconds = &seconds } } else if r.MaxExpiry != nil { //nolint:staticcheck // Backward compatibility // Fall back to MaxExpiry (raw seconds) if MaxExpiryDuration not set r.maxExpirySeconds = r.MaxExpiry //nolint:staticcheck // Backward compatibility } // Compile IdentifierRegex pattern if r.IdentifierRegex != "" { compiled, compileErr := regexp.Compile(r.IdentifierRegex) if compileErr != nil { log.W.F("failed to compile IdentifierRegex %q: %v", r.IdentifierRegex, compileErr) } else { r.identifierRegexCache = compiled } } // Convert FollowsWhitelistAdmins hex strings to binary (DEPRECATED) if len(r.FollowsWhitelistAdmins) > 0 { r.followsWhitelistAdminsBin = make([][]byte, 0, len(r.FollowsWhitelistAdmins)) for _, hexPubkey := range r.FollowsWhitelistAdmins { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode FollowsWhitelistAdmins pubkey %q: %v", hexPubkey, decErr) continue } r.followsWhitelistAdminsBin = append(r.followsWhitelistAdminsBin, binPubkey) } } // Convert ReadFollowsWhitelist hex strings to binary if len(r.ReadFollowsWhitelist) > 0 { r.readFollowsWhitelistBin = make([][]byte, 0, len(r.ReadFollowsWhitelist)) for _, hexPubkey := range r.ReadFollowsWhitelist { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode ReadFollowsWhitelist pubkey %q: %v", hexPubkey, decErr) continue } r.readFollowsWhitelistBin = append(r.readFollowsWhitelistBin, binPubkey) } } // Convert WriteFollowsWhitelist hex strings to binary if len(r.WriteFollowsWhitelist) > 0 { r.writeFollowsWhitelistBin = make([][]byte, 0, len(r.WriteFollowsWhitelist)) for _, hexPubkey := range r.WriteFollowsWhitelist { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode WriteFollowsWhitelist pubkey %q: %v", hexPubkey, decErr) continue } r.writeFollowsWhitelistBin = append(r.writeFollowsWhitelistBin, binPubkey) } } // Convert WriteAllowIfTagged hex strings to binary if len(r.WriteAllowIfTagged) > 0 { r.writeAllowIfTaggedBin = make([][]byte, 0, len(r.WriteAllowIfTagged)) for _, hexPubkey := range r.WriteAllowIfTagged { binPubkey, decErr := hex.Dec(hexPubkey) if decErr != nil { log.W.F("failed to decode WriteAllowIfTagged pubkey %q: %v", hexPubkey, decErr) continue } r.writeAllowIfTaggedBin = append(r.writeAllowIfTaggedBin, binPubkey) } } return err } // IsInFollowsWhitelist checks if the given pubkey is in this rule's follows whitelist. // The pubkey parameter should be binary ([]byte), not hex-encoded. func (r *Rule) IsInFollowsWhitelist(pubkey []byte) bool { if len(pubkey) == 0 || len(r.followsWhitelistFollowsBin) == 0 { return false } for _, follow := range r.followsWhitelistFollowsBin { if utils.FastEqual(pubkey, follow) { return true } } return false } // UpdateFollowsWhitelist sets the follows list for this rule's FollowsWhitelistAdmins. // The follows should be binary pubkeys ([]byte), not hex-encoded. func (r *Rule) UpdateFollowsWhitelist(follows [][]byte) { r.followsWhitelistFollowsBin = follows } // GetFollowsWhitelistAdminsBin returns the binary-encoded admin pubkeys for this rule. func (r *Rule) GetFollowsWhitelistAdminsBin() [][]byte { return r.followsWhitelistAdminsBin } // HasFollowsWhitelistAdmins returns true if this rule has FollowsWhitelistAdmins configured. // DEPRECATED: Use HasReadFollowsWhitelist and HasWriteFollowsWhitelist instead. func (r *Rule) HasFollowsWhitelistAdmins() bool { return len(r.FollowsWhitelistAdmins) > 0 } // HasReadFollowsWhitelist returns true if this rule has ReadFollowsWhitelist configured. func (r *Rule) HasReadFollowsWhitelist() bool { return len(r.ReadFollowsWhitelist) > 0 } // HasWriteFollowsWhitelist returns true if this rule has WriteFollowsWhitelist configured. func (r *Rule) HasWriteFollowsWhitelist() bool { return len(r.WriteFollowsWhitelist) > 0 } // GetReadFollowsWhitelistBin returns the binary-encoded pubkeys for ReadFollowsWhitelist. func (r *Rule) GetReadFollowsWhitelistBin() [][]byte { return r.readFollowsWhitelistBin } // GetWriteFollowsWhitelistBin returns the binary-encoded pubkeys for WriteFollowsWhitelist. func (r *Rule) GetWriteFollowsWhitelistBin() [][]byte { return r.writeFollowsWhitelistBin } // UpdateReadFollowsWhitelist sets the follows list for this rule's ReadFollowsWhitelist. // The follows should be binary pubkeys ([]byte), not hex-encoded. func (r *Rule) UpdateReadFollowsWhitelist(follows [][]byte) { r.readFollowsFollowsBin = follows } // UpdateWriteFollowsWhitelist sets the follows list for this rule's WriteFollowsWhitelist. // The follows should be binary pubkeys ([]byte), not hex-encoded. func (r *Rule) UpdateWriteFollowsWhitelist(follows [][]byte) { r.writeFollowsFollowsBin = follows } // IsInReadFollowsWhitelist checks if the given pubkey is in this rule's read follows whitelist. // The pubkey parameter should be binary ([]byte), not hex-encoded. // Returns true if either: // 1. The pubkey is one of the ReadFollowsWhitelist pubkeys themselves, OR // 2. The pubkey is in the follows list of the ReadFollowsWhitelist pubkeys. func (r *Rule) IsInReadFollowsWhitelist(pubkey []byte) bool { if len(pubkey) == 0 { return false } // Check if pubkey is one of the whitelist pubkeys themselves for _, wlPubkey := range r.readFollowsWhitelistBin { if utils.FastEqual(pubkey, wlPubkey) { return true } } // Check if pubkey is in the follows list for _, follow := range r.readFollowsFollowsBin { if utils.FastEqual(pubkey, follow) { return true } } return false } // IsInWriteFollowsWhitelist checks if the given pubkey is in this rule's write follows whitelist. // The pubkey parameter should be binary ([]byte), not hex-encoded. // Returns true if either: // 1. The pubkey is one of the WriteFollowsWhitelist pubkeys themselves, OR // 2. The pubkey is in the follows list of the WriteFollowsWhitelist pubkeys. func (r *Rule) IsInWriteFollowsWhitelist(pubkey []byte) bool { if len(pubkey) == 0 { return false } // Check if pubkey is one of the whitelist pubkeys themselves for _, wlPubkey := range r.writeFollowsWhitelistBin { if utils.FastEqual(pubkey, wlPubkey) { return true } } // Check if pubkey is in the follows list for _, follow := range r.writeFollowsFollowsBin { if utils.FastEqual(pubkey, follow) { return true } } return false } // PolicyEvent represents an event with additional context for policy scripts. // It embeds the Nostr event and adds authentication and network context. type PolicyEvent struct { *event.E LoggedInPubkey string `json:"logged_in_pubkey,omitempty"` IPAddress string `json:"ip_address,omitempty"` AccessType string `json:"access_type,omitempty"` // "read" or "write" } // MarshalJSON implements custom JSON marshaling for PolicyEvent. // It safely serializes the embedded event and additional context fields. func (pe *PolicyEvent) MarshalJSON() ([]byte, error) { if pe.E == nil { return json.Marshal( map[string]interface{}{ "logged_in_pubkey": pe.LoggedInPubkey, "ip_address": pe.IPAddress, }, ) } // Create a safe copy of the event for JSON marshaling safeEvent := map[string]interface{}{ "id": hex.Enc(pe.E.ID), "pubkey": hex.Enc(pe.E.Pubkey), "created_at": pe.E.CreatedAt, "kind": pe.E.Kind, "content": string(pe.E.Content), "tags": pe.E.Tags, "sig": hex.Enc(pe.E.Sig), } // Add policy-specific fields if pe.LoggedInPubkey != "" { safeEvent["logged_in_pubkey"] = pe.LoggedInPubkey } if pe.IPAddress != "" { safeEvent["ip_address"] = pe.IPAddress } if pe.AccessType != "" { safeEvent["access_type"] = pe.AccessType } return json.Marshal(safeEvent) } // PolicyResponse represents a response from the policy script. // The script should return JSON with these fields to indicate its decision. type PolicyResponse struct { ID string `json:"id"` Action string `json:"action"` // accept, reject, or shadowReject Msg string `json:"msg"` // NIP-20 response message (only used for reject) } // ScriptRunner manages a single policy script process. // Each unique script path gets its own independent runner with its own goroutine. type ScriptRunner struct { ctx context.Context cancel context.CancelFunc configDir string scriptPath string currentCmd *exec.Cmd currentCancel context.CancelFunc mutex sync.RWMutex isRunning bool isStarting bool stdin io.WriteCloser stdout io.ReadCloser stderr io.ReadCloser responseChan chan PolicyResponse startupChan chan error } // PolicyManager handles multiple policy script runners. // It manages the lifecycle of policy scripts, handles communication with them, // and provides resilient operation with automatic restart capabilities. // Each unique script path gets its own ScriptRunner instance. type PolicyManager struct { ctx context.Context cancel context.CancelFunc configDir string configPath string // Path to policy.json file scriptPath string // Default script path for backward compatibility enabled bool mutex sync.RWMutex runners map[string]*ScriptRunner // Map of script path -> runner } // ConfigPath returns the path to the policy configuration file. // This is used by hot-reload handlers to know where to save updated policy. func (pm *PolicyManager) ConfigPath() string { return pm.configPath } // P represents a complete policy configuration for a Nostr relay. // It defines access control rules, kind filtering, and default behavior. // Policies are evaluated in order: global rules, kind filtering, specific rules, then default policy. type P struct { // Kind is policies for accepting or rejecting events by kind number. Kind Kinds `json:"kind"` // rules is a map of rules for criteria that must be met for the event to be allowed to be written to the relay. // Unexported to enforce use of public API methods (CheckPolicy, IsEnabled). rules map[int]Rule // Global is a rule set that applies to all events. 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"` // 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) ownersBin [][]byte // Binary cache for policy-defined owner pubkeys // followsMx protects all follows-related caches from concurrent access. // This includes policyFollows, Global.readFollowsFollowsBin, Global.writeFollowsFollowsBin, // and rule-specific follows whitelists. // Use RLock for reads (CheckPolicy) and Lock for writes (Update*Follows*). followsMx sync.RWMutex // manager handles policy script execution. // Unexported to enforce use of public API methods (CheckPolicy, IsEnabled). manager *PolicyManager } // 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"` 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. func (p *P) UnmarshalJSON(data []byte) error { var shadow pJSON if err := json.Unmarshal(data, &shadow); err != nil { return err } p.Kind = shadow.Kind p.rules = shadow.Rules p.Global = shadow.Global 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 { 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) } } // 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 } // New creates a new policy from JSON configuration. // If policyJSON is empty, returns a policy with default settings. // The default_policy field defaults to "allow" if not specified. // Returns an error if the policy JSON contains invalid values (e.g., invalid // ISO-8601 duration format for max_expiry_duration, invalid regex patterns, etc.). func New(policyJSON []byte) (p *P, err error) { p = &P{ DefaultPolicy: "allow", // Set default value } if len(policyJSON) > 0 { // Validate JSON before loading to fail fast on invalid configurations. // This prevents silent failures where invalid values (like "T10M" instead // of "PT10M" for max_expiry_duration) are ignored and constraints don't apply. if err = p.ValidateJSON(policyJSON); err != nil { return nil, fmt.Errorf("policy validation failed: %v", err) } if err = json.Unmarshal(policyJSON, p); chk.E(err) { return nil, fmt.Errorf("failed to unmarshal policy JSON: %v", err) } } // Ensure default policy is valid if p.DefaultPolicy == "" { p.DefaultPolicy = "allow" } // Populate binary caches for all rules (including global rule) p.Global.populateBinaryCache() for kind := range p.rules { rule := p.rules[kind] // Get a copy rule.populateBinaryCache() p.rules[kind] = rule // Store the modified copy back } return } // IsPartyInvolved checks if the given pubkey is a party involved in the event. // A party is involved if they are either: // 1. The author of the event (ev.Pubkey == userPubkey) // 2. Mentioned in a p-tag of the event // // Both ev.Pubkey and userPubkey must be binary ([]byte), not hex-encoded. // P-tags may be stored in either binary-optimized format (33 bytes) or hex format. // // This is the single source of truth for "parties_involved" / "privileged" checks. func IsPartyInvolved(ev *event.E, userPubkey []byte) bool { // Must be authenticated if len(userPubkey) == 0 { return false } // Check if user is the author if bytes.Equal(ev.Pubkey, userPubkey) { return true } // Check if user is in p tags pTags := ev.Tags.GetAll([]byte("p")) for _, pTag := range pTags { // ValueHex() handles both binary and hex storage formats automatically pt, err := hex.Dec(string(pTag.ValueHex())) if err != nil { // Skip malformed tags continue } if bytes.Equal(pt, userPubkey) { return true } } return false } // IsEnabled returns whether the policy system is enabled and ready to process events. // This is the public API for checking if policy filtering should be applied. func (p *P) IsEnabled() bool { return p != nil && p.manager != nil && p.manager.IsEnabled() } // ConfigPath returns the path to the policy configuration file. // Delegates to the internal PolicyManager. func (p *P) ConfigPath() string { if p == nil || p.manager == nil { return "" } return p.manager.ConfigPath() } // getDefaultPolicyAction returns true if the default policy is "allow", false if "deny" func (p *P) getDefaultPolicyAction() (allowed bool) { switch p.DefaultPolicy { case "deny": return false case "allow", "": return true default: // Invalid value, default to allow return true } } // NewWithManager creates a new policy with a policy manager for script execution. // It initializes the policy manager, loads configuration from files, and starts // background processes for script management and periodic health checks. // // The customPolicyPath parameter allows overriding the default policy file location. // If empty, uses the default path: $HOME/.config/{appName}/policy.json // If provided, it MUST be an absolute path (starting with /) or the function will panic. func NewWithManager(ctx context.Context, appName string, enabled bool, customPolicyPath string) *P { configDir := filepath.Join(xdg.ConfigHome, appName) scriptPath := filepath.Join(configDir, "policy.sh") // Determine the policy config path var configPath string if customPolicyPath != "" { // Validate that custom path is absolute if !filepath.IsAbs(customPolicyPath) { panic(fmt.Sprintf("FATAL: ORLY_POLICY_PATH must be an ABSOLUTE path (starting with /), got: %q", customPolicyPath)) } configPath = customPolicyPath // Update configDir to match the custom path's directory for script resolution configDir = filepath.Dir(customPolicyPath) scriptPath = filepath.Join(configDir, "policy.sh") log.I.F("using custom policy path: %s", configPath) } else { configPath = filepath.Join(configDir, "policy.json") } ctx, cancel := context.WithCancel(ctx) manager := &PolicyManager{ ctx: ctx, cancel: cancel, configDir: configDir, configPath: configPath, scriptPath: scriptPath, enabled: enabled, runners: make(map[string]*ScriptRunner), } // Load policy configuration from JSON file policy := &P{ DefaultPolicy: "allow", // Set default value manager: manager, } if enabled { if err := policy.LoadFromFile(configPath); err != nil { log.W.F( "policy enabled but config failed to load from %s: %v — disabling policy", configPath, err, ) } else { log.I.F("loaded policy configuration from %s", configPath) // Start the policy script if it exists and is enabled go manager.startPolicyIfExists() // Start periodic check for policy script availability go manager.periodicCheck() } } return policy } // getOrCreateRunner gets an existing runner for the script path or creates a new one. // This method is thread-safe and ensures only one runner exists per unique script path. func (pm *PolicyManager) getOrCreateRunner(scriptPath string) *ScriptRunner { pm.mutex.Lock() defer pm.mutex.Unlock() // Check if runner already exists if runner, exists := pm.runners[scriptPath]; exists { return runner } // Create new runner runnerCtx, runnerCancel := context.WithCancel(pm.ctx) runner := &ScriptRunner{ ctx: runnerCtx, cancel: runnerCancel, configDir: pm.configDir, scriptPath: scriptPath, responseChan: make(chan PolicyResponse, 100), startupChan: make(chan error, 1), } pm.runners[scriptPath] = runner // Start periodic check for this runner go runner.periodicCheck() return runner } // ScriptRunner methods // IsRunning returns whether the script is currently running. func (sr *ScriptRunner) IsRunning() bool { sr.mutex.RLock() defer sr.mutex.RUnlock() return sr.isRunning } // ensureRunning ensures the script is running, starting it if necessary. func (sr *ScriptRunner) ensureRunning() error { sr.mutex.Lock() // Check if already running if sr.isRunning { sr.mutex.Unlock() return nil } // Check if already starting if sr.isStarting { sr.mutex.Unlock() // Wait for startup to complete select { case err := <-sr.startupChan: if err != nil { return fmt.Errorf("script startup failed: %v", err) } // Double-check it's actually running after receiving signal sr.mutex.RLock() running := sr.isRunning sr.mutex.RUnlock() if !running { return fmt.Errorf("script startup completed but process is not running") } return nil case <-time.After(10 * time.Second): return fmt.Errorf("script startup timeout") case <-sr.ctx.Done(): return fmt.Errorf("script context cancelled") } } // Mark as starting sr.isStarting = true sr.mutex.Unlock() // Start the script in a goroutine go func() { err := sr.Start() sr.mutex.Lock() sr.isStarting = false sr.mutex.Unlock() // Signal startup completion (non-blocking) // Drain any stale value first, then send select { case <-sr.startupChan: default: } select { case sr.startupChan <- err: default: // Channel should be empty now, but if it's full, try again sr.startupChan <- err } }() // Wait for startup to complete select { case err := <-sr.startupChan: if err != nil { return fmt.Errorf("script startup failed: %v", err) } // Double-check it's actually running after receiving signal sr.mutex.RLock() running := sr.isRunning sr.mutex.RUnlock() if !running { return fmt.Errorf("script startup completed but process is not running") } return nil case <-time.After(10 * time.Second): sr.mutex.Lock() sr.isStarting = false sr.mutex.Unlock() return fmt.Errorf("script startup timeout") case <-sr.ctx.Done(): sr.mutex.Lock() sr.isStarting = false sr.mutex.Unlock() return fmt.Errorf("script context cancelled") } } // Start starts the script process. func (sr *ScriptRunner) Start() error { sr.mutex.Lock() defer sr.mutex.Unlock() if sr.isRunning { return fmt.Errorf("script is already running") } if _, err := os.Stat(sr.scriptPath); os.IsNotExist(err) { return fmt.Errorf("script does not exist at %s", sr.scriptPath) } // Create a new context for this command cmdCtx, cmdCancel := context.WithCancel(sr.ctx) // Make the script executable if err := os.Chmod(sr.scriptPath, 0755); chk.E(err) { cmdCancel() return fmt.Errorf("failed to make script executable: %v", err) } // Start the script cmd := exec.CommandContext(cmdCtx, sr.scriptPath) cmd.Dir = sr.configDir // Set up stdio pipes for communication stdin, err := cmd.StdinPipe() if chk.E(err) { cmdCancel() return fmt.Errorf("failed to create stdin pipe: %v", err) } stdout, err := cmd.StdoutPipe() if chk.E(err) { cmdCancel() stdin.Close() return fmt.Errorf("failed to create stdout pipe: %v", err) } stderr, err := cmd.StderrPipe() if chk.E(err) { cmdCancel() stdin.Close() stdout.Close() return fmt.Errorf("failed to create stderr pipe: %v", err) } // Start the command if err := cmd.Start(); chk.E(err) { cmdCancel() stdin.Close() stdout.Close() stderr.Close() return fmt.Errorf("failed to start script: %v", err) } sr.currentCmd = cmd sr.currentCancel = cmdCancel sr.stdin = stdin sr.stdout = stdout sr.stderr = stderr sr.isRunning = true // Start response reader in background go sr.readResponses() // Log stderr output in background go sr.logOutput(stdout, stderr) // Monitor the process go sr.monitorProcess() log.I.F( "policy script started: %s (pid=%d)", sr.scriptPath, cmd.Process.Pid, ) return nil } // Stop stops the script gracefully. func (sr *ScriptRunner) Stop() error { sr.mutex.Lock() if !sr.isRunning || sr.currentCmd == nil { sr.mutex.Unlock() return fmt.Errorf("script is not running") } // Close stdin first to signal the script to exit if sr.stdin != nil { sr.stdin.Close() } // Cancel the context if sr.currentCancel != nil { sr.currentCancel() } // Get the process reference before releasing the lock process := sr.currentCmd.Process sr.mutex.Unlock() // Wait for graceful shutdown with timeout // Note: monitorProcess() is the one that calls cmd.Wait() and cleans up // We just wait for it to finish by polling isRunning gracefulShutdown := false for i := 0; i < 50; i++ { // 5 seconds total (50 * 100ms) time.Sleep(100 * time.Millisecond) sr.mutex.RLock() running := sr.isRunning sr.mutex.RUnlock() if !running { gracefulShutdown = true log.I.F("policy script stopped gracefully: %s", sr.scriptPath) break } } if !gracefulShutdown { // Force kill after timeout log.W.F( "policy script did not stop gracefully, sending SIGKILL: %s", sr.scriptPath, ) if process != nil { if err := process.Kill(); chk.E(err) { log.E.F("failed to kill script process: %v", err) } } // Wait a bit more for monitorProcess to clean up for i := 0; i < 30; i++ { // 3 more seconds time.Sleep(100 * time.Millisecond) sr.mutex.RLock() running := sr.isRunning sr.mutex.RUnlock() if !running { break } } } return nil } // ProcessEvent sends an event to the script and waits for a response. func (sr *ScriptRunner) ProcessEvent(evt *PolicyEvent) ( *PolicyResponse, error, ) { log.D.F("processing event: %s", evt.Serialize()) sr.mutex.RLock() if !sr.isRunning || sr.stdin == nil { sr.mutex.RUnlock() return nil, fmt.Errorf("script is not running") } stdin := sr.stdin sr.mutex.RUnlock() // Serialize the event to JSON eventJSON, err := json.Marshal(evt) if chk.E(err) { return nil, fmt.Errorf("failed to serialize event: %v", err) } // Send the event JSON to the script (newline-terminated) if _, err := stdin.Write(append(eventJSON, '\n')); chk.E(err) { // Check if it's a broken pipe error, which means the script has died if strings.Contains(err.Error(), "broken pipe") || strings.Contains(err.Error(), "closed pipe") { log.E.F( "policy script %s stdin closed (broken pipe) - script may have crashed or exited prematurely", sr.scriptPath, ) // Mark as not running so it will be restarted on next periodic check sr.mutex.Lock() sr.isRunning = false sr.mutex.Unlock() } return nil, fmt.Errorf("failed to write event to script: %v", err) } // Wait for response with timeout select { case response := <-sr.responseChan: log.D.S("response", response) return &response, nil case <-time.After(5 * time.Second): log.W.F( "policy script %s response timeout - script may not be responding correctly (check for debug output on stdout)", sr.scriptPath, ) return nil, fmt.Errorf("script response timeout") case <-sr.ctx.Done(): return nil, fmt.Errorf("script context cancelled") } } // readResponses reads JSONL responses from the script func (sr *ScriptRunner) readResponses() { if sr.stdout == nil { return } scanner := bufio.NewScanner(sr.stdout) nonJSONLineCount := 0 for scanner.Scan() { line := scanner.Text() if line == "" { continue } log.D.F("policy response: %s", line) var response PolicyResponse if err := json.Unmarshal([]byte(line), &response); chk.E(err) { // Check if this looks like debug output if strings.HasPrefix(line, "{") { // Looks like JSON but failed to parse log.E.F( "failed to parse policy response from %s: %v\nLine: %s", sr.scriptPath, err, line, ) } else { // Definitely not JSON - probably debug output nonJSONLineCount++ if nonJSONLineCount <= 3 { log.W.F( "policy script %s produced non-JSON output on stdout (should only output JSONL): %q", sr.scriptPath, line, ) } else if nonJSONLineCount == 4 { log.W.F( "policy script %s continues to produce non-JSON output - suppressing further warnings", sr.scriptPath, ) } log.W.F( "IMPORTANT: Policy scripts must ONLY write JSON responses to stdout. Use stderr or a log file for debug output.", ) } continue } // Send response to channel (non-blocking) select { case sr.responseChan <- response: default: log.W.F( "policy response channel full for %s, dropping response", sr.scriptPath, ) } } if err := scanner.Err(); chk.E(err) { log.E.F( "error reading policy responses from %s: %v", sr.scriptPath, err, ) } } // logOutput logs the output from stderr func (sr *ScriptRunner) logOutput(_ /* stdout */, stderr io.ReadCloser) { defer stderr.Close() // Only log stderr, stdout is used by readResponses go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { line := scanner.Text() if line != "" { // Log script stderr output through relay logging system log.I.F("[policy script %s] %s", sr.scriptPath, line) } } if err := scanner.Err(); chk.E(err) { log.E.F("error reading stderr from policy script %s: %v", sr.scriptPath, err) } }() } // monitorProcess monitors the script process and cleans up when it exits func (sr *ScriptRunner) monitorProcess() { if sr.currentCmd == nil { return } err := sr.currentCmd.Wait() sr.mutex.Lock() defer sr.mutex.Unlock() // Clean up pipes if sr.stdin != nil { sr.stdin.Close() sr.stdin = nil } if sr.stdout != nil { sr.stdout.Close() sr.stdout = nil } if sr.stderr != nil { sr.stderr.Close() sr.stderr = nil } sr.isRunning = false sr.currentCmd = nil sr.currentCancel = nil if err != nil { log.E.F( "policy script exited with error: %s: %v, will retry periodically", sr.scriptPath, err, ) } else { log.I.F("policy script exited normally: %s", sr.scriptPath) } } // periodicCheck periodically checks if script becomes available and attempts to restart failed scripts. func (sr *ScriptRunner) periodicCheck() { ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for { select { case <-sr.ctx.Done(): return case <-ticker.C: sr.mutex.RLock() running := sr.isRunning sr.mutex.RUnlock() // Check if script is not running and try to start it if !running { if _, err := os.Stat(sr.scriptPath); err == nil { // Script exists but not running, try to start go func() { if err := sr.Start(); err != nil { log.E.F( "failed to restart policy script %s: %v, will retry in next cycle", sr.scriptPath, err, ) } else { log.I.F( "policy script restarted successfully: %s", sr.scriptPath, ) } }() } } } } } // LoadFromFile loads policy configuration from a JSON file. // Returns an error if the file doesn't exist, can't be read, or contains invalid JSON. func (p *P) LoadFromFile(configPath string) error { if _, err := os.Stat(configPath); os.IsNotExist(err) { return fmt.Errorf( "policy configuration file does not exist: %s", configPath, ) } configData, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("failed to read policy configuration file: %v", err) } if len(configData) == 0 { return fmt.Errorf("policy configuration file is empty") } if err := json.Unmarshal(configData, p); err != nil { return fmt.Errorf("failed to parse policy configuration JSON: %v", err) } // Populate binary caches for all rules (including global rule) p.Global.populateBinaryCache() for kind, rule := range p.rules { rule.populateBinaryCache() p.rules[kind] = rule // Update the map with the modified rule } return nil } // CheckPolicy checks if an event is allowed based on the policy configuration. // The access parameter should be "write" for accepting events or "read" for filtering events. // Returns true if the event is allowed, false if denied, and an error if validation fails. // // Policy evaluation order (more specific rules take precedence): // 1. Kinds whitelist/blacklist - if kind is blocked, deny immediately // 2. Kind-specific rule - if exists for this kind, use it exclusively // 3. Global rule - fallback if no kind-specific rule exists // 4. Default policy - fallback if no rules apply // // Thread-safety: Uses followsMx.RLock to protect reads of follows whitelists during policy checks. // Write operations (Update*) acquire the write lock, which blocks concurrent reads. func (p *P) CheckPolicy( access string, ev *event.E, loggedInPubkey []byte, ipAddress string, ) (allowed bool, err error) { // Handle nil policy - this should not happen if policy is enabled // If policy is enabled but p is nil, it's a configuration error if p == nil { log.F.Ln("FATAL: CheckPolicy called on nil policy - this indicates misconfiguration. " + "If ORLY_POLICY_ENABLED=true, ensure policy configuration is valid.") return false, fmt.Errorf("policy is nil but policy checking is enabled - check configuration") } // Handle nil event if ev == nil { return false, fmt.Errorf("event cannot be nil") } // Acquire read lock to protect follows whitelists during policy check p.followsMx.RLock() defer p.followsMx.RUnlock() // ========================================================================== // STEP 1: Check kinds whitelist/blacklist (applies before any rule checks) // ========================================================================== if !p.checkKindsPolicy(access, ev.Kind) { return false, nil } // ========================================================================== // STEP 2: Check KIND-SPECIFIC rule FIRST (more specific = higher priority) // ========================================================================== // If kind-specific rule exists and accepts, that's final - global is ignored. rule, hasKindRule := p.rules[int(ev.Kind)] if hasKindRule { // Check if script is present and enabled for this kind if rule.Script != "" && p.manager != nil { if p.manager.IsEnabled() { // Check if script file exists before trying to use it if _, err := os.Stat(rule.Script); err == nil { // Script exists, try to use it log.D.F("using policy script for kind %d: %s", ev.Kind, rule.Script) allowed, err := p.checkScriptPolicy( access, ev, rule.Script, loggedInPubkey, ipAddress, ) if err == nil { // Script ran successfully, return its decision return allowed, nil } // Script failed, fall through to apply other criteria log.W.F("policy script check failed for kind %d: %v, applying other criteria", ev.Kind, err) } else { // Script configured but doesn't exist log.W.F("policy script configured for kind %d but not found at %s: %v, applying other criteria", ev.Kind, rule.Script, err) } // Script doesn't exist or failed, fall through to apply other criteria } else { // Policy manager is disabled, fall back to default policy log.D.F("policy manager is disabled for kind %d, falling back to default policy (%s)", ev.Kind, p.DefaultPolicy) return p.getDefaultPolicyAction(), nil } } // Apply kind-specific rule-based filtering return p.checkRulePolicy(access, ev, rule, loggedInPubkey) } // ========================================================================== // STEP 3: No kind-specific rule - check GLOBAL rule as fallback // ========================================================================== // Check if global rule has any configuration if p.Global.hasAnyRules() { // Apply global rule filtering return p.checkRulePolicy(access, ev, p.Global, loggedInPubkey) } // ========================================================================== // STEP 4: No kind-specific or global rules - use default policy // ========================================================================== return p.getDefaultPolicyAction(), nil } // checkKindsPolicy checks if the event kind is allowed for the given access type. // Logic: // 1. If explicit whitelist exists, use it (but respect permissive flags for read/write) // 2. If explicit blacklist exists, use it (but respect permissive flags for read/write) // 3. Otherwise, kinds with defined rules are implicitly allowed, others denied (with permissive overrides) // // Permissive flags (set on Global rule): // - ReadAllowPermissive: Allows READ access for kinds not in whitelist (write still restricted) // - WriteAllowPermissive: Allows WRITE access for kinds not in whitelist (uses global rule constraints) func (p *P) checkKindsPolicy(access string, kind uint16) bool { // If whitelist is present, only allow whitelisted kinds (with permissive overrides) if len(p.Kind.Whitelist) > 0 { for _, allowedKind := range p.Kind.Whitelist { if kind == uint16(allowedKind) { return true } } // Kind not in whitelist - check permissive flags if access == "read" && p.Global.ReadAllowPermissive { log.D.F("read_allow_permissive: allowing read for kind %d not in whitelist", kind) return true // Allow read even though kind not whitelisted } if access == "write" && p.Global.WriteAllowPermissive { log.D.F("write_allow_permissive: allowing write for kind %d not in whitelist (global rules apply)", kind) return true // Allow write even though kind not whitelisted, global rule will be applied } return false } // If blacklist is present, deny blacklisted kinds if len(p.Kind.Blacklist) > 0 { for _, deniedKind := range p.Kind.Blacklist { if kind == uint16(deniedKind) { // Kind is explicitly blacklisted - permissive flags don't override blacklist return false } } // Not in blacklist - check if rule exists for implicit whitelist _, hasRule := p.rules[int(kind)] if hasRule { return true } // No kind-specific rule - check permissive flags if access == "read" && p.Global.ReadAllowPermissive { log.D.F("read_allow_permissive: allowing read for kind %d (not blacklisted, no rule)", kind) return true } if access == "write" && p.Global.WriteAllowPermissive { log.D.F("write_allow_permissive: allowing write for kind %d (not blacklisted, no rule)", kind) return true } return false // Only allow if there's a rule defined } // No explicit whitelist or blacklist // Behavior depends on whether default_policy is explicitly set: // - If default_policy is explicitly "allow", allow all kinds (rules add constraints, not restrictions) // - If default_policy is unset or "deny", use implicit whitelist (only allow kinds with rules) // - If global rule has any configuration, allow kinds through for global rule checking // - Permissive flags can override implicit whitelist behavior if len(p.rules) > 0 { // If default_policy is explicitly "allow", don't use implicit whitelist if p.DefaultPolicy == "allow" { return true } // Implicit whitelist mode - only allow kinds with specific rules _, hasRule := p.rules[int(kind)] if hasRule { return true } // No kind-specific rule, but check if global rule exists if p.Global.hasAnyRules() { return true // Allow through for global rule check } // Check permissive flags for implicit whitelist override if access == "read" && p.Global.ReadAllowPermissive { log.D.F("read_allow_permissive: allowing read for kind %d (implicit whitelist override)", kind) return true } if access == "write" && p.Global.WriteAllowPermissive { log.D.F("write_allow_permissive: allowing write for kind %d (implicit whitelist override)", kind) return true } return false } // No kind-specific rules - check if global rule exists if p.Global.hasAnyRules() { return true // Allow through for global rule check } // No rules at all - fall back to default policy return p.getDefaultPolicyAction() } // checkGlobalFollowsWhitelistAccess checks if the user is explicitly granted access // via the global rule's follows whitelists (read_follows_whitelist or write_follows_whitelist). // This grants access that bypasses the default policy for kinds without specific rules. // Note: p should never be nil here - caller (CheckPolicy) already validates this. func (p *P) checkGlobalFollowsWhitelistAccess(access string, loggedInPubkey []byte) bool { if len(loggedInPubkey) == 0 { return false } if access == "read" { // Check if user is in global read follows whitelist if p.Global.HasReadFollowsWhitelist() && p.Global.IsInReadFollowsWhitelist(loggedInPubkey) { return true } // Also check legacy WriteAllowFollows and FollowsWhitelistAdmins for read access if p.Global.WriteAllowFollows && p.PolicyFollowWhitelistEnabled && p.IsPolicyFollow(loggedInPubkey) { return true } if p.Global.HasFollowsWhitelistAdmins() && p.Global.IsInFollowsWhitelist(loggedInPubkey) { return true } } else if access == "write" { // Check if user is in global write follows whitelist if p.Global.HasWriteFollowsWhitelist() && p.Global.IsInWriteFollowsWhitelist(loggedInPubkey) { return true } // Also check legacy WriteAllowFollows and FollowsWhitelistAdmins for write access if p.Global.WriteAllowFollows && p.PolicyFollowWhitelistEnabled && p.IsPolicyFollow(loggedInPubkey) { return true } if p.Global.HasFollowsWhitelistAdmins() && p.Global.IsInFollowsWhitelist(loggedInPubkey) { return true } } return false } // checkGlobalRulePolicy checks if the event passes the global rule filter // Note: p should never be nil here - caller (CheckPolicy) already validates this. func (p *P) checkGlobalRulePolicy( access string, ev *event.E, loggedInPubkey []byte, ) bool { // Skip if no global rules are configured if !p.Global.hasAnyRules() { return true } // Apply global rule filtering allowed, err := p.checkRulePolicy(access, ev, p.Global, loggedInPubkey) if err != nil { log.E.F("global rule policy check failed: %v", err) return false } return allowed } // checkRulePolicy evaluates rule-based access control with the following logic: // // READ ACCESS (default-permissive): // - Denied if in read_deny list // - If read_allow, read_follows_whitelist, or privileged is set, user must pass one of those checks // - Otherwise, read is allowed by default // // WRITE ACCESS (default-permissive): // - Denied if in write_deny list // - Universal constraints (size, tags, age) apply to writes only // - If write_allow or write_follows_whitelist is set, user must pass one of those checks // - Otherwise, write is allowed by default // // PRIVILEGED: Only applies to READ operations (party-involved check) func (p *P) checkRulePolicy( access string, ev *event.E, rule Rule, loggedInPubkey []byte, ) (allowed bool, err error) { log.T.F("checkRulePolicy: access=%s kind=%d readFollowsFollowsBin_len=%d readFollowsWhitelistBin_len=%d HasReadFollowsWhitelist=%v", access, ev.Kind, len(rule.readFollowsFollowsBin), len(rule.readFollowsWhitelistBin), rule.HasReadFollowsWhitelist()) // =================================================================== // STEP 1: Universal Constraints (WRITE ONLY - apply to everyone) // =================================================================== if access == "write" { // Check size limits if rule.SizeLimit != nil { eventSize := int64(len(ev.Serialize())) if eventSize > *rule.SizeLimit { return false, nil } } if rule.ContentLimit != nil { contentSize := int64(len(ev.Content)) if contentSize > *rule.ContentLimit { return false, nil } } // Check required tags if len(rule.MustHaveTags) > 0 { for _, requiredTag := range rule.MustHaveTags { if ev.Tags.GetFirst([]byte(requiredTag)) == nil { return false, nil } } } // Check expiry time (uses maxExpirySeconds which is parsed from MaxExpiryDuration or MaxExpiry) if rule.maxExpirySeconds != nil && *rule.maxExpirySeconds > 0 { expiryTag := ev.Tags.GetFirst([]byte("expiration")) if expiryTag == nil { return false, nil // Must have expiry if max_expiry is set } // Parse expiry timestamp and validate it's within allowed duration from created_at expiryStr := string(expiryTag.Value()) expiryTs, parseErr := strconv.ParseInt(expiryStr, 10, 64) if parseErr != nil { log.D.F("invalid expiration tag value %q: %v", expiryStr, parseErr) return false, nil // Invalid expiry format } maxAllowedExpiry := ev.CreatedAt + *rule.maxExpirySeconds if expiryTs >= maxAllowedExpiry { log.D.F("expiration %d exceeds max allowed %d (created_at %d + max_expiry %d)", expiryTs, maxAllowedExpiry, ev.CreatedAt, *rule.maxExpirySeconds) return false, nil // Expiry too far in the future } } // Check ProtectedRequired (NIP-70: events must have "-" tag) if rule.ProtectedRequired { protectedTag := ev.Tags.GetFirst([]byte("-")) if protectedTag == nil { log.D.F("protected_required: event missing '-' tag (NIP-70)") return false, nil // Must have protected tag } } // Check IdentifierRegex (validates "d" tag values) if rule.identifierRegexCache != nil { dTags := ev.Tags.GetAll([]byte("d")) if len(dTags) == 0 { log.D.F("identifier_regex: event missing 'd' tag") return false, nil // Must have d tag if identifier_regex is set } for _, dTag := range dTags { value := string(dTag.Value()) if !rule.identifierRegexCache.MatchString(value) { log.D.F("identifier_regex: d tag value %q does not match pattern %q", value, rule.IdentifierRegex) return false, nil } } } // Check MaxAgeOfEvent (maximum age of event in seconds) if rule.MaxAgeOfEvent != nil && *rule.MaxAgeOfEvent > 0 { currentTime := time.Now().Unix() maxAllowedTime := currentTime - *rule.MaxAgeOfEvent if ev.CreatedAt < maxAllowedTime { return false, nil // Event is too old } } // Check MaxAgeEventInFuture (maximum time event can be in the future in seconds) if rule.MaxAgeEventInFuture != nil && *rule.MaxAgeEventInFuture > 0 { currentTime := time.Now().Unix() maxFutureTime := currentTime + *rule.MaxAgeEventInFuture if ev.CreatedAt > maxFutureTime { return false, nil // Event is too far in the future } } // Check tag validation rules (regex patterns) // NOTE: TagValidation only validates tags that ARE present on the event. // To REQUIRE a tag to exist, use MustHaveTags instead. if 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, skip validation for this tag type // (TagValidation validates format, not presence - use MustHaveTags for presence) if len(tags) == 0 { continue } // 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) // =================================================================== if access == "write" { // Check write deny list - deny specific users from submitting events if len(rule.writeDenyBin) > 0 { for _, deniedPubkey := range rule.writeDenyBin { if utils.FastEqual(loggedInPubkey, deniedPubkey) { return false, nil // Submitter explicitly denied } } } else if len(rule.WriteDeny) > 0 { // Fallback: binary cache not populated, use hex comparison loggedInPubkeyHex := hex.Enc(loggedInPubkey) for _, deniedPubkey := range rule.WriteDeny { if loggedInPubkeyHex == deniedPubkey { return false, nil // Submitter explicitly denied } } } } else if access == "read" { // Check read deny list if len(rule.readDenyBin) > 0 { for _, deniedPubkey := range rule.readDenyBin { if utils.FastEqual(loggedInPubkey, deniedPubkey) { return false, nil // Explicitly denied } } } else if len(rule.ReadDeny) > 0 { // Fallback: binary cache not populated, use hex comparison loggedInPubkeyHex := hex.Enc(loggedInPubkey) for _, deniedPubkey := range rule.ReadDeny { if loggedInPubkeyHex == deniedPubkey { return false, nil // Explicitly denied } } } } // =================================================================== // STEP 3: Legacy WriteAllowFollows (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 (legacy behavior) 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 } } // FollowsWhitelistAdmins grants access to follows of specific admin pubkeys for this rule // This is a per-rule alternative to WriteAllowFollows which uses global PolicyAdmins (DEPRECATED) if rule.HasFollowsWhitelistAdmins() { if rule.IsInFollowsWhitelist(loggedInPubkey) { log.D.F("follows_whitelist_admins granted %s access for kind %d", access, ev.Kind) return true, nil // Allow access from rule-specific admin follow } } // =================================================================== // STEP 4: New Follows Whitelist Checks (separate read/write) // =================================================================== if access == "read" { // Check ReadFollowsWhitelist - if set, it acts as a whitelist if rule.HasReadFollowsWhitelist() { if rule.IsInReadFollowsWhitelist(loggedInPubkey) { log.D.F("read_follows_whitelist granted read access for kind %d", ev.Kind) return true, nil } // ReadFollowsWhitelist is set but user is not in it // Continue to check other access methods (privileged, read_allow) } } else if access == "write" { // Check WriteFollowsWhitelist - if set, it acts as a whitelist if rule.HasWriteFollowsWhitelist() { if rule.IsInWriteFollowsWhitelist(loggedInPubkey) { log.D.F("write_follows_whitelist granted write access for kind %d", ev.Kind) return true, nil } // WriteFollowsWhitelist is set but user is not in it - must check write_allow too } } // =================================================================== // STEP 5: Read Access Control // =================================================================== if access == "read" { hasReadAllowList := len(rule.readAllowBin) > 0 || len(rule.ReadAllow) > 0 hasReadFollowsWhitelist := rule.HasReadFollowsWhitelist() // Include deprecated FollowsWhitelistAdmins for backward compatibility (it grants read+write) hasLegacyFollowsWhitelist := rule.HasFollowsWhitelistAdmins() userIsPrivileged := rule.Privileged && IsPartyInvolved(ev, loggedInPubkey) // Check if user is in read allow list userInAllowList := false if len(rule.readAllowBin) > 0 { for _, allowedPubkey := range rule.readAllowBin { if utils.FastEqual(loggedInPubkey, allowedPubkey) { userInAllowList = true break } } } else if len(rule.ReadAllow) > 0 { loggedInPubkeyHex := hex.Enc(loggedInPubkey) for _, allowedPubkey := range rule.ReadAllow { if loggedInPubkeyHex == allowedPubkey { userInAllowList = true break } } } // Determine if any read whitelist restriction is active // Note: Legacy FollowsWhitelistAdmins also counts as a read restriction for backward compatibility hasReadRestriction := hasReadAllowList || hasReadFollowsWhitelist || hasLegacyFollowsWhitelist || rule.Privileged if hasReadRestriction { // User must pass one of the configured access methods if userInAllowList { return true, nil } if userIsPrivileged { return true, nil } // User is in ReadFollowsWhitelist was already checked in STEP 4 // User in legacy FollowsWhitelistAdmins was already checked in STEP 3 // If we reach here with a read restriction, deny access return false, nil } // No read restriction configured - read is permissive by default return true, nil } // =================================================================== // STEP 6: Write Access Control // =================================================================== if access == "write" { hasWriteAllowList := len(rule.writeAllowBin) > 0 || len(rule.WriteAllow) > 0 hasWriteFollowsWhitelist := rule.HasWriteFollowsWhitelist() // Include deprecated FollowsWhitelistAdmins for backward compatibility hasLegacyFollowsWhitelist := rule.HasFollowsWhitelistAdmins() // Check if user is in write allow list userInAllowList := false if len(rule.writeAllowBin) > 0 { for _, allowedPubkey := range rule.writeAllowBin { if utils.FastEqual(loggedInPubkey, allowedPubkey) { userInAllowList = true break } } } else if len(rule.WriteAllow) > 0 { loggedInPubkeyHex := hex.Enc(loggedInPubkey) for _, allowedPubkey := range rule.WriteAllow { if loggedInPubkeyHex == allowedPubkey { userInAllowList = true break } } } // Determine if any write whitelist restriction is active // Note: Legacy FollowsWhitelistAdmins also counts as a write restriction for backward compatibility hasWriteIfTagged := len(rule.writeAllowIfTaggedBin) > 0 || len(rule.WriteAllowIfTagged) > 0 hasWriteRestriction := hasWriteAllowList || hasWriteFollowsWhitelist || hasLegacyFollowsWhitelist || hasWriteIfTagged if hasWriteRestriction { // User must pass one of the configured access methods if userInAllowList { return true, nil } // User in WriteFollowsWhitelist was already checked in STEP 4 // User in legacy FollowsWhitelistAdmins was already checked in STEP 3 // Check write_allow_if_tagged: allow if event p-tags a listed pubkey if hasWriteIfTagged { pTags := ev.Tags.GetAll([]byte("p")) for _, pTag := range pTags { pt, decErr := hex.Dec(string(pTag.ValueHex())) if decErr != nil { continue } for _, allowed := range rule.writeAllowIfTaggedBin { if utils.FastEqual(pt, allowed) { return true, nil } } } } // If we reach here with a write restriction, deny access return false, nil } // No write restriction configured - write is permissive by default return true, nil } // =================================================================== // STEP 7: Default Policy (fallback) // =================================================================== // If no specific rules matched, use the configured default policy return p.getDefaultPolicyAction(), nil } // checkScriptPolicy runs the policy script to determine if event should be allowed func (p *P) checkScriptPolicy( access string, ev *event.E, scriptPath string, loggedInPubkey []byte, ipAddress string, ) (allowed bool, err error) { if p.manager == nil { return false, fmt.Errorf("policy manager is not initialized") } // If policy is disabled, fall back to default policy immediately if !p.manager.IsEnabled() { log.W.F( "policy rule for kind %d is inactive (policy disabled), falling back to default policy (%s)", ev.Kind, p.DefaultPolicy, ) return p.getDefaultPolicyAction(), nil } // Check if script file exists if _, err := os.Stat(scriptPath); os.IsNotExist(err) { // Script doesn't exist, return error so caller can fall back to other criteria return false, fmt.Errorf( "policy script does not exist at %s", scriptPath, ) } // Get or create a runner for this specific script path runner := p.manager.getOrCreateRunner(scriptPath) // Policy is enabled, check if this runner is running if !runner.IsRunning() { // Try to start this runner and wait for it log.D.F("starting policy script for kind %d: %s", ev.Kind, scriptPath) if err := runner.ensureRunning(); err != nil { // Startup failed, return error so caller can fall back to other criteria return false, fmt.Errorf( "failed to start policy script %s: %v", scriptPath, err, ) } log.I.F("policy script started for kind %d: %s", ev.Kind, scriptPath) } // Create policy event with additional context policyEvent := &PolicyEvent{ E: ev, LoggedInPubkey: hex.Enc(loggedInPubkey), IPAddress: ipAddress, AccessType: access, } // Process event through policy script response, scriptErr := runner.ProcessEvent(policyEvent) if chk.E(scriptErr) { log.E.F( "policy rule for kind %d failed (script processing error: %v), falling back to default policy (%s)", ev.Kind, scriptErr, p.DefaultPolicy, ) // Fall back to default policy on script failure return p.getDefaultPolicyAction(), nil } // Handle script response switch response.Action { case "accept": return true, nil case "reject": return false, nil case "shadowReject": return false, nil // Treat as reject for policy purposes default: log.W.F( "policy rule for kind %d returned unknown action '%s', falling back to default policy (%s)", ev.Kind, response.Action, p.DefaultPolicy, ) // Fall back to default policy for unknown actions return p.getDefaultPolicyAction(), nil } } // PolicyManager methods // periodicCheck periodically checks if the default policy script becomes available. // This is for backward compatibility with the default script path. func (pm *PolicyManager) periodicCheck() { // Get or create runner for the default script path // This will also start its own periodic check pm.getOrCreateRunner(pm.scriptPath) } // startPolicyIfExists starts the default policy script if the file exists. // This is for backward compatibility with the default script path. // Only logs if the default script actually exists - missing default scripts are normal // when users configure rule-specific scripts. func (pm *PolicyManager) startPolicyIfExists() { if _, err := os.Stat(pm.scriptPath); err == nil { // Default script exists, try to start it log.I.F("found default policy script at %s, starting...", pm.scriptPath) runner := pm.getOrCreateRunner(pm.scriptPath) if err := runner.Start(); err != nil { log.E.F( "failed to start default policy script: %v, will retry periodically", err, ) } } // Silently ignore if default script doesn't exist - it's fine if rules use custom scripts } // IsEnabled returns whether the policy manager is enabled. func (pm *PolicyManager) IsEnabled() bool { return pm.enabled } // IsRunning returns whether the default policy script is currently running. // Deprecated: Use getOrCreateRunner(scriptPath).IsRunning() for specific scripts. func (pm *PolicyManager) IsRunning() bool { pm.mutex.RLock() defer pm.mutex.RUnlock() // Check if default script runner exists and is running if runner, exists := pm.runners[pm.scriptPath]; exists { return runner.IsRunning() } return false } // GetScriptPath returns the default script path. func (pm *PolicyManager) GetScriptPath() string { return pm.scriptPath } // Shutdown gracefully shuts down the policy manager and all running scripts. func (pm *PolicyManager) Shutdown() { pm.cancel() pm.mutex.Lock() defer pm.mutex.Unlock() // Stop all running scripts for path, runner := range pm.runners { if runner.IsRunning() { log.I.F("stopping policy script: %s", path) runner.Stop() } // Cancel the runner's context runner.cancel() } // 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 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) } } // Note: Owner-specific validation (non-empty owners) is done in ValidateOwnerPolicyUpdate // Validate regex patterns in tag_validation rules and new fields 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 IdentifierRegex pattern if rule.IdentifierRegex != "" { if _, err := regexp.Compile(rule.IdentifierRegex); err != nil { return fmt.Errorf("invalid identifier_regex pattern in kind %d: %v", kind, err) } } // Validate MaxExpiryDuration format if rule.MaxExpiryDuration != "" { if _, err := parseDuration(rule.MaxExpiryDuration); err != nil { return fmt.Errorf("invalid max_expiry_duration %q in kind %d: %v (format must be ISO-8601 duration, e.g. \"PT10M\" for 10 minutes, \"P7D\" for 7 days, \"P1DT12H\" for 1 day 12 hours)", rule.MaxExpiryDuration, kind, err) } } // Validate FollowsWhitelistAdmins pubkeys for _, admin := range rule.FollowsWhitelistAdmins { if len(admin) != 64 { return fmt.Errorf("invalid follows_whitelist_admins pubkey length in kind %d: %q (expected 64 hex characters)", kind, admin) } if _, err := hex.Dec(admin); err != nil { return fmt.Errorf("invalid follows_whitelist_admins pubkey format in kind %d: %q: %v", kind, admin, 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 global rule IdentifierRegex pattern if tempPolicy.Global.IdentifierRegex != "" { if _, err := regexp.Compile(tempPolicy.Global.IdentifierRegex); err != nil { return fmt.Errorf("invalid identifier_regex pattern in global rule: %v", err) } } // Validate global rule MaxExpiryDuration format if tempPolicy.Global.MaxExpiryDuration != "" { if _, err := parseDuration(tempPolicy.Global.MaxExpiryDuration); err != nil { return fmt.Errorf("invalid max_expiry_duration %q in global rule: %v (format must be ISO-8601 duration, e.g. \"PT10M\" for 10 minutes, \"P7D\" for 7 days, \"P1DT12H\" for 1 day 12 hours)", tempPolicy.Global.MaxExpiryDuration, err) } } // Validate global rule FollowsWhitelistAdmins pubkeys for _, admin := range tempPolicy.Global.FollowsWhitelistAdmins { if len(admin) != 64 { return fmt.Errorf("invalid follows_whitelist_admins pubkey length in global rule: %q (expected 64 hex characters)", admin) } if _, err := hex.Dec(admin); err != nil { return fmt.Errorf("invalid follows_whitelist_admins pubkey format in global rule: %q: %v", admin, 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) } // Validate permissive flags: if both read_allow_permissive AND write_allow_permissive are set // with a kind whitelist or blacklist, this makes the whitelist/blacklist meaningless hasKindRestriction := len(tempPolicy.Kind.Whitelist) > 0 || len(tempPolicy.Kind.Blacklist) > 0 if hasKindRestriction && tempPolicy.Global.ReadAllowPermissive && tempPolicy.Global.WriteAllowPermissive { return fmt.Errorf("invalid policy: both read_allow_permissive and write_allow_permissive cannot be enabled together with a kind whitelist or blacklist (this would make the kind restriction meaningless)") } 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.followsMx.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.Owners = tempPolicy.Owners p.policyAdminsBin = tempPolicy.policyAdminsBin p.ownersBin = tempPolicy.ownersBin // Note: policyFollows is NOT reset here - it will be refreshed separately p.followsMx.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, Owners: p.Owners, } // 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.followsMx.RLock() defer p.followsMx.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.followsMx.RLock() defer p.followsMx.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.followsMx.Lock() defer p.followsMx.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.followsMx.RLock() defer p.followsMx.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 } // 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.followsMx.RLock() defer p.followsMx.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. func (p *P) IsPolicyFollowWhitelistEnabled() bool { if p == nil { return false } return p.PolicyFollowWhitelistEnabled } // ============================================================================= // FollowsWhitelistAdmins Methods // ============================================================================= // GetAllFollowsWhitelistAdmins returns all unique admin pubkeys from FollowsWhitelistAdmins // across all rules (including global). Returns hex-encoded pubkeys. // This is used at startup to validate that kind 3 events exist for these admins. func (p *P) GetAllFollowsWhitelistAdmins() []string { if p == nil { return nil } // Use map to deduplicate admins := make(map[string]struct{}) // Check global rule for _, admin := range p.Global.FollowsWhitelistAdmins { admins[admin] = struct{}{} } // Check all kind-specific rules for _, rule := range p.rules { for _, admin := range rule.FollowsWhitelistAdmins { admins[admin] = struct{}{} } } // Convert map to slice result := make([]string, 0, len(admins)) for admin := range admins { result = append(result, admin) } return result } // GetRuleForKind returns the Rule for a specific kind, or nil if no rule exists. // This allows external code to access and modify rule-specific follows whitelists. func (p *P) GetRuleForKind(kind int) *Rule { if p == nil || p.rules == nil { return nil } if rule, exists := p.rules[kind]; exists { return &rule } return nil } // UpdateRuleFollowsWhitelist updates the follows whitelist for a specific kind's rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. // Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateRuleFollowsWhitelist(kind int, follows [][]byte) { if p == nil || p.rules == nil { return } p.followsMx.Lock() defer p.followsMx.Unlock() if rule, exists := p.rules[kind]; exists { rule.UpdateFollowsWhitelist(follows) p.rules[kind] = rule } } // UpdateGlobalFollowsWhitelist updates the follows whitelist for the global rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. // Note: We directly modify p.Global's unexported field because Global is a value type (not *Rule), // so calling p.Global.UpdateFollowsWhitelist() would operate on a copy and discard changes. // Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateGlobalFollowsWhitelist(follows [][]byte) { if p == nil { return } p.followsMx.Lock() defer p.followsMx.Unlock() p.Global.followsWhitelistFollowsBin = follows } // GetGlobalRule returns a pointer to the global rule for modification. func (p *P) GetGlobalRule() *Rule { if p == nil { return nil } return &p.Global } // GetRules returns the rules map for iteration. // Note: Returns a copy of the map keys to prevent modification. func (p *P) GetRulesKinds() []int { if p == nil || p.rules == nil { return nil } kinds := make([]int, 0, len(p.rules)) for kind := range p.rules { kinds = append(kinds, kind) } return kinds } // ============================================================================= // ReadFollowsWhitelist and WriteFollowsWhitelist Methods // ============================================================================= // GetAllReadFollowsWhitelistPubkeys returns all unique pubkeys from ReadFollowsWhitelist // across all rules (including global). Returns hex-encoded pubkeys. // This is used at startup to validate that kind 3 events exist for these pubkeys. func (p *P) GetAllReadFollowsWhitelistPubkeys() []string { if p == nil { return nil } // Use map to deduplicate pubkeys := make(map[string]struct{}) // Check global rule for _, pk := range p.Global.ReadFollowsWhitelist { pubkeys[pk] = struct{}{} } // Check all kind-specific rules for _, rule := range p.rules { for _, pk := range rule.ReadFollowsWhitelist { pubkeys[pk] = struct{}{} } } // Convert map to slice result := make([]string, 0, len(pubkeys)) for pk := range pubkeys { result = append(result, pk) } return result } // GetAllWriteFollowsWhitelistPubkeys returns all unique pubkeys from WriteFollowsWhitelist // across all rules (including global). Returns hex-encoded pubkeys. // This is used at startup to validate that kind 3 events exist for these pubkeys. func (p *P) GetAllWriteFollowsWhitelistPubkeys() []string { if p == nil { return nil } // Use map to deduplicate pubkeys := make(map[string]struct{}) // Check global rule for _, pk := range p.Global.WriteFollowsWhitelist { pubkeys[pk] = struct{}{} } // Check all kind-specific rules for _, rule := range p.rules { for _, pk := range rule.WriteFollowsWhitelist { pubkeys[pk] = struct{}{} } } // Convert map to slice result := make([]string, 0, len(pubkeys)) for pk := range pubkeys { result = append(result, pk) } return result } // GetAllFollowsWhitelistPubkeys returns all unique pubkeys from both ReadFollowsWhitelist // and WriteFollowsWhitelist across all rules (including global). Returns hex-encoded pubkeys. // This is a convenience method for startup validation to check all required kind 3 events. func (p *P) GetAllFollowsWhitelistPubkeys() []string { if p == nil { return nil } // Use map to deduplicate pubkeys := make(map[string]struct{}) // Get read follows whitelist pubkeys for _, pk := range p.GetAllReadFollowsWhitelistPubkeys() { pubkeys[pk] = struct{}{} } // Get write follows whitelist pubkeys for _, pk := range p.GetAllWriteFollowsWhitelistPubkeys() { pubkeys[pk] = struct{}{} } // Also include deprecated FollowsWhitelistAdmins for backward compatibility for _, pk := range p.GetAllFollowsWhitelistAdmins() { pubkeys[pk] = struct{}{} } // Convert map to slice result := make([]string, 0, len(pubkeys)) for pk := range pubkeys { result = append(result, pk) } return result } // UpdateRuleReadFollowsWhitelist updates the read follows whitelist for a specific kind's rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. // Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateRuleReadFollowsWhitelist(kind int, follows [][]byte) { if p == nil || p.rules == nil { return } p.followsMx.Lock() defer p.followsMx.Unlock() if rule, exists := p.rules[kind]; exists { rule.UpdateReadFollowsWhitelist(follows) p.rules[kind] = rule } } // UpdateRuleWriteFollowsWhitelist updates the write follows whitelist for a specific kind's rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. // Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateRuleWriteFollowsWhitelist(kind int, follows [][]byte) { if p == nil || p.rules == nil { return } p.followsMx.Lock() defer p.followsMx.Unlock() if rule, exists := p.rules[kind]; exists { rule.UpdateWriteFollowsWhitelist(follows) p.rules[kind] = rule } } // UpdateGlobalReadFollowsWhitelist updates the read follows whitelist for the global rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. // Note: We directly modify p.Global's unexported field because Global is a value type (not *Rule), // so calling p.Global.UpdateReadFollowsWhitelist() would operate on a copy and discard changes. // Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateGlobalReadFollowsWhitelist(follows [][]byte) { if p == nil { return } p.followsMx.Lock() defer p.followsMx.Unlock() p.Global.readFollowsFollowsBin = follows } // UpdateGlobalWriteFollowsWhitelist updates the write follows whitelist for the global rule. // The follows should be binary pubkeys ([]byte), not hex-encoded. // Note: We directly modify p.Global's unexported field because Global is a value type (not *Rule), // so calling p.Global.UpdateWriteFollowsWhitelist() would operate on a copy and discard changes. // Thread-safe: uses followsMx to protect concurrent access. func (p *P) UpdateGlobalWriteFollowsWhitelist(follows [][]byte) { if p == nil { return } p.followsMx.Lock() defer p.followsMx.Unlock() p.Global.writeFollowsFollowsBin = follows } // ============================================================================= // Owner vs Policy Admin Update Validation // ============================================================================= // ValidateOwnerPolicyUpdate validates a full policy update from an owner. // Owners can modify all fields but the owners list must be non-empty. func (p *P) ValidateOwnerPolicyUpdate(policyJSON []byte) error { // First run standard validation if err := p.ValidateJSON(policyJSON); err != nil { return err } // Parse the new policy tempPolicy := &P{} if err := json.Unmarshal(policyJSON, tempPolicy); err != nil { return fmt.Errorf("failed to parse policy JSON: %v", err) } // Owner-specific validation: owners list cannot be empty if len(tempPolicy.Owners) == 0 { return fmt.Errorf("owners list cannot be empty: at least one owner must be defined to prevent lockout") } return nil } // ValidatePolicyAdminUpdate validates a policy update from a policy admin. // Policy admins CANNOT modify: owners, policy_admins // Policy admins CAN: extend rules, add blacklists, add new kind rules func (p *P) ValidatePolicyAdminUpdate(policyJSON []byte, adminPubkey []byte) error { // First run standard validation if err := p.ValidateJSON(policyJSON); err != nil { return err } // Parse the new policy tempPolicy := &P{} if err := json.Unmarshal(policyJSON, tempPolicy); err != nil { return fmt.Errorf("failed to parse policy JSON: %v", err) } // Protected field check: owners must match current if !stringSliceEqual(tempPolicy.Owners, p.Owners) { return fmt.Errorf("policy admins cannot modify the 'owners' field: this is a protected field that only owners can change") } // Protected field check: policy_admins must match current if !stringSliceEqual(tempPolicy.PolicyAdmins, p.PolicyAdmins) { return fmt.Errorf("policy admins cannot modify the 'policy_admins' field: this is a protected field that only owners can change") } // Validate that the admin is not reducing owner-granted permissions // This check ensures policy admins can only extend, not restrict if err := p.validateNoPermissionReduction(tempPolicy); err != nil { return fmt.Errorf("policy admins cannot reduce owner-granted permissions: %v", err) } return nil } // validateNoPermissionReduction checks that the new policy doesn't reduce // permissions that were granted in the current (owner) policy. // // Policy admins CAN: // - ADD to allow lists (write_allow, read_allow) // - ADD to deny lists (write_deny, read_deny) to blacklist non-admin users // - INCREASE limits (size_limit, content_limit, max_age_of_event) // - ADD new kinds to whitelist or blacklist // - ADD new rules for kinds not defined by owner // // Policy admins CANNOT: // - REMOVE from allow lists // - DECREASE limits // - REMOVE kinds from whitelist // - REMOVE rules defined by owner // - ADD new required tags (restrictions) // - BLACKLIST owners or other policy admins func (p *P) validateNoPermissionReduction(newPolicy *P) error { // Check kind whitelist - new policy must include all current whitelisted kinds for _, kind := range p.Kind.Whitelist { found := false for _, newKind := range newPolicy.Kind.Whitelist { if kind == newKind { found = true break } } if !found { return fmt.Errorf("cannot remove kind %d from whitelist", kind) } } // Check each rule in the current policy for kind, currentRule := range p.rules { newRule, exists := newPolicy.rules[kind] if !exists { return fmt.Errorf("cannot remove rule for kind %d", kind) } // Check write_allow - new rule must include all current pubkeys for _, pk := range currentRule.WriteAllow { if !containsString(newRule.WriteAllow, pk) { return fmt.Errorf("cannot remove pubkey %s from write_allow for kind %d", pk, kind) } } // Check read_allow - new rule must include all current pubkeys for _, pk := range currentRule.ReadAllow { if !containsString(newRule.ReadAllow, pk) { return fmt.Errorf("cannot remove pubkey %s from read_allow for kind %d", pk, kind) } } // Check write_deny - cannot blacklist owners or policy admins for _, pk := range newRule.WriteDeny { if containsString(p.Owners, pk) { return fmt.Errorf("cannot blacklist owner %s in write_deny for kind %d", pk, kind) } if containsString(p.PolicyAdmins, pk) { return fmt.Errorf("cannot blacklist policy admin %s in write_deny for kind %d", pk, kind) } } // Check read_deny - cannot blacklist owners or policy admins for _, pk := range newRule.ReadDeny { if containsString(p.Owners, pk) { return fmt.Errorf("cannot blacklist owner %s in read_deny for kind %d", pk, kind) } if containsString(p.PolicyAdmins, pk) { return fmt.Errorf("cannot blacklist policy admin %s in read_deny for kind %d", pk, kind) } } // Check size limits - new limit cannot be smaller if currentRule.SizeLimit != nil && newRule.SizeLimit != nil { if *newRule.SizeLimit < *currentRule.SizeLimit { return fmt.Errorf("cannot reduce size_limit for kind %d from %d to %d", kind, *currentRule.SizeLimit, *newRule.SizeLimit) } } // Check content limits - new limit cannot be smaller if currentRule.ContentLimit != nil && newRule.ContentLimit != nil { if *newRule.ContentLimit < *currentRule.ContentLimit { return fmt.Errorf("cannot reduce content_limit for kind %d from %d to %d", kind, *currentRule.ContentLimit, *newRule.ContentLimit) } } // Check max_age_of_event - new limit cannot be smaller (smaller = more restrictive) if currentRule.MaxAgeOfEvent != nil && newRule.MaxAgeOfEvent != nil { if *newRule.MaxAgeOfEvent < *currentRule.MaxAgeOfEvent { return fmt.Errorf("cannot reduce max_age_of_event for kind %d from %d to %d", kind, *currentRule.MaxAgeOfEvent, *newRule.MaxAgeOfEvent) } } // Check must_have_tags - cannot add new required tags (more restrictive) for _, tag := range newRule.MustHaveTags { found := false for _, currentTag := range currentRule.MustHaveTags { if tag == currentTag { found = true break } } if !found { return fmt.Errorf("cannot add required tag %q for kind %d (only owners can add restrictions)", tag, kind) } } } // Check global rule write_deny - cannot blacklist owners or policy admins for _, pk := range newPolicy.Global.WriteDeny { if containsString(p.Owners, pk) { return fmt.Errorf("cannot blacklist owner %s in global write_deny", pk) } if containsString(p.PolicyAdmins, pk) { return fmt.Errorf("cannot blacklist policy admin %s in global write_deny", pk) } } // Check global rule read_deny - cannot blacklist owners or policy admins for _, pk := range newPolicy.Global.ReadDeny { if containsString(p.Owners, pk) { return fmt.Errorf("cannot blacklist owner %s in global read_deny", pk) } if containsString(p.PolicyAdmins, pk) { return fmt.Errorf("cannot blacklist policy admin %s in global read_deny", pk) } } // Check global rule size limits if p.Global.SizeLimit != nil && newPolicy.Global.SizeLimit != nil { if *newPolicy.Global.SizeLimit < *p.Global.SizeLimit { return fmt.Errorf("cannot reduce global size_limit from %d to %d", *p.Global.SizeLimit, *newPolicy.Global.SizeLimit) } } return nil } // ReloadAsOwner reloads the policy from an owner's kind 12345 event. // Owners can modify all fields but the owners list must be non-empty. func (p *P) ReloadAsOwner(policyJSON []byte, configPath string) error { // Validate as owner update if err := p.ValidateOwnerPolicyUpdate(policyJSON); err != nil { return fmt.Errorf("owner policy validation failed: %v", err) } // Use existing Reload logic return p.Reload(policyJSON, configPath) } // ReloadAsPolicyAdmin reloads the policy from a policy admin's kind 12345 event. // Policy admins cannot modify protected fields (owners, policy_admins) and // cannot reduce owner-granted permissions. func (p *P) ReloadAsPolicyAdmin(policyJSON []byte, configPath string, adminPubkey []byte) error { // Validate as policy admin update if err := p.ValidatePolicyAdminUpdate(policyJSON, adminPubkey); err != nil { return fmt.Errorf("policy admin validation failed: %v", err) } // Use existing Reload logic return p.Reload(policyJSON, configPath) } // stringSliceEqual checks if two string slices are equal (order-independent). func stringSliceEqual(a, b []string) bool { if len(a) != len(b) { return false } // Create maps for comparison aMap := make(map[string]int) for _, v := range a { aMap[v]++ } bMap := make(map[string]int) for _, v := range b { bMap[v]++ } // Compare maps for k, v := range aMap { if bMap[k] != v { return false } } return true }