You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

2907 lines
97 KiB

package policy
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"git.mleku.dev/mleku/nostr/encoders/event"
"git.mleku.dev/mleku/nostr/encoders/hex"
"github.com/adrg/xdg"
"github.com/sosodev/duration"
"lol.mleku.dev/chk"
"lol.mleku.dev/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"`
// 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
}
// 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
}
// 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)
}
}
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.E.F(
"FATAL: Policy system is ENABLED (ORLY_POLICY_ENABLED=true) but configuration failed to load from %s: %v",
configPath, err,
)
log.E.F("The relay cannot start with an invalid policy configuration.")
log.E.F("Fix: Either disable the policy system (ORLY_POLICY_ENABLED=false) or ensure %s exists and contains valid JSON", configPath)
panic(fmt.Sprintf("fatal policy configuration error: %v", err))
}
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
hasWriteRestriction := hasWriteAllowList || hasWriteFollowsWhitelist || hasLegacyFollowsWhitelist
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
// 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
}