Files
next.orly.dev/pkg/policy/composition.go
mleku 7a27c44bc9
Some checks failed
Go / build-and-release (push) Has been cancelled
Enhance policy system tests and documentation.
Added extensive tests for default-permissive access control, read/write follow whitelists, and privileged-only fields. Updated policy documentation with new configuration examples, access control reference, and logging details.
2025-12-03 19:19:36 +00:00

526 lines
17 KiB
Go

package policy
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"git.mleku.dev/mleku/nostr/encoders/hex"
"lol.mleku.dev/log"
"next.orly.dev/pkg/utils"
)
// =============================================================================
// Policy Composition Types
// =============================================================================
// PolicyAdminContribution represents extensions/additions from a policy admin.
// Policy admins can extend the base owner policy but cannot modify protected fields
// (owners, policy_admins) or reduce owner-granted permissions.
type PolicyAdminContribution struct {
// AdminPubkey is the hex-encoded pubkey of the policy admin who made this contribution
AdminPubkey string `json:"admin_pubkey"`
// CreatedAt is the Unix timestamp when this contribution was created
CreatedAt int64 `json:"created_at"`
// EventID is the Nostr event ID that created this contribution (for audit trail)
EventID string `json:"event_id,omitempty"`
// KindWhitelistAdd adds kinds to the whitelist (OR with owner's whitelist)
KindWhitelistAdd []int `json:"kind_whitelist_add,omitempty"`
// KindBlacklistAdd adds kinds to the blacklist (overrides whitelist)
KindBlacklistAdd []int `json:"kind_blacklist_add,omitempty"`
// RulesExtend extends existing rules defined by the owner
RulesExtend map[int]RuleExtension `json:"rules_extend,omitempty"`
// RulesAdd adds new rules for kinds not defined by the owner
RulesAdd map[int]Rule `json:"rules_add,omitempty"`
// GlobalExtend extends the global rule
GlobalExtend *RuleExtension `json:"global_extend,omitempty"`
}
// RuleExtension defines how a policy admin can extend an existing owner rule.
// All fields are additive - they extend, not replace, the owner's configuration.
type RuleExtension struct {
// WriteAllowAdd adds pubkeys to the write allow list
WriteAllowAdd []string `json:"write_allow_add,omitempty"`
// WriteDenyAdd adds pubkeys to the write deny list (overrides allow)
WriteDenyAdd []string `json:"write_deny_add,omitempty"`
// ReadAllowAdd adds pubkeys to the read allow list
ReadAllowAdd []string `json:"read_allow_add,omitempty"`
// ReadDenyAdd adds pubkeys to the read deny list (overrides allow)
ReadDenyAdd []string `json:"read_deny_add,omitempty"`
// SizeLimitOverride can only make the limit MORE permissive (larger)
SizeLimitOverride *int64 `json:"size_limit_override,omitempty"`
// ContentLimitOverride can only make the limit MORE permissive (larger)
ContentLimitOverride *int64 `json:"content_limit_override,omitempty"`
// MaxAgeOfEventOverride can only make the limit MORE permissive (older allowed)
MaxAgeOfEventOverride *int64 `json:"max_age_of_event_override,omitempty"`
// MaxAgeEventInFutureOverride can only make the limit MORE permissive (further future allowed)
MaxAgeEventInFutureOverride *int64 `json:"max_age_event_in_future_override,omitempty"`
// WriteAllowFollows extends the follow whitelist feature
WriteAllowFollows *bool `json:"write_allow_follows,omitempty"`
// FollowsWhitelistAdminsAdd adds admin pubkeys whose follows are whitelisted
FollowsWhitelistAdminsAdd []string `json:"follows_whitelist_admins_add,omitempty"`
}
// ComposedPolicy manages the base owner policy and policy admin contributions.
// It computes an effective merged policy at runtime.
type ComposedPolicy struct {
// OwnerPolicy is the base policy set by owners
OwnerPolicy *P
// Contributions is a map of event ID -> contribution for deduplication
Contributions map[string]*PolicyAdminContribution
// contributionsMx protects the contributions map
contributionsMx sync.RWMutex
// configDir is the directory where policy files are stored
configDir string
}
// =============================================================================
// Protected Field Validation
// =============================================================================
// ProtectedFields are fields that only owners can modify
var ProtectedFields = []string{"owners", "policy_admins"}
// ValidateOwnerPolicy validates a policy update from an owner.
// Ensures owners list is non-empty.
func ValidateOwnerPolicy(policy *P) error {
if len(policy.Owners) == 0 {
return fmt.Errorf("owners list cannot be empty: at least one owner must be defined")
}
// Validate all owner pubkeys are valid hex
for _, owner := range policy.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)
}
}
// Validate all policy admin pubkeys are valid hex
for _, admin := range policy.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)
}
}
return nil
}
// ValidatePolicyAdminContribution validates a contribution from a policy admin.
// Ensures no protected fields are modified and extensions are valid.
func ValidatePolicyAdminContribution(
ownerPolicy *P,
contribution *PolicyAdminContribution,
existingContributions map[string]*PolicyAdminContribution,
) error {
// Validate the admin pubkey is valid
if len(contribution.AdminPubkey) != 64 {
return fmt.Errorf("invalid admin pubkey length")
}
// Validate kind additions don't conflict with owner blacklist
// (though PA can add to blacklist to override whitelist)
// Validate rule extensions
for kind, ext := range contribution.RulesExtend {
ownerRule, exists := ownerPolicy.rules[kind]
if !exists {
return fmt.Errorf("cannot extend rule for kind %d: not defined in owner policy (use rules_add instead)", kind)
}
// Validate size limit overrides are more permissive
if ext.SizeLimitOverride != nil && ownerRule.SizeLimit != nil {
if *ext.SizeLimitOverride < *ownerRule.SizeLimit {
return fmt.Errorf("size_limit_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.SizeLimit)
}
}
if ext.ContentLimitOverride != nil && ownerRule.ContentLimit != nil {
if *ext.ContentLimitOverride < *ownerRule.ContentLimit {
return fmt.Errorf("content_limit_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.ContentLimit)
}
}
if ext.MaxAgeOfEventOverride != nil && ownerRule.MaxAgeOfEvent != nil {
if *ext.MaxAgeOfEventOverride < *ownerRule.MaxAgeOfEvent {
return fmt.Errorf("max_age_of_event_override for kind %d must be >= owner's limit (%d)", kind, *ownerRule.MaxAgeOfEvent)
}
}
// Validate pubkey formats in allow/deny lists
for _, pk := range ext.WriteAllowAdd {
if len(pk) != 64 {
return fmt.Errorf("invalid pubkey in write_allow_add for kind %d: %q", kind, pk)
}
}
for _, pk := range ext.WriteDenyAdd {
if len(pk) != 64 {
return fmt.Errorf("invalid pubkey in write_deny_add for kind %d: %q", kind, pk)
}
}
for _, pk := range ext.ReadAllowAdd {
if len(pk) != 64 {
return fmt.Errorf("invalid pubkey in read_allow_add for kind %d: %q", kind, pk)
}
}
for _, pk := range ext.ReadDenyAdd {
if len(pk) != 64 {
return fmt.Errorf("invalid pubkey in read_deny_add for kind %d: %q", kind, pk)
}
}
}
// Validate rules_add are for kinds not already defined by owner
for kind := range contribution.RulesAdd {
if _, exists := ownerPolicy.rules[kind]; exists {
return fmt.Errorf("cannot add rule for kind %d: already defined in owner policy (use rules_extend instead)", kind)
}
}
return nil
}
// =============================================================================
// Policy Composition Logic
// =============================================================================
// NewComposedPolicy creates a new composed policy from an owner policy.
func NewComposedPolicy(ownerPolicy *P, configDir string) *ComposedPolicy {
return &ComposedPolicy{
OwnerPolicy: ownerPolicy,
Contributions: make(map[string]*PolicyAdminContribution),
configDir: configDir,
}
}
// AddContribution adds a policy admin contribution.
// Returns error if validation fails.
func (cp *ComposedPolicy) AddContribution(contribution *PolicyAdminContribution) error {
cp.contributionsMx.Lock()
defer cp.contributionsMx.Unlock()
// Validate the contribution
if err := ValidatePolicyAdminContribution(cp.OwnerPolicy, contribution, cp.Contributions); err != nil {
return err
}
// Store the contribution
cp.Contributions[contribution.EventID] = contribution
// Persist to disk
if err := cp.saveContribution(contribution); err != nil {
log.W.F("failed to persist contribution: %v", err)
}
return nil
}
// RemoveContribution removes a policy admin contribution by event ID.
func (cp *ComposedPolicy) RemoveContribution(eventID string) {
cp.contributionsMx.Lock()
defer cp.contributionsMx.Unlock()
delete(cp.Contributions, eventID)
// Remove from disk
if cp.configDir != "" {
contribPath := filepath.Join(cp.configDir, "policy-contributions", eventID+".json")
os.Remove(contribPath)
}
}
// GetEffectivePolicy computes the merged effective policy.
// Composition rules:
// - Whitelists are unioned (OR)
// - Blacklists are unioned and override whitelists
// - Limits use the most permissive value
// - Conflicts between PAs: oldest created_at wins (except deny always wins)
func (cp *ComposedPolicy) GetEffectivePolicy() *P {
cp.contributionsMx.RLock()
defer cp.contributionsMx.RUnlock()
// Clone the owner policy as base
effective := cp.cloneOwnerPolicy()
// Sort contributions by created_at (oldest first for conflict resolution)
sorted := cp.getSortedContributions()
// Apply each contribution
for _, contrib := range sorted {
cp.applyContribution(effective, contrib)
}
// Repopulate binary caches
effective.Global.populateBinaryCache()
for kind := range effective.rules {
rule := effective.rules[kind]
rule.populateBinaryCache()
effective.rules[kind] = rule
}
return effective
}
// cloneOwnerPolicy creates a deep copy of the owner policy.
func (cp *ComposedPolicy) cloneOwnerPolicy() *P {
// Marshal and unmarshal to create a deep copy
data, _ := json.Marshal(cp.OwnerPolicy)
var cloned P
json.Unmarshal(data, &cloned)
// Copy the manager reference (not cloned)
cloned.manager = cp.OwnerPolicy.manager
return &cloned
}
// getSortedContributions returns contributions sorted by created_at.
func (cp *ComposedPolicy) getSortedContributions() []*PolicyAdminContribution {
sorted := make([]*PolicyAdminContribution, 0, len(cp.Contributions))
for _, contrib := range cp.Contributions {
sorted = append(sorted, contrib)
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].CreatedAt < sorted[j].CreatedAt
})
return sorted
}
// applyContribution applies a single contribution to the effective policy.
func (cp *ComposedPolicy) applyContribution(effective *P, contrib *PolicyAdminContribution) {
// Apply kind whitelist additions (OR)
for _, kind := range contrib.KindWhitelistAdd {
if !containsInt(effective.Kind.Whitelist, kind) {
effective.Kind.Whitelist = append(effective.Kind.Whitelist, kind)
}
}
// Apply kind blacklist additions (OR, overrides whitelist)
for _, kind := range contrib.KindBlacklistAdd {
if !containsInt(effective.Kind.Blacklist, kind) {
effective.Kind.Blacklist = append(effective.Kind.Blacklist, kind)
}
}
// Apply rule extensions
for kind, ext := range contrib.RulesExtend {
if rule, exists := effective.rules[kind]; exists {
cp.applyRuleExtension(&rule, &ext, contrib.CreatedAt)
effective.rules[kind] = rule
}
}
// Apply new rules
for kind, rule := range contrib.RulesAdd {
if _, exists := effective.rules[kind]; !exists {
if effective.rules == nil {
effective.rules = make(map[int]Rule)
}
effective.rules[kind] = rule
}
}
// Apply global rule extension
if contrib.GlobalExtend != nil {
cp.applyRuleExtension(&effective.Global, contrib.GlobalExtend, contrib.CreatedAt)
}
}
// applyRuleExtension applies a rule extension to an existing rule.
func (cp *ComposedPolicy) applyRuleExtension(rule *Rule, ext *RuleExtension, _ int64) {
// Add to allow lists (OR)
for _, pk := range ext.WriteAllowAdd {
if !containsString(rule.WriteAllow, pk) {
rule.WriteAllow = append(rule.WriteAllow, pk)
}
}
for _, pk := range ext.ReadAllowAdd {
if !containsString(rule.ReadAllow, pk) {
rule.ReadAllow = append(rule.ReadAllow, pk)
}
}
// Add to deny lists (OR, overrides allow) - deny always wins
for _, pk := range ext.WriteDenyAdd {
if !containsString(rule.WriteDeny, pk) {
rule.WriteDeny = append(rule.WriteDeny, pk)
}
}
for _, pk := range ext.ReadDenyAdd {
if !containsString(rule.ReadDeny, pk) {
rule.ReadDeny = append(rule.ReadDeny, pk)
}
}
// Apply limit overrides (most permissive wins)
if ext.SizeLimitOverride != nil {
if rule.SizeLimit == nil || *ext.SizeLimitOverride > *rule.SizeLimit {
rule.SizeLimit = ext.SizeLimitOverride
}
}
if ext.ContentLimitOverride != nil {
if rule.ContentLimit == nil || *ext.ContentLimitOverride > *rule.ContentLimit {
rule.ContentLimit = ext.ContentLimitOverride
}
}
if ext.MaxAgeOfEventOverride != nil {
if rule.MaxAgeOfEvent == nil || *ext.MaxAgeOfEventOverride > *rule.MaxAgeOfEvent {
rule.MaxAgeOfEvent = ext.MaxAgeOfEventOverride
}
}
if ext.MaxAgeEventInFutureOverride != nil {
if rule.MaxAgeEventInFuture == nil || *ext.MaxAgeEventInFutureOverride > *rule.MaxAgeEventInFuture {
rule.MaxAgeEventInFuture = ext.MaxAgeEventInFutureOverride
}
}
// Enable WriteAllowFollows if requested (OR logic)
if ext.WriteAllowFollows != nil && *ext.WriteAllowFollows {
rule.WriteAllowFollows = true
}
// Add to follows whitelist admins
for _, pk := range ext.FollowsWhitelistAdminsAdd {
if !containsString(rule.FollowsWhitelistAdmins, pk) {
rule.FollowsWhitelistAdmins = append(rule.FollowsWhitelistAdmins, pk)
}
}
}
// =============================================================================
// Persistence
// =============================================================================
// saveContribution persists a contribution to disk.
func (cp *ComposedPolicy) saveContribution(contrib *PolicyAdminContribution) error {
if cp.configDir == "" {
return nil
}
contribDir := filepath.Join(cp.configDir, "policy-contributions")
if err := os.MkdirAll(contribDir, 0755); err != nil {
return err
}
contribPath := filepath.Join(contribDir, contrib.EventID+".json")
data, err := json.MarshalIndent(contrib, "", " ")
if err != nil {
return err
}
return os.WriteFile(contribPath, data, 0644)
}
// LoadContributions loads all contributions from disk.
func (cp *ComposedPolicy) LoadContributions() error {
if cp.configDir == "" {
return nil
}
contribDir := filepath.Join(cp.configDir, "policy-contributions")
if _, err := os.Stat(contribDir); os.IsNotExist(err) {
return nil // No contributions yet
}
entries, err := os.ReadDir(contribDir)
if err != nil {
return err
}
cp.contributionsMx.Lock()
defer cp.contributionsMx.Unlock()
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
contribPath := filepath.Join(contribDir, entry.Name())
data, err := os.ReadFile(contribPath)
if err != nil {
log.W.F("failed to read contribution %s: %v", entry.Name(), err)
continue
}
var contrib PolicyAdminContribution
if err := json.Unmarshal(data, &contrib); err != nil {
log.W.F("failed to parse contribution %s: %v", entry.Name(), err)
continue
}
// Validate against current owner policy
if err := ValidatePolicyAdminContribution(cp.OwnerPolicy, &contrib, cp.Contributions); err != nil {
log.W.F("contribution %s is no longer valid: %v (skipping)", entry.Name(), err)
continue
}
cp.Contributions[contrib.EventID] = &contrib
}
log.I.F("loaded %d policy admin contributions", len(cp.Contributions))
return nil
}
// =============================================================================
// Owner Detection
// =============================================================================
// IsOwner checks if the given pubkey is an owner.
// The pubkey parameter should be binary ([]byte), not hex-encoded.
func (p *P) IsOwner(pubkey []byte) bool {
if len(pubkey) == 0 {
return false
}
p.followsMx.RLock()
defer p.followsMx.RUnlock()
for _, owner := range p.ownersBin {
if utils.FastEqual(owner, pubkey) {
return true
}
}
return false
}
// IsOwnerOrPolicyAdmin checks if the given pubkey is an owner or policy admin.
// The pubkey parameter should be binary ([]byte), not hex-encoded.
func (p *P) IsOwnerOrPolicyAdmin(pubkey []byte) bool {
return p.IsOwner(pubkey) || p.IsPolicyAdmin(pubkey)
}
// =============================================================================
// Helper Functions
// =============================================================================
func containsInt(slice []int, val int) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}
func containsString(slice []string, val string) bool {
for _, v := range slice {
if v == val {
return true
}
}
return false
}