package lua import ( "encoding/json" "os" ) // Kernel action grants are OPERATOR policy, persisted separately from both // plugin state and the endpoint/visibility overrides. // // Why a separate file rather than reusing .access.json: // // - .access.json is a flat map[string]string ("name" or "name#path" -> spec). // Kernel grants are a list plus a boolean, so folding them in would either // change the shape of a file an older binary still reads, or force an // encoding that cannot round-trip. An upgrade must not corrupt a running // deployment's restrictions. // - The two answer different questions — "who may call this endpoint" vs // "what may this plugin do to the kernel" — and an operator looking at one // should not have to parse the other. // // Shape: { "": { "actions": ["reload", ...], "all_read": true } } const kernelPermsFileName = ".kernel-perms.json" func (ps *Plugins) kernelPermsFile() string { if ps.dir == "" { return "" } return ps.dir + string(os.PathSeparator) + kernelPermsFileName } // LoadKernelPermissions reads the persisted grants. A missing file is the normal // fresh-install state (nothing granted); a corrupt file is logged and ignored // rather than fatal — losing a grant is recoverable, refusing to boot is not. func (ps *Plugins) LoadKernelPermissions() error { path := ps.kernelPermsFile() if path == "" { return nil } b, err := os.ReadFile(path) if os.IsNotExist(err) { return nil } if err != nil { return err } // The file holds BOTH kernel-action grants (map[string]jsonPermission, the // historical shape) AND, for plugins that asked for them, the operator's // extra-host grants (map[string]jsonPluginPolicy.Hosts). A bare // {actions:[...]} row still parses into jsonPluginPolicy with empty Hosts, // so old files load unchanged. raw := map[string]jsonPluginPolicy{} if err := json.Unmarshal(b, &raw); err != nil { return err } ps.mu.Lock() defer ps.mu.Unlock() if ps.kernelPerms == nil { ps.kernelPerms = kernelPermissionSet{} } if ps.extraHosts == nil { ps.extraHosts = map[string][]string{} } for name, jp := range raw { ps.kernelPerms[name] = KernelPermission{Actions: jp.Actions, AllRead: jp.AllRead} if len(jp.Hosts) > 0 { // Load the persisted grant into extraHosts WITHOUT folding it into the // allowlist yet — sources are loaded later, and the caller owns that // fold. GrantExtraHosts rebuilds the allowlist when sources are ready. ps.extraHosts[name] = append([]string(nil), jp.Hosts...) } } return nil } // saveKernelPermissionsLocked flushes the grant map. Caller holds ps.mu. // // Grants drop unknown action names on write. If an operator authorized an action // that a later release removed, keeping the name would leave a stale entry that // nothing can ever satisfy — and it would reappear in the UI as if it were live. // // Host grants travel in the same file (jsonPluginPolicy.Hosts): a granted host is // the operator's explicit decision to let a plugin egress somewhere, and losing // it across a restart (the previous behaviour) made grants silently vanish. func (ps *Plugins) saveKernelPermissionsLocked() { path := ps.kernelPermsFile() if path == "" { return } out := map[string]jsonPluginPolicy{} something := false for name, perm := range ps.kernelPerms { valid := make([]string, 0, len(perm.Actions)) for _, a := range perm.Actions { if _, ok := LookupKernelAction(a); ok { valid = append(valid, a) } } row := policyFromActions(KernelPermission{Actions: valid, AllRead: perm.AllRead}) if hs := ps.extraHosts[name]; len(hs) > 0 { row.Hosts = append([]string(nil), hs...) } if len(valid) == 0 && !perm.AllRead && len(row.Hosts) == 0 { continue // nothing granted: presence would be a lie } out[name] = row something = true } // A plugin may hold host grants with NO kernel actions granted (operators // approve hosts and actions independently). Emit those too. for name, hs := range ps.extraHosts { if len(hs) == 0 { continue } if _, exists := out[name]; !exists { out[name] = jsonPluginPolicy{Hosts: append([]string(nil), hs...)} something = true } } if !something { _ = os.Remove(path) return } writeJSONAtomic(path, out) }