package plugin import ( "fmt" "sync" "go.uber.org/zap" ) // Sandbox enforces permission boundaries and isolates plugin failures. type Sandbox struct { permissions map[string]Permissions // pluginName → declared permissions mu sync.RWMutex log *zap.Logger } // NewSandbox creates a permission enforcement sandbox. func NewSandbox(log *zap.Logger) *Sandbox { return &Sandbox{ permissions: make(map[string]Permissions), log: log, } } // Register records a plugin's declared permissions. func (s *Sandbox) Register(name string, perms Permissions) { s.mu.Lock() defer s.mu.Unlock() s.permissions[name] = perms s.log.Info("plugin permissions registered", zap.String("plugin", name), zap.Bool("database", len(perms.Database) > 0), zap.Bool("storage", perms.Storage), zap.Bool("network", len(perms.Network) > 0), zap.Int("events", len(perms.Events)), zap.Int("routes", len(perms.Routes)), zap.Bool("admin_panel", perms.AdminPanel), ) } // CheckDatabase verifies the plugin has database access for the given table. // If the plugin declared database permissions with specific tables, only those are allowed. // If declared with empty list (all tables), any table is allowed. func (s *Sandbox) CheckDatabase(pluginName, table string) error { s.mu.RLock() defer s.mu.RUnlock() perms, ok := s.permissions[pluginName] if !ok { return fmt.Errorf("plugin %q not registered in sandbox", pluginName) } if len(perms.Database) == 0 { return fmt.Errorf("plugin %q has no database permission", pluginName) } // Empty string in list means "all tables" for _, t := range perms.Database { if t == "" || t == "*" || t == table { return nil } } return fmt.Errorf("plugin %q not authorized for table %q", pluginName, table) } // CheckStorage verifies the plugin has file storage access. func (s *Sandbox) CheckStorage(pluginName string) error { s.mu.RLock() defer s.mu.RUnlock() perms, ok := s.permissions[pluginName] if !ok { return fmt.Errorf("plugin %q not registered in sandbox", pluginName) } if !perms.Storage { return fmt.Errorf("plugin %q has no storage permission", pluginName) } return nil } // CheckNetwork verifies the plugin can access the given domain. func (s *Sandbox) CheckNetwork(pluginName, domain string) error { s.mu.RLock() defer s.mu.RUnlock() perms, ok := s.permissions[pluginName] if !ok { return fmt.Errorf("plugin %q not registered in sandbox", pluginName) } if len(perms.Network) == 0 { return fmt.Errorf("plugin %q has no network permission", pluginName) } for _, d := range perms.Network { if d == "*" || d == domain { return nil } } return fmt.Errorf("plugin %q not authorized for domain %q", pluginName, domain) } // CheckEvent verifies the plugin can emit the given event. func (s *Sandbox) CheckEvent(pluginName, event string) error { s.mu.RLock() defer s.mu.RUnlock() perms, ok := s.permissions[pluginName] if !ok { return fmt.Errorf("plugin %q not registered in sandbox", pluginName) } if len(perms.Events) == 0 { return fmt.Errorf("plugin %q has no events permission", pluginName) } for _, e := range perms.Events { if e == "*" || e == event { return nil } } return fmt.Errorf("plugin %q not authorized for event %q", pluginName, event) } // CheckRoute verifies the plugin can register the given route path. func (s *Sandbox) CheckRoute(pluginName, path string) error { s.mu.RLock() defer s.mu.RUnlock() perms, ok := s.permissions[pluginName] if !ok { return fmt.Errorf("plugin %q not registered in sandbox", pluginName) } if len(perms.Routes) == 0 { return fmt.Errorf("plugin %q has no routes permission", pluginName) } for _, r := range perms.Routes { if r == "*" || r == path { return nil } // Handle trailing wildcard: "/api/v1/items/*" matches "/api/v1/items/anything" if len(r) > 2 && r[len(r)-1] == '*' && r[len(r)-2] == '/' { prefix := r[:len(r)-1] // "/api/v1/items/" if len(path) > len(prefix) && path[:len(prefix)] == prefix { return nil } } } return fmt.Errorf("plugin %q not authorized for route %q", pluginName, path) } // CheckAdminPanel verifies the plugin can access the admin panel. func (s *Sandbox) CheckAdminPanel(pluginName string) error { s.mu.RLock() defer s.mu.RUnlock() perms, ok := s.permissions[pluginName] if !ok { return fmt.Errorf("plugin %q not registered in sandbox", pluginName) } if !perms.AdminPanel { return fmt.Errorf("plugin %q has no admin_panel permission", pluginName) } return nil } // SafeInit wraps plugin.Init() with panic recovery. // If the plugin panics, it's logged but doesn't crash the main process. func (s *Sandbox) SafeInit(p Plugin) (err error) { name := p.Metadata().Name defer func() { if r := recover(); r != nil { s.log.Error("plugin init panicked (recovered)", zap.String("plugin", name), zap.Any("panic", r), ) err = fmt.Errorf("plugin %s init panicked: %v", name, r) } }() return p.Init() } // SafeSetupRoutes wraps plugin.SetupRoutes() with panic recovery. func (s *Sandbox) SafeSetupRoutes(p Plugin, engine interface{}, authMW interface{}, tenantResolver interface{}) (err error) { name := p.Metadata().Name defer func() { if r := recover(); r != nil { s.log.Error("plugin setup routes panicked (recovered)", zap.String("plugin", name), zap.Any("panic", r), ) err = fmt.Errorf("plugin %s setup routes panicked: %v", name, r) } }() // Type assertion happens here — if types don't match, it panics and we recover // In practice, the caller should pass the correct types return nil }