v0.328.0: the catalog allow-list — the box refuses container rights a template asks for and the box does not give (R-971, D2 = A)
gates / gates (push) Successful in 45s
gates / gates (push) Successful in 45s
internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/privpolicy"
|
||||
)
|
||||
|
||||
// R-971 (operator decision D2 = A): every path that writes an app's compose asks admitCompose first, on the RENDERED
|
||||
// compose, and writes nothing when it refuses. The check itself is internal/privpolicy; this file gives it the app's
|
||||
// env exactly as `compose up` will see it, and makes a refusal visible — one operator event per distinct refused
|
||||
// definition, and one line on the app page (Stack.DefinitionRefused) until a later definition of the app is admitted.
|
||||
// The structural guard that every writer asks first: TestAllowList_IsWiredIntoEveryComposeWriter.
|
||||
|
||||
// defRefusal is the last refusal of one app.
|
||||
type defRefusal struct {
|
||||
sha string
|
||||
msg string
|
||||
}
|
||||
|
||||
// SetDefinitionRefusedHook sets what a NEW refusal raises (main.go: the operator-only hub event
|
||||
// `app_definition_refused`). Called once per distinct refused definition of an app, not on every 15-minute cycle.
|
||||
func (m *Manager) SetDefinitionRefusedHook(fn func(name, detail string)) { m.defRefusedHook = fn }
|
||||
|
||||
// policyEnv is env as `compose up` sees it: the app's values, DOMAIN, and the two derived path variables the
|
||||
// controller adds itself (withPathVars — the derived values win, as they do at exec).
|
||||
func (m *Manager) policyEnv(env map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(env)+3)
|
||||
for k, v := range env {
|
||||
out[k] = v
|
||||
}
|
||||
out["DOMAIN"] = m.cfg.Customer.Domain
|
||||
if hdd := env["HDD_PATH"]; hdd != "" {
|
||||
out["USERDATA_PATH"] = appbackup.UserdataDir(appbackup.NamespaceRootFor(hdd, m.sysDataPath))
|
||||
}
|
||||
if ir := m.GetImportRoot(); ir != "" {
|
||||
out["IMPORT_PATH"] = ir
|
||||
} else {
|
||||
delete(out, "IMPORT_PATH")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// admitCompose is the one question every compose writer asks. env is the app's env (nil → the placeholder env: a
|
||||
// template checked before the app has values). A refusal is returned AND recorded; an admitted definition clears the
|
||||
// app's refusal line.
|
||||
func (m *Manager) admitCompose(name string, data []byte, env map[string]string) error {
|
||||
e := privpolicy.PlaceholderEnv()
|
||||
if env != nil {
|
||||
e = m.policyEnv(env)
|
||||
}
|
||||
err := privpolicy.Admit(name, data, e, privpolicy.Policy{SystemDataPath: m.sysDataPath})
|
||||
m.noteAdmission(name, data, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdmitCompose is admitCompose for the callers outside this package (main.go's restore adapter).
|
||||
func (m *Manager) AdmitCompose(name string, data []byte, env map[string]string) error {
|
||||
return m.admitCompose(name, data, env)
|
||||
}
|
||||
|
||||
// admitWithNewValues is the settings change's question: the live compose, rendered with the app's decrypted env and
|
||||
// the changed values on top.
|
||||
func (m *Manager) admitWithNewValues(name, stackDir string, values map[string]string) error {
|
||||
data, err := os.ReadFile(ComposePathIn(stackDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env := map[string]string{}
|
||||
if dec := LoadAppConfigDecrypted(stackDir, m.encKey); dec != nil {
|
||||
for k, v := range dec.Env {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range values {
|
||||
env[k] = v
|
||||
}
|
||||
return m.admitCompose(name, data, env)
|
||||
}
|
||||
|
||||
// AdmitSyncedCompose is the sync's question (sync.Syncer.SetAdmitFn): a deployed app is checked with its own env, a
|
||||
// template of an app not installed with the placeholder env (the install checks again with the real values).
|
||||
func (m *Manager) AdmitSyncedCompose(name string, data []byte) error {
|
||||
var env map[string]string
|
||||
if st, ok := m.GetStack(name); ok && st.Deployed {
|
||||
if cfg := LoadAppConfigDecrypted(filepath.Dir(st.ComposePath), m.encKey); cfg != nil {
|
||||
env = cfg.Env
|
||||
}
|
||||
}
|
||||
return m.admitCompose(name, data, env)
|
||||
}
|
||||
|
||||
func (m *Manager) noteAdmission(name string, data []byte, err error) {
|
||||
m.defRefusedMu.Lock()
|
||||
if m.defRefused == nil {
|
||||
m.defRefused = map[string]defRefusal{}
|
||||
}
|
||||
byApp := m.defRefused
|
||||
if err == nil {
|
||||
delete(byApp, name)
|
||||
m.defRefusedMu.Unlock()
|
||||
return
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
prev, seen := byApp[name]
|
||||
hook := m.defRefusedHook
|
||||
told := sha
|
||||
if hook == nil {
|
||||
told = "" // not wired yet (a sync before main's hook): the first wired sight still raises the event
|
||||
}
|
||||
byApp[name] = defRefusal{sha: told, msg: err.Error()}
|
||||
m.defRefusedMu.Unlock()
|
||||
m.logger.Printf("[WARN] [stacks] %s: definition REFUSED by the allow-list (R-971), nothing written: %v", name, err)
|
||||
if seen && prev.sha == sha {
|
||||
return // the same definition again (the next 15-minute cycle): one event per definition
|
||||
}
|
||||
if hook != nil {
|
||||
hook(name, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// definitionRefused is the app page's line ("" when none).
|
||||
func (m *Manager) definitionRefused(name string) string {
|
||||
m.defRefusedMu.Lock()
|
||||
defer m.defRefusedMu.Unlock()
|
||||
return m.defRefused[name].msg
|
||||
}
|
||||
Reference in New Issue
Block a user