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 }