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