composition.go raw

   1  package policy
   2  
   3  import (
   4  	"encoding/json"
   5  	"fmt"
   6  	"os"
   7  	"path/filepath"
   8  	"sort"
   9  	"sync"
  10  
  11  	"next.orly.dev/pkg/nostr/encoders/hex"
  12  	"next.orly.dev/pkg/lol/log"
  13  	"next.orly.dev/pkg/utils"
  14  )
  15  
  16  // =============================================================================
  17  // Policy Composition Types
  18  // =============================================================================
  19  
  20  // PolicyAdminContribution represents extensions/additions from a policy admin.
  21  // Policy admins can extend the base owner policy but cannot modify protected fields
  22  // (owners, policy_admins) or reduce owner-granted permissions.
  23  type PolicyAdminContribution struct {
  24  	// AdminPubkey is the hex-encoded pubkey of the policy admin who made this contribution
  25  	AdminPubkey string `json:"admin_pubkey"`
  26  	// CreatedAt is the Unix timestamp when this contribution was created
  27  	CreatedAt int64 `json:"created_at"`
  28  	// EventID is the Nostr event ID that created this contribution (for audit trail)
  29  	EventID string `json:"event_id,omitempty"`
  30  
  31  	// KindWhitelistAdd adds kinds to the whitelist (OR with owner's whitelist)
  32  	KindWhitelistAdd []int `json:"kind_whitelist_add,omitempty"`
  33  	// KindBlacklistAdd adds kinds to the blacklist (overrides whitelist)
  34  	KindBlacklistAdd []int `json:"kind_blacklist_add,omitempty"`
  35  
  36  	// RulesExtend extends existing rules defined by the owner
  37  	RulesExtend map[int]RuleExtension `json:"rules_extend,omitempty"`
  38  	// RulesAdd adds new rules for kinds not defined by the owner
  39  	RulesAdd map[int]Rule `json:"rules_add,omitempty"`
  40  
  41  	// GlobalExtend extends the global rule
  42  	GlobalExtend *RuleExtension `json:"global_extend,omitempty"`
  43  }
  44  
  45  // RuleExtension defines how a policy admin can extend an existing owner rule.
  46  // All fields are additive - they extend, not replace, the owner's configuration.
  47  type RuleExtension struct {
  48  	// WriteAllowAdd adds pubkeys to the write allow list
  49  	WriteAllowAdd []string `json:"write_allow_add,omitempty"`
  50  	// WriteDenyAdd adds pubkeys to the write deny list (overrides allow)
  51  	WriteDenyAdd []string `json:"write_deny_add,omitempty"`
  52  	// ReadAllowAdd adds pubkeys to the read allow list
  53  	ReadAllowAdd []string `json:"read_allow_add,omitempty"`
  54  	// ReadDenyAdd adds pubkeys to the read deny list (overrides allow)
  55  	ReadDenyAdd []string `json:"read_deny_add,omitempty"`
  56  
  57  	// SizeLimitOverride can only make the limit MORE permissive (larger)
  58  	SizeLimitOverride *int64 `json:"size_limit_override,omitempty"`
  59  	// ContentLimitOverride can only make the limit MORE permissive (larger)
  60  	ContentLimitOverride *int64 `json:"content_limit_override,omitempty"`
  61  	// MaxAgeOfEventOverride can only make the limit MORE permissive (older allowed)
  62  	MaxAgeOfEventOverride *int64 `json:"max_age_of_event_override,omitempty"`
  63  	// MaxAgeEventInFutureOverride can only make the limit MORE permissive (further future allowed)
  64  	MaxAgeEventInFutureOverride *int64 `json:"max_age_event_in_future_override,omitempty"`
  65  
  66  	// WriteAllowFollows extends the follow whitelist feature
  67  	WriteAllowFollows *bool `json:"write_allow_follows,omitempty"`
  68  	// FollowsWhitelistAdminsAdd adds admin pubkeys whose follows are whitelisted
  69  	FollowsWhitelistAdminsAdd []string `json:"follows_whitelist_admins_add,omitempty"`
  70  }
  71  
  72  // ComposedPolicy manages the base owner policy and policy admin contributions.
  73  // It computes an effective merged policy at runtime.
  74  type ComposedPolicy struct {
  75  	// OwnerPolicy is the base policy set by owners
  76  	OwnerPolicy *P
  77  	// Contributions is a map of event ID -> contribution for deduplication
  78  	Contributions map[string]*PolicyAdminContribution
  79  	// contributionsMx protects the contributions map
  80  	contributionsMx sync.RWMutex
  81  	// configDir is the directory where policy files are stored
  82  	configDir string
  83  }
  84  
  85  // =============================================================================
  86  // Protected Field Validation
  87  // =============================================================================
  88  
  89  // ProtectedFields are fields that only owners can modify
  90  var ProtectedFields = []string{"owners", "policy_admins"}
  91  
  92  // ValidateOwnerPolicy validates a policy update from an owner.
  93  // Ensures owners list is non-empty.
  94  func ValidateOwnerPolicy(policy *P) error {
  95  	if len(policy.Owners) == 0 {
  96  		return fmt.Errorf("owners list cannot be empty: at least one owner must be defined")
  97  	}
  98  
  99  	// Validate all owner pubkeys are valid hex
 100  	for _, owner := range policy.Owners {
 101  		if len(owner) != 64 {
 102  			return fmt.Errorf("invalid owner pubkey length: %q (expected 64 hex characters)", owner)
 103  		}
 104  		if _, err := hex.Dec(owner); err != nil {
 105  			return fmt.Errorf("invalid owner pubkey format: %q: %v", owner, err)
 106  		}
 107  	}
 108  
 109  	// Validate all policy admin pubkeys are valid hex
 110  	for _, admin := range policy.PolicyAdmins {
 111  		if len(admin) != 64 {
 112  			return fmt.Errorf("invalid policy_admin pubkey length: %q (expected 64 hex characters)", admin)
 113  		}
 114  		if _, err := hex.Dec(admin); err != nil {
 115  			return fmt.Errorf("invalid policy_admin pubkey format: %q: %v", admin, err)
 116  		}
 117  	}
 118  
 119  	return nil
 120  }
 121  
 122  // ValidatePolicyAdminContribution validates a contribution from a policy admin.
 123  // Ensures no protected fields are modified and extensions are valid.
 124  func ValidatePolicyAdminContribution(
 125  	ownerPolicy *P,
 126  	contribution *PolicyAdminContribution,
 127  	existingContributions map[string]*PolicyAdminContribution,
 128  ) error {
 129  	// Validate the admin pubkey is valid
 130  	if len(contribution.AdminPubkey) != 64 {
 131  		return fmt.Errorf("invalid admin pubkey length")
 132  	}
 133  
 134  	// Validate kind additions don't conflict with owner blacklist
 135  	// (though PA can add to blacklist to override whitelist)
 136  
 137  	// Validate rule extensions
 138  	for kind, ext := range contribution.RulesExtend {
 139  		ownerRule, exists := ownerPolicy.rules[kind]
 140  		if !exists {
 141  			return fmt.Errorf("cannot extend rule for kind %d: not defined in owner policy (use rules_add instead)", kind)
 142  		}
 143  
 144  		// Validate size limit overrides are more permissive
 145  		if ext.SizeLimitOverride != nil && ownerRule.SizeLimit != nil {
 146  			if *ext.SizeLimitOverride < *ownerRule.SizeLimit {
 147  				return fmt.Errorf("size_limit_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.SizeLimit)
 148  			}
 149  		}
 150  
 151  		if ext.ContentLimitOverride != nil && ownerRule.ContentLimit != nil {
 152  			if *ext.ContentLimitOverride < *ownerRule.ContentLimit {
 153  				return fmt.Errorf("content_limit_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.ContentLimit)
 154  			}
 155  		}
 156  
 157  		if ext.MaxAgeOfEventOverride != nil && ownerRule.MaxAgeOfEvent != nil {
 158  			if *ext.MaxAgeOfEventOverride < *ownerRule.MaxAgeOfEvent {
 159  				return fmt.Errorf("max_age_of_event_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.MaxAgeOfEvent)
 160  			}
 161  		}
 162  
 163  		// Validate pubkey formats in allow/deny lists
 164  		for _, pk := range ext.WriteAllowAdd {
 165  			if len(pk) != 64 {
 166  				return fmt.Errorf("invalid pubkey in write_allow_add for kind %d: %q", kind, pk)
 167  			}
 168  		}
 169  		for _, pk := range ext.WriteDenyAdd {
 170  			if len(pk) != 64 {
 171  				return fmt.Errorf("invalid pubkey in write_deny_add for kind %d: %q", kind, pk)
 172  			}
 173  		}
 174  		for _, pk := range ext.ReadAllowAdd {
 175  			if len(pk) != 64 {
 176  				return fmt.Errorf("invalid pubkey in read_allow_add for kind %d: %q", kind, pk)
 177  			}
 178  		}
 179  		for _, pk := range ext.ReadDenyAdd {
 180  			if len(pk) != 64 {
 181  				return fmt.Errorf("invalid pubkey in read_deny_add for kind %d: %q", kind, pk)
 182  			}
 183  		}
 184  	}
 185  
 186  	// Validate rules_add are for kinds not already defined by owner
 187  	for kind := range contribution.RulesAdd {
 188  		if _, exists := ownerPolicy.rules[kind]; exists {
 189  			return fmt.Errorf("cannot add rule for kind %d: already defined in owner policy (use rules_extend instead)", kind)
 190  		}
 191  	}
 192  
 193  	return nil
 194  }
 195  
 196  // =============================================================================
 197  // Policy Composition Logic
 198  // =============================================================================
 199  
 200  // NewComposedPolicy creates a new composed policy from an owner policy.
 201  func NewComposedPolicy(ownerPolicy *P, configDir string) *ComposedPolicy {
 202  	return &ComposedPolicy{
 203  		OwnerPolicy:   ownerPolicy,
 204  		Contributions: make(map[string]*PolicyAdminContribution),
 205  		configDir:     configDir,
 206  	}
 207  }
 208  
 209  // AddContribution adds a policy admin contribution.
 210  // Returns error if validation fails.
 211  func (cp *ComposedPolicy) AddContribution(contribution *PolicyAdminContribution) error {
 212  	cp.contributionsMx.Lock()
 213  	defer cp.contributionsMx.Unlock()
 214  
 215  	// Validate the contribution
 216  	if err := ValidatePolicyAdminContribution(cp.OwnerPolicy, contribution, cp.Contributions); err != nil {
 217  		return err
 218  	}
 219  
 220  	// Store the contribution
 221  	cp.Contributions[contribution.EventID] = contribution
 222  
 223  	// Persist to disk
 224  	if err := cp.saveContribution(contribution); err != nil {
 225  		log.W.F("failed to persist contribution: %v", err)
 226  	}
 227  
 228  	return nil
 229  }
 230  
 231  // RemoveContribution removes a policy admin contribution by event ID.
 232  func (cp *ComposedPolicy) RemoveContribution(eventID string) {
 233  	cp.contributionsMx.Lock()
 234  	defer cp.contributionsMx.Unlock()
 235  
 236  	delete(cp.Contributions, eventID)
 237  
 238  	// Remove from disk
 239  	if cp.configDir != "" {
 240  		contribPath := filepath.Join(cp.configDir, "policy-contributions", eventID+".json")
 241  		os.Remove(contribPath)
 242  	}
 243  }
 244  
 245  // GetEffectivePolicy computes the merged effective policy.
 246  // Composition rules:
 247  // - Whitelists are unioned (OR)
 248  // - Blacklists are unioned and override whitelists
 249  // - Limits use the most permissive value
 250  // - Conflicts between PAs: oldest created_at wins (except deny always wins)
 251  func (cp *ComposedPolicy) GetEffectivePolicy() *P {
 252  	cp.contributionsMx.RLock()
 253  	defer cp.contributionsMx.RUnlock()
 254  
 255  	// Clone the owner policy as base
 256  	effective := cp.cloneOwnerPolicy()
 257  
 258  	// Sort contributions by created_at (oldest first for conflict resolution)
 259  	sorted := cp.getSortedContributions()
 260  
 261  	// Apply each contribution
 262  	for _, contrib := range sorted {
 263  		cp.applyContribution(effective, contrib)
 264  	}
 265  
 266  	// Repopulate binary caches
 267  	effective.Global.populateBinaryCache()
 268  	for kind := range effective.rules {
 269  		rule := effective.rules[kind]
 270  		rule.populateBinaryCache()
 271  		effective.rules[kind] = rule
 272  	}
 273  
 274  	return effective
 275  }
 276  
 277  // cloneOwnerPolicy creates a deep copy of the owner policy.
 278  func (cp *ComposedPolicy) cloneOwnerPolicy() *P {
 279  	// Marshal and unmarshal to create a deep copy
 280  	data, _ := json.Marshal(cp.OwnerPolicy)
 281  	var cloned P
 282  	json.Unmarshal(data, &cloned)
 283  
 284  	// Copy the manager reference (not cloned)
 285  	cloned.manager = cp.OwnerPolicy.manager
 286  
 287  	return &cloned
 288  }
 289  
 290  // getSortedContributions returns contributions sorted by created_at.
 291  func (cp *ComposedPolicy) getSortedContributions() []*PolicyAdminContribution {
 292  	sorted := make([]*PolicyAdminContribution, 0, len(cp.Contributions))
 293  	for _, contrib := range cp.Contributions {
 294  		sorted = append(sorted, contrib)
 295  	}
 296  	sort.Slice(sorted, func(i, j int) bool {
 297  		return sorted[i].CreatedAt < sorted[j].CreatedAt
 298  	})
 299  	return sorted
 300  }
 301  
 302  // applyContribution applies a single contribution to the effective policy.
 303  func (cp *ComposedPolicy) applyContribution(effective *P, contrib *PolicyAdminContribution) {
 304  	// Apply kind whitelist additions (OR)
 305  	for _, kind := range contrib.KindWhitelistAdd {
 306  		if !containsInt(effective.Kind.Whitelist, kind) {
 307  			effective.Kind.Whitelist = append(effective.Kind.Whitelist, kind)
 308  		}
 309  	}
 310  
 311  	// Apply kind blacklist additions (OR, overrides whitelist)
 312  	for _, kind := range contrib.KindBlacklistAdd {
 313  		if !containsInt(effective.Kind.Blacklist, kind) {
 314  			effective.Kind.Blacklist = append(effective.Kind.Blacklist, kind)
 315  		}
 316  	}
 317  
 318  	// Apply rule extensions
 319  	for kind, ext := range contrib.RulesExtend {
 320  		if rule, exists := effective.rules[kind]; exists {
 321  			cp.applyRuleExtension(&rule, &ext, contrib.CreatedAt)
 322  			effective.rules[kind] = rule
 323  		}
 324  	}
 325  
 326  	// Apply new rules
 327  	for kind, rule := range contrib.RulesAdd {
 328  		if _, exists := effective.rules[kind]; !exists {
 329  			if effective.rules == nil {
 330  				effective.rules = make(map[int]Rule)
 331  			}
 332  			effective.rules[kind] = rule
 333  		}
 334  	}
 335  
 336  	// Apply global rule extension
 337  	if contrib.GlobalExtend != nil {
 338  		cp.applyRuleExtension(&effective.Global, contrib.GlobalExtend, contrib.CreatedAt)
 339  	}
 340  }
 341  
 342  // applyRuleExtension applies a rule extension to an existing rule.
 343  func (cp *ComposedPolicy) applyRuleExtension(rule *Rule, ext *RuleExtension, _ int64) {
 344  	// Add to allow lists (OR)
 345  	for _, pk := range ext.WriteAllowAdd {
 346  		if !containsString(rule.WriteAllow, pk) {
 347  			rule.WriteAllow = append(rule.WriteAllow, pk)
 348  		}
 349  	}
 350  	for _, pk := range ext.ReadAllowAdd {
 351  		if !containsString(rule.ReadAllow, pk) {
 352  			rule.ReadAllow = append(rule.ReadAllow, pk)
 353  		}
 354  	}
 355  
 356  	// Add to deny lists (OR, overrides allow) - deny always wins
 357  	for _, pk := range ext.WriteDenyAdd {
 358  		if !containsString(rule.WriteDeny, pk) {
 359  			rule.WriteDeny = append(rule.WriteDeny, pk)
 360  		}
 361  	}
 362  	for _, pk := range ext.ReadDenyAdd {
 363  		if !containsString(rule.ReadDeny, pk) {
 364  			rule.ReadDeny = append(rule.ReadDeny, pk)
 365  		}
 366  	}
 367  
 368  	// Apply limit overrides (most permissive wins)
 369  	if ext.SizeLimitOverride != nil {
 370  		if rule.SizeLimit == nil || *ext.SizeLimitOverride > *rule.SizeLimit {
 371  			rule.SizeLimit = ext.SizeLimitOverride
 372  		}
 373  	}
 374  	if ext.ContentLimitOverride != nil {
 375  		if rule.ContentLimit == nil || *ext.ContentLimitOverride > *rule.ContentLimit {
 376  			rule.ContentLimit = ext.ContentLimitOverride
 377  		}
 378  	}
 379  	if ext.MaxAgeOfEventOverride != nil {
 380  		if rule.MaxAgeOfEvent == nil || *ext.MaxAgeOfEventOverride > *rule.MaxAgeOfEvent {
 381  			rule.MaxAgeOfEvent = ext.MaxAgeOfEventOverride
 382  		}
 383  	}
 384  	if ext.MaxAgeEventInFutureOverride != nil {
 385  		if rule.MaxAgeEventInFuture == nil || *ext.MaxAgeEventInFutureOverride > *rule.MaxAgeEventInFuture {
 386  			rule.MaxAgeEventInFuture = ext.MaxAgeEventInFutureOverride
 387  		}
 388  	}
 389  
 390  	// Enable WriteAllowFollows if requested (OR logic)
 391  	if ext.WriteAllowFollows != nil && *ext.WriteAllowFollows {
 392  		rule.WriteAllowFollows = true
 393  	}
 394  
 395  	// Add to follows whitelist admins
 396  	for _, pk := range ext.FollowsWhitelistAdminsAdd {
 397  		if !containsString(rule.FollowsWhitelistAdmins, pk) {
 398  			rule.FollowsWhitelistAdmins = append(rule.FollowsWhitelistAdmins, pk)
 399  		}
 400  	}
 401  }
 402  
 403  // =============================================================================
 404  // Persistence
 405  // =============================================================================
 406  
 407  // saveContribution persists a contribution to disk.
 408  func (cp *ComposedPolicy) saveContribution(contrib *PolicyAdminContribution) error {
 409  	if cp.configDir == "" {
 410  		return nil
 411  	}
 412  
 413  	contribDir := filepath.Join(cp.configDir, "policy-contributions")
 414  	if err := os.MkdirAll(contribDir, 0755); err != nil {
 415  		return err
 416  	}
 417  
 418  	contribPath := filepath.Join(contribDir, contrib.EventID+".json")
 419  	data, err := json.MarshalIndent(contrib, "", "  ")
 420  	if err != nil {
 421  		return err
 422  	}
 423  
 424  	return os.WriteFile(contribPath, data, 0644)
 425  }
 426  
 427  // LoadContributions loads all contributions from disk.
 428  func (cp *ComposedPolicy) LoadContributions() error {
 429  	if cp.configDir == "" {
 430  		return nil
 431  	}
 432  
 433  	contribDir := filepath.Join(cp.configDir, "policy-contributions")
 434  	if _, err := os.Stat(contribDir); os.IsNotExist(err) {
 435  		return nil // No contributions yet
 436  	}
 437  
 438  	entries, err := os.ReadDir(contribDir)
 439  	if err != nil {
 440  		return err
 441  	}
 442  
 443  	cp.contributionsMx.Lock()
 444  	defer cp.contributionsMx.Unlock()
 445  
 446  	for _, entry := range entries {
 447  		if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
 448  			continue
 449  		}
 450  
 451  		contribPath := filepath.Join(contribDir, entry.Name())
 452  		data, err := os.ReadFile(contribPath)
 453  		if err != nil {
 454  			log.W.F("failed to read contribution %s: %v", entry.Name(), err)
 455  			continue
 456  		}
 457  
 458  		var contrib PolicyAdminContribution
 459  		if err := json.Unmarshal(data, &contrib); err != nil {
 460  			log.W.F("failed to parse contribution %s: %v", entry.Name(), err)
 461  			continue
 462  		}
 463  
 464  		// Validate against current owner policy
 465  		if err := ValidatePolicyAdminContribution(cp.OwnerPolicy, &contrib, cp.Contributions); err != nil {
 466  			log.W.F("contribution %s is no longer valid: %v (skipping)", entry.Name(), err)
 467  			continue
 468  		}
 469  
 470  		cp.Contributions[contrib.EventID] = &contrib
 471  	}
 472  
 473  	log.I.F("loaded %d policy admin contributions", len(cp.Contributions))
 474  	return nil
 475  }
 476  
 477  // =============================================================================
 478  // Owner Detection
 479  // =============================================================================
 480  
 481  // IsOwner checks if the given pubkey is an owner.
 482  // The pubkey parameter should be binary ([]byte), not hex-encoded.
 483  func (p *P) IsOwner(pubkey []byte) bool {
 484  	if len(pubkey) == 0 {
 485  		return false
 486  	}
 487  
 488  	p.followsMx.RLock()
 489  	defer p.followsMx.RUnlock()
 490  
 491  	for _, owner := range p.ownersBin {
 492  		if utils.FastEqual(owner, pubkey) {
 493  			return true
 494  		}
 495  	}
 496  	return false
 497  }
 498  
 499  // IsOwnerOrPolicyAdmin checks if the given pubkey is an owner or policy admin.
 500  // The pubkey parameter should be binary ([]byte), not hex-encoded.
 501  func (p *P) IsOwnerOrPolicyAdmin(pubkey []byte) bool {
 502  	return p.IsOwner(pubkey) || p.IsPolicyAdmin(pubkey)
 503  }
 504  
 505  // =============================================================================
 506  // Helper Functions
 507  // =============================================================================
 508  
 509  func containsInt(slice []int, val int) bool {
 510  	for _, v := range slice {
 511  		if v == val {
 512  			return true
 513  		}
 514  	}
 515  	return false
 516  }
 517  
 518  func containsString(slice []string, val string) bool {
 519  	for _, v := range slice {
 520  		if v == val {
 521  			return true
 522  		}
 523  	}
 524  	return false
 525  }
 526