package lua import ( "encoding/json" "os" "sort" ) // Access overrides are OPERATOR policy, not plugin data. They live in their own // file rather than in each plugin's state because: // // - they describe the deployment, not the plugin, so a plugin upgrade that // replaces its .state.json must not silently widen access again; // - they must survive a restart. An in-memory map made "restrict billing to // admin" look like it worked right up to the next deploy, at which point the // restriction evaporated with nothing in the logs to say why; // - the plugin_dir is the one place an operator already looks when asking // "what does this plugin actually get to see", so the answer lives beside it. // // The file is a flat map of "name" (whole plugin) or "name#path" (one endpoint) // to an access spec, which is the same shape the in-memory store uses, so load // and save cannot disagree about what a key means. const accessFileName = ".access.json" func (ps *Plugins) accessFile() string { if ps.dir == "" { return "" } return ps.dir + string(os.PathSeparator) + accessFileName } // LoadAccessOverrides reads the persisted overrides into memory. A missing file // is not an error: no operator has restricted anything yet, which is the // default state of a fresh install. // // A corrupt file is also not fatal. Losing a restriction is bad, but refusing to // start the gateway is worse, and an empty map plus a logged error leaves the // operator with something to fix. func (ps *Plugins) LoadAccessOverrides() error { path := ps.accessFile() if path == "" { return nil } b, err := os.ReadFile(path) if os.IsNotExist(err) { return nil } if err != nil { return err } m := map[string]string{} if err := json.Unmarshal(b, &m); err != nil { return err } ps.mu.Lock() defer ps.mu.Unlock() if ps.visibility == nil { ps.visibility = visibilityOverrides{} } for k, v := range m { ps.visibility[k] = v } return nil } // saveAccessOverridesLocked flushes the whole override map. It is called on // every change, and unlike plugin state the map is tiny and changes only when an // operator clicks Save — so a synchronous write costs nothing that matters, and // it means the change is durable the moment the API returns 200. func (ps *Plugins) saveAccessOverridesLocked() { path := ps.accessFile() if path == "" { return } out := make(map[string]string, len(ps.visibility)) for k, v := range ps.visibility { if v != "" { out[k] = v } } if len(out) == 0 { // Leave no empty file behind: its absence is the honest encoding of // "nothing is overridden". _ = os.Remove(path) return } writeJSONAtomic(path, out) } // AccessOverrides returns a copy of every override in force, for the UI. func (ps *Plugins) accessOverridesSnapshot() map[string]string { ps.mu.RLock() defer ps.mu.RUnlock() if len(ps.visibility) == 0 { return nil } out := make(map[string]string, len(ps.visibility)) for k, v := range ps.visibility { out[k] = v } return out } // sortedOverrideKeys exists only so a debug dump is stable; map iteration order // would otherwise make two identical states look different. func (ps *Plugins) sortedOverrideKeys() []string { m := ps.accessOverridesSnapshot() keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys }