diff --git a/core/cli/run.go b/core/cli/run.go index d4eb3f597f..f3cdbc86c2 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -43,6 +43,9 @@ type RunCMD struct { P2PEnableLimits string `name:"p2p-enable-limits" env:"LOCALAI_P2P_ENABLE_LIMITS" help:"Set to true to enable P2P resource limits" group:"p2p"` AddonHostPolicy string `name:"addon-host-policy" env:"LOCALAI_ADDON_HOST_POLICY" help:"Absolute path to administrator host operation policy JSON, read once at startup. Empty disables environment policy" group:"addons"` AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"` + AddonHostDeny string `name:"addon-host-deny" env:"LOCALAI_ADDON_HOST_DENY" help:"Host operations no grant may enable, as a comma list of addon-id=operation (a prefix such as egress., an exact name or *). Use *=prefix for every add-on. Deny always wins over grants and is unioned with the deny rules of the policy file" group:"addons"` + AddonHostBlanket string `name:"addon-host-blanket" env:"LOCALAI_ADDON_HOST_BLANKET" help:"Blanket approval of declared host operations at install and upgrade: off, official or any. When set, the UI shows it read-only. Unset leaves the choice to the administrator in the UI" group:"addons"` + AddonHostUIGrants bool `name:"addon-host-ui-grants" env:"LOCALAI_ADDON_HOST_UI_GRANTS" default:"true" help:"Let an administrator grant host operations to add-ons in the UI. Turn it off with --addon-host-ui-grants=false to make the policy file and flags the only source of grants" group:"addons"` AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"` AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner). Default: local-owner when authentication is off, named-user when it is on" group:"addons"` AddonsRole string `name:"addons-role" env:"LOCALAI_ADDONS_ROLE" default:"combined" help:"How this process takes part in the add-on owner: combined (this node is its own owner, the default), owner (only the owner, serves the carrier on the network) or frontend (no owner here; every add-on request goes to the owner). owner and frontend need --addons-owner-address and --addons-carrier-dir" group:"addons"` @@ -329,6 +332,10 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { return err } logAddonHostOpsResolution(hostOps) + blanket, err := config.ParseAddonHostBlanket(r.AddonHostBlanket) + if err != nil { + return err + } diagnosticOpts := r.diagnosticsOptions() if err := diagnosticOpts.Validate(); err != nil { @@ -384,6 +391,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { } opts := []config.AppOption{ config.WithAddonHostOps(hostOps.Grants), + config.WithAddonHostDeny(hostOps.Deny), + config.WithAddonHostBlanket(blanket), + config.WithAddonHostUIGrantsLocked(!r.AddonHostUIGrants), config.WithProxyAPIKeyEnvLookup(os.Getenv), config.WithContext(runCtx), config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency), diff --git a/core/cli/run_addon_host_ops_test.go b/core/cli/run_addon_host_ops_test.go index 278ce73baa..27dd31543b 100644 --- a/core/cli/run_addon_host_ops_test.go +++ b/core/cli/run_addon_host_ops_test.go @@ -36,7 +36,7 @@ var _ = Describe("addon host policy CLI precedence", Serial, func() { return &command.Run } BeforeEach(func() { - for _, key := range []string{"LOCALAI_ADDON_HOST_OPS", "LOCALAI_ADDON_HOST_POLICY"} { + for _, key := range []string{"LOCALAI_ADDON_HOST_OPS", "LOCALAI_ADDON_HOST_POLICY", "LOCALAI_ADDON_HOST_DENY"} { old, ok := os.LookupEnv(key) DeferCleanup(func() { if ok { @@ -57,31 +57,66 @@ var _ = Describe("addon host policy CLI precedence", Serial, func() { Expect(got.Source).To(Equal("policy:" + p)) Expect(got.Grants).To(Equal(map[string][]string{"custom": {"workspace."}})) }) - It("uses flag presence even when empty and ignores an invalid policy", func() { + writePolicy := func(body string) string { + p := filepath.Join(GinkgoT().TempDir(), "policy.json") + Expect(os.WriteFile(p, []byte(body), 0600)).To(Succeed()) + return p + } + It("uses flag presence even when empty, ignores the file grants and still applies its deny", func() { GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.") - GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", writePolicy(`{"schemaVersion":2,"grants":{"file":["z."]},"deny":{"*":["egress."]}}`)) got, err := parse("--addon-host-ops=").resolveAddonHostOps() Expect(err).NotTo(HaveOccurred()) Expect(got.Grants).To(BeEmpty()) Expect(got.Source).To(Equal("flag")) - Expect(got.PolicyIgnored).To(BeTrue()) + Expect(got.GrantsIgnored).To(BeTrue()) + Expect(got.Deny).To(Equal(map[string][]string{"*": {"egress."}})) }) - It("uses explicit empty environment instead of policy", func() { + It("uses explicit empty environment instead of file grants and still applies its deny", func() { GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "") - GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", writePolicy(`{"schemaVersion":2,"deny":{"a":["*"]}}`)) got, err := parse().resolveAddonHostOps() Expect(err).NotTo(HaveOccurred()) Expect(got.Grants).To(BeEmpty()) Expect(got.Source).To(Equal("environment")) - Expect(got.PolicyIgnored).To(BeTrue()) + Expect(got.GrantsIgnored).To(BeTrue()) + Expect(got.Deny).To(Equal(map[string][]string{"a": {"*"}})) }) - It("does not union a nonempty flag with environment or policy", func() { - GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.") + It("stops startup on an invalid selected file even when a flat list replaces its grants", func() { GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") + _, err := parse("--addon-host-ops=flag=y.").resolveAddonHostOps() + Expect(err).To(HaveOccurred()) + }) + It("does not union a nonempty flag with the file grants", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_OPS", "env=x.") + GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", writePolicy(`{"schemaVersion":1,"grants":{"file":["z."]}}`)) got, err := parse("--addon-host-ops=flag=y.").resolveAddonHostOps() Expect(err).NotTo(HaveOccurred()) Expect(got.Grants).To(Equal(map[string][]string{"flag": {"y."}})) }) + It("unions the deny flag with the file deny", func() { + got, err := parse("--addon-host-policy="+writePolicy(`{"schemaVersion":2,"deny":{"web":["workspace.admin"]}}`), "--addon-host-deny=web=egress.").resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Deny).To(Equal(map[string][]string{"web": {"workspace.admin", "egress."}})) + }) + It("reads the deny environment variable", func() { + GinkgoT().Setenv("LOCALAI_ADDON_HOST_DENY", "*=connector.") + got, err := parse("--addon-host-policy=" + writePolicy(`{"schemaVersion":2,"deny":{"web":["workspace.admin"]}}`)).resolveAddonHostOps() + Expect(err).NotTo(HaveOccurred()) + Expect(got.Deny).To(Equal(map[string][]string{"web": {"workspace.admin"}, "*": {"connector."}})) + }) + It("rejects an invalid deny flag and blanket mode", func() { + _, err := parse("--addon-host-deny=nonsense").resolveAddonHostOps() + Expect(err).To(HaveOccurred()) + _, err = config.ParseAddonHostBlanket("sometimes") + Expect(err).To(HaveOccurred()) + }) + It("leaves UI grants on and the blanket unset by default", func() { + r := parse() + Expect(r.AddonHostUIGrants).To(BeTrue()) + Expect(r.AddonHostBlanket).To(BeEmpty()) + Expect(parse("--addon-host-ui-grants=false").AddonHostUIGrants).To(BeFalse()) + }) It("lets an empty policy flag disable environment policy without restoring defaults", func() { GinkgoT().Setenv("LOCALAI_ADDON_HOST_POLICY", "/missing/policy") got, err := parse("--addon-host-policy=").resolveAddonHostOps() diff --git a/core/cli/run_addon_host_policy.go b/core/cli/run_addon_host_policy.go index 8ea389d6bb..7c435cedc5 100644 --- a/core/cli/run_addon_host_policy.go +++ b/core/cli/run_addon_host_policy.go @@ -41,7 +41,13 @@ func (r *RunCMD) resolveAddonHostOps() (config.AddonHostOpsResolution, error) { } else if value, present := os.LookupEnv("LOCALAI_ADDON_HOST_POLICY"); present { policy = &value } - resolved, err := config.ResolveAddonHostOps(flag, environment, policy) + var deny *string + if r.AddonHostDeny != "" { + deny = &r.AddonHostDeny + } + // Kong has already merged the deny environment variable into the field, + // so there is no separate environment value to union. + resolved, err := config.ResolveAddonHostOps(flag, environment, policy, deny, nil) if err == nil && resolved.Source == "policy" { resolved.Source = "policy:" + *policy } @@ -50,15 +56,19 @@ func (r *RunCMD) resolveAddonHostOps() (config.AddonHostOpsResolution, error) { func logAddonHostOpsResolution(resolved config.AddonHostOpsResolution) { if resolved.Source == "empty" { - xlog.Warn("Addons receive no host operations: select --addon-host-policy or explicitly configure --addon-host-ops") + xlog.Warn("Addons receive no host operations from the policy file or flags: an administrator can grant them in the UI at install, or select --addon-host-policy or --addon-host-ops") } prefixes := 0 for _, grants := range resolved.Grants { prefixes += len(grants) } - xlog.Info("Addon host operation policy resolved", "source", resolved.Source, "addons", len(resolved.Grants), "prefixes", prefixes) - if resolved.PolicyIgnored { - xlog.Warn("Addon host policy ignored: explicit host operations override takes precedence") + denied := 0 + for _, entries := range resolved.Deny { + denied += len(entries) } - xlog.Info("Addon manifests and libraries cannot grant host operations; policy changes require restart") + xlog.Info("Addon host operation policy resolved", "source", resolved.Source, "addons", len(resolved.Grants), "prefixes", prefixes, "denyRules", denied) + if resolved.GrantsIgnored { + xlog.Warn("Addon host policy grants ignored, deny applied: an explicit host operations list replaces the file grants") + } + xlog.Info("Addon manifests and libraries cannot grant host operations; file and flag policy changes require restart") } diff --git a/core/config/addon_host_ops.go b/core/config/addon_host_ops.go index 239587131d..4a8a2345a6 100644 --- a/core/config/addon_host_ops.go +++ b/core/config/addon_host_ops.go @@ -4,12 +4,14 @@ package config import ( "fmt" "regexp" + "slices" "strings" ) var ( addonIDForHostOps = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) hostOpPrefix = regexp.MustCompile(`^[a-z]+\.([a-z]+\.)*$`) + hostOpExact = regexp.MustCompile(`^[a-z]+(\.[a-z]+)+$`) ) // ParseAddonHostOps reads a comma list of addon-id=op-prefix pairs. An add-on @@ -38,3 +40,94 @@ func WithAddonHostOps(ops map[string][]string) AppOption { snapshot := CloneAddonHostOps(ops) return func(o *ApplicationConfig) { o.AddonHostOps = CloneAddonHostOps(snapshot) } } + +// validDenyEntry accepts what a deny rule may name: a prefix (workspace.), +// one exact operation (workspace.admin) or the single token "*". +func validDenyEntry(s string) bool { + return s == "*" || hostOpPrefix.MatchString(s) || hostOpExact.MatchString(s) +} + +// ParseAddonHostDeny reads a comma list of addon-id=entry pairs, where addon-id +// may be "*" for every add-on and entry is a prefix, an exact operation name +// or "*". Duplicates are errors; "*" must be the only entry of its add-on. +func ParseAddonHostDeny(s string) (map[string][]string, error) { + out := map[string][]string{} + for _, item := range strings.Split(s, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + id, entry, ok := strings.Cut(item, "=") + id, entry = strings.TrimSpace(id), strings.TrimSpace(entry) + if !ok || !(id == "*" || addonIDForHostOps.MatchString(id)) || !validDenyEntry(entry) { + return nil, fmt.Errorf("invalid host deny entry %q: want addon-id=op-prefix or *=op-prefix, such as *=egress. or custom-addon=*", item) + } + for _, existing := range out[id] { + if existing == entry { + return nil, fmt.Errorf("duplicate host deny entry %q", item) + } + } + out[id] = append(out[id], entry) + } + if len(out) > 128 { + return nil, fmt.Errorf("host deny exceeds 128 addons") + } + for id, entries := range out { + if len(entries) > 32 { + return nil, fmt.Errorf("host deny exceeds 32 entries for %q", id) + } + for _, e := range entries { + if e == "*" && len(entries) != 1 { + return nil, fmt.Errorf(`host deny "*" must be the only entry of %q`, id) + } + } + } + return out, nil +} + +// MergeAddonHostDeny unions deny maps. Entries stay in first-seen order and a +// "*" absorbs every other entry of its key. The result is never nil. +func MergeAddonHostDeny(maps ...map[string][]string) map[string][]string { + out := map[string][]string{} + for _, m := range maps { + for id, entries := range m { + for _, e := range entries { + if !slices.Contains(out[id], e) { + out[id] = append(out[id], e) + } + } + } + } + for id, entries := range out { + if slices.Contains(entries, "*") { + out[id] = []string{"*"} + } + } + return out +} + +// ParseAddonHostBlanket validates the deployment blanket mode. The empty +// string means the deployment does not set it and the UI decides. +func ParseAddonHostBlanket(s string) (string, error) { + switch s = strings.TrimSpace(s); s { + case "", "off", "official", "any": + return s, nil + } + return "", fmt.Errorf("invalid --addon-host-blanket %q: want off, official or any", s) +} + +// WithAddonHostDeny sets the startup deny rules. The map is copied. +func WithAddonHostDeny(deny map[string][]string) AppOption { + snapshot := CloneAddonHostOps(deny) + return func(o *ApplicationConfig) { o.AddonHostDeny = CloneAddonHostOps(snapshot) } +} + +// WithAddonHostBlanket sets the deployment blanket mode ("" leaves it to the UI). +func WithAddonHostBlanket(mode string) AppOption { + return func(o *ApplicationConfig) { o.AddonHostBlanket = mode } +} + +// WithAddonHostUIGrantsLocked makes the UI grant store read-only. +func WithAddonHostUIGrantsLocked(locked bool) AppOption { + return func(o *ApplicationConfig) { o.AddonHostUIGrantsLocked = locked } +} diff --git a/core/config/addon_host_policy.go b/core/config/addon_host_policy.go index 8812950092..7e8ade0703 100644 --- a/core/config/addon_host_policy.go +++ b/core/config/addon_host_policy.go @@ -12,19 +12,33 @@ import ( const maxAddonHostPolicyBytes = 64 * 1024 -// AddonHostOpsResolution is a startup snapshot. Consumers must not mutate Grants. -// Source and PolicyIgnored are bounded diagnostics, never policy file contents. +// AddonHostOpsResolution is a startup snapshot. Consumers must not mutate +// Grants or Deny. Source and GrantsIgnored are bounded diagnostics, never +// policy file contents. type AddonHostOpsResolution struct { - Grants map[string][]string - Source string - PolicyIgnored bool + Grants map[string][]string + // Deny is the union of the deny rules of the selected policy file, the + // --addon-host-deny flag and its environment variable. Keys are add-on + // IDs or "*". Values are prefixes, exact operation names or the single + // token "*". + Deny map[string][]string + Source string + // GrantsIgnored is true when a selected policy file had its grants + // replaced by a flat --addon-host-ops list. Its deny rules still apply. + GrantsIgnored bool } -// ResolveAddonHostOps applies presence-based precedence. Nil means absent; -// a pointer to an empty string is an explicit decision to grant nothing. -// policyPath is already selected by the caller (flag, including empty, over env). -func ResolveAddonHostOps(flag, environment, policyPath *string) (AddonHostOpsResolution, error) { - out := AddonHostOpsResolution{Grants: map[string][]string{}, Source: "empty"} +// ResolveAddonHostOps applies presence-based precedence to grants. Nil means +// absent; a pointer to an empty string is an explicit decision to grant +// nothing. policyPath is already selected by the caller (flag, including +// empty, over env). +// +// A selected policy file is always opened, because its deny rules must +// survive a flat grants list: a deny that disappears when someone adds a flag +// is a security failure. Deny rules from the file, denyFlag and denyEnv are +// unioned. +func ResolveAddonHostOps(flag, environment, policyPath, denyFlag, denyEnv *string) (AddonHostOpsResolution, error) { + out := AddonHostOpsResolution{Grants: map[string][]string{}, Deny: map[string][]string{}, Source: "empty"} selected := flag if selected != nil { out.Source = "flag" @@ -33,62 +47,104 @@ func ResolveAddonHostOps(flag, environment, policyPath *string) (AddonHostOpsRes out.Source = "environment" } var err error + var file AddonHostPolicy + filePresent := policyPath != nil && *policyPath != "" + if filePresent { + file, err = LoadAddonHostPolicyFile(*policyPath) + if err != nil { + if out.Source == "empty" { + out.Source = "policy" + } + return out, fmt.Errorf("addon host operations (%s): %w", out.Source, err) + } + } if selected != nil { - out.PolicyIgnored = policyPath != nil && *policyPath != "" + out.GrantsIgnored = filePresent out.Grants, err = ParseAddonHostOps(*selected) - } else if policyPath != nil && *policyPath != "" { + } else if filePresent { out.Source = "policy" - out.Grants, err = LoadAddonHostPolicy(*policyPath) + out.Grants = file.Grants } if err != nil { return out, fmt.Errorf("addon host operations (%s): %w", out.Source, err) } + out.Deny = MergeAddonHostDeny(file.Deny) + for _, raw := range []*string{denyFlag, denyEnv} { + if raw == nil { + continue + } + parsed, perr := ParseAddonHostDeny(*raw) + if perr != nil { + return out, fmt.Errorf("addon host deny: %w", perr) + } + out.Deny = MergeAddonHostDeny(out.Deny, parsed) + } return out, nil } -// LoadAddonHostPolicy reads only an explicitly selected administrator file. +// AddonHostPolicy is the decoded content of a policy file. +type AddonHostPolicy struct { + SchemaVersion int + Grants map[string][]string + Deny map[string][]string +} + +// LoadAddonHostPolicy reads a policy file and returns only its grants. It +// remains for callers that predate the deny list. +func LoadAddonHostPolicy(path string) (map[string][]string, error) { + p, err := LoadAddonHostPolicyFile(path) + return p.Grants, err +} + +// LoadAddonHostPolicyFile reads only an explicitly selected administrator file. // Ownership and mount permissions are the deployment administrator's responsibility. // Symlinks to regular files are permitted; this is not a filesystem sandbox. -func LoadAddonHostPolicy(path string) (map[string][]string, error) { +func LoadAddonHostPolicyFile(path string) (AddonHostPolicy, error) { if !filepath.IsAbs(path) { - return nil, fmt.Errorf("policy path must be absolute") + return AddonHostPolicy{}, fmt.Errorf("policy path must be absolute") } // Stat before opening rejects directories and special files (including FIFOs) // without a potentially blocking read. Recheck the opened descriptor as well. info, err := os.Stat(path) if err != nil { - return nil, fmt.Errorf("stat policy: %w", err) + return AddonHostPolicy{}, fmt.Errorf("stat policy: %w", err) } if !info.Mode().IsRegular() { - return nil, fmt.Errorf("policy must be a regular file") + return AddonHostPolicy{}, fmt.Errorf("policy must be a regular file") } // #nosec G304 -- Only the administrator selects this startup policy path via CLI/config; regular-file and 64 KiB checks bound its read. f, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("open policy: %w", err) + return AddonHostPolicy{}, fmt.Errorf("open policy: %w", err) } // Read-only cleanup: close errors cannot change the decoded result. defer func() { _ = f.Close() }() info, err = f.Stat() if err != nil { - return nil, fmt.Errorf("stat opened policy: %w", err) + return AddonHostPolicy{}, fmt.Errorf("stat opened policy: %w", err) } if !info.Mode().IsRegular() || info.Size() > maxAddonHostPolicyBytes { - return nil, fmt.Errorf("policy must be a regular file of at most 64 KiB") + return AddonHostPolicy{}, fmt.Errorf("policy must be a regular file of at most 64 KiB") } data, err := io.ReadAll(io.LimitReader(f, maxAddonHostPolicyBytes+1)) if err != nil { - return nil, fmt.Errorf("read policy: %w", err) + return AddonHostPolicy{}, fmt.Errorf("read policy: %w", err) } if len(data) > maxAddonHostPolicyBytes { - return nil, fmt.Errorf("policy exceeds 64 KiB") + return AddonHostPolicy{}, fmt.Errorf("policy exceeds 64 KiB") } return parseAddonHostPolicy(data) } +// Policy file schema versions this binary reads. +const ( + addonHostPolicyV1 = 1 + addonHostPolicyV2 = 2 +) + // Token decoding deliberately avoids encoding/json struct-field case folding // and map decoding's last-key-wins behavior. -func parseAddonHostPolicy(data []byte) (map[string][]string, error) { +func parseAddonHostPolicy(data []byte) (AddonHostPolicy, error) { d := json.NewDecoder(bytes.NewReader(data)) d.UseNumber() expect := func(want json.Delim) error { @@ -101,91 +157,127 @@ func parseAddonHostPolicy(data []byte) (map[string][]string, error) { } return nil } + fail := func(err error) (AddonHostPolicy, error) { return AddonHostPolicy{}, err } if err := expect('{'); err != nil { - return nil, err + return fail(err) } seen := map[string]bool{} - grants := map[string][]string{} + out := AddonHostPolicy{Grants: map[string][]string{}, Deny: map[string][]string{}} for d.More() { key, err := d.Token() if err != nil { - return nil, err + return fail(err) } name, ok := key.(string) if !ok || seen[name] { - return nil, fmt.Errorf("duplicate or invalid policy key") + return fail(fmt.Errorf("duplicate or invalid policy key")) } seen[name] = true switch name { case "schemaVersion": version, err := d.Token() if err != nil { - return nil, err + return fail(err) } - if version != json.Number("1") { - return nil, fmt.Errorf("schemaVersion must be integer 1") + n, isNumber := version.(json.Number) + v, convErr := n.Int64() + switch { + case !isNumber || convErr != nil || v < addonHostPolicyV1: + return fail(fmt.Errorf("schemaVersion must be an integer (supported versions: 1, 2)")) + case v > addonHostPolicyV2: + return fail(fmt.Errorf("policy requires a newer LocalAI (supported versions: 1, 2)")) } + out.SchemaVersion = int(v) case "grants": - if err := expect('{'); err != nil { - return nil, err + m, err := readHostOpMap(d, expect, hostOpPrefix.MatchString, false) + if err != nil { + return fail(err) } - for d.More() { - token, err := d.Token() - if err != nil { - return nil, err - } - id, ok := token.(string) - if !ok || !addonIDForHostOps.MatchString(id) { - return nil, fmt.Errorf("invalid policy addon ID") - } - if _, exists := grants[id]; exists { - return nil, fmt.Errorf("duplicate policy addon ID") - } - if len(grants) >= 128 { - return nil, fmt.Errorf("policy exceeds 128 addons") - } - if err := expect('['); err != nil { - return nil, err - } - prefixes := []string{} - seenPrefixes := map[string]bool{} - for d.More() { - token, err := d.Token() - if err != nil { - return nil, err - } - prefix, ok := token.(string) - if !ok || !hostOpPrefix.MatchString(prefix) || seenPrefixes[prefix] { - return nil, fmt.Errorf("invalid or duplicate policy prefix") - } - if len(prefixes) >= 32 { - return nil, fmt.Errorf("policy exceeds 32 prefixes per addon") - } - prefixes = append(prefixes, prefix) - seenPrefixes[prefix] = true - } - if err := expect(']'); err != nil { - return nil, err - } - grants[id] = prefixes - } - if err := expect('}'); err != nil { - return nil, err + out.Grants = m + case "deny": + m, err := readHostOpMap(d, expect, validDenyEntry, true) + if err != nil { + return fail(err) } + out.Deny = m default: - return nil, fmt.Errorf("unknown policy key") + return fail(fmt.Errorf("unknown policy key")) } } + if err := expect('}'); err != nil { + return fail(err) + } + if !seen["schemaVersion"] { + return fail(fmt.Errorf("policy requires schemaVersion")) + } + if out.SchemaVersion == addonHostPolicyV1 { + if seen["deny"] { + return fail(fmt.Errorf("deny requires schemaVersion 2")) + } + if !seen["grants"] { + return fail(fmt.Errorf("policy requires schemaVersion and grants")) + } + } + if _, err := d.Token(); err != io.EOF { + return fail(fmt.Errorf("unexpected trailing policy data")) + } + return out, nil +} + +// readHostOpMap reads {"id": ["entry", ...]}. For a deny map the key "*" is +// valid and the entry "*" must stand alone. +func readHostOpMap(d *json.Decoder, expect func(json.Delim) error, validEntry func(string) bool, deny bool) (map[string][]string, error) { + if err := expect('{'); err != nil { + return nil, err + } + out := map[string][]string{} + for d.More() { + token, err := d.Token() + if err != nil { + return nil, err + } + id, ok := token.(string) + if !ok || !(addonIDForHostOps.MatchString(id) || (deny && id == "*")) { + return nil, fmt.Errorf("invalid policy addon ID") + } + if _, exists := out[id]; exists { + return nil, fmt.Errorf("duplicate policy addon ID") + } + if len(out) >= 128 { + return nil, fmt.Errorf("policy exceeds 128 addons") + } + if err := expect('['); err != nil { + return nil, err + } + entries := []string{} + seenEntries := map[string]bool{} + for d.More() { + token, err := d.Token() + if err != nil { + return nil, err + } + entry, ok := token.(string) + if !ok || !validEntry(entry) || seenEntries[entry] { + return nil, fmt.Errorf("invalid or duplicate policy prefix") + } + if len(entries) >= 32 { + return nil, fmt.Errorf("policy exceeds 32 prefixes per addon") + } + entries = append(entries, entry) + seenEntries[entry] = true + } + if err := expect(']'); err != nil { + return nil, err + } + if seenEntries["*"] && len(entries) != 1 { + return nil, fmt.Errorf(`policy deny "*" must be the only entry of its list`) + } + out[id] = entries + } if err := expect('}'); err != nil { return nil, err } - if !seen["schemaVersion"] || !seen["grants"] { - return nil, fmt.Errorf("policy requires schemaVersion and grants") - } - if _, err := d.Token(); err != io.EOF { - return nil, fmt.Errorf("unexpected trailing policy data") - } - return grants, nil + return out, nil } // CloneAddonHostOps isolates startup grants from caller-owned maps and slices. diff --git a/core/config/addon_host_policy_test.go b/core/config/addon_host_policy_test.go index e548bbc86e..a8aa9a304c 100644 --- a/core/config/addon_host_policy_test.go +++ b/core/config/addon_host_policy_test.go @@ -33,7 +33,8 @@ var _ = Describe("Addon host policy", func() { Entry("missing grants", `{"schemaVersion":1}`), Entry("null grants", `{"schemaVersion":1,"grants":null}`), Entry("null prefixes", `{"schemaVersion":1,"grants":{"a":null}}`), - Entry("wrong version", `{"schemaVersion":2,"grants":{}}`), + Entry("version 3", `{"schemaVersion":3,"grants":{}}`), + Entry("version 0", `{"schemaVersion":0,"grants":{}}`), Entry("string version", `{"schemaVersion":"1","grants":{}}`), Entry("fraction version", `{"schemaVersion":1.0,"grants":{}}`), Entry("unknown key", `{"schemaVersion":1,"grants":{},"extra":true}`), @@ -97,9 +98,9 @@ var _ = Describe("Addon host policy", func() { _, err = LoadAddonHostPolicy(write(`{"schemaVersion":1,"grants":{"a":[` + strings.Join(prefixes, ",") + `]}}`)) Expect(err).To(HaveOccurred()) }) - It("resolves presence without union or opening an ignored policy", func() { + It("resolves presence without union, always opening the selected policy", func() { empty, env, flag := "", "env=x.", "flag=y." - bad := "/not-present-policy.json" + bad := write(`{"schemaVersion":2,"grants":{"unused":["q."]},"deny":{"*":["egress."]}}`) policy := write(`{"schemaVersion":1,"grants":{"file":["z."]}}`) for _, tc := range []struct { flag, env, policy *string @@ -115,14 +116,17 @@ var _ = Describe("Addon host policy", func() { {nil, &empty, &bad, map[string][]string{}, "environment", true}, {nil, nil, &empty, map[string][]string{}, "empty", false}, } { - got, err := ResolveAddonHostOps(tc.flag, tc.env, tc.policy) + got, err := ResolveAddonHostOps(tc.flag, tc.env, tc.policy, nil, nil) Expect(err).NotTo(HaveOccurred()) Expect(got.Grants).To(Equal(tc.want)) Expect(got.Source).To(Equal(tc.source)) - Expect(got.PolicyIgnored).To(Equal(tc.ignored)) + Expect(got.GrantsIgnored).To(Equal(tc.ignored)) } - _, err := ResolveAddonHostOps(nil, nil, &bad) + missing := "/not-present-policy.json" + _, err := ResolveAddonHostOps(nil, nil, &missing, nil, nil) Expect(err).To(HaveOccurred()) + _, err = ResolveAddonHostOps(&flag, nil, &missing, nil, nil) + Expect(err).To(HaveOccurred(), "an invalid selected file stops startup even when a flat list replaces its grants") }) It("snapshots programmatic grants including an exact empty decision", func() { original := map[string][]string{"a": {"x."}} @@ -138,4 +142,88 @@ var _ = Describe("Addon host policy", func() { WithAddonHostOps(map[string][]string{})(c) Expect(c.AddonHostOps).To(BeEmpty()) }) + + Describe("schemaVersion 2 and the deny list", func() { + It("reads grants and deny, and accepts a deny-only file", func() { + p, err := LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"grants":{"sandbox":["workspace.","egress."]},"deny":{"*":["connector."],"web":["workspace.admin"],"telegram":["*"]}}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Grants).To(Equal(map[string][]string{"sandbox": {"workspace.", "egress."}})) + Expect(p.Deny).To(Equal(map[string][]string{"*": {"connector."}, "web": {"workspace.admin"}, "telegram": {"*"}})) + p, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{"*":["egress."]}}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(p.Grants).To(BeEmpty()) + Expect(p.Deny).To(HaveLen(1)) + }) + DescribeTable("rejects", func(body, message string) { + _, err := LoadAddonHostPolicyFile(write(body)) + Expect(err).To(MatchError(ContainSubstring(message))) + }, + Entry("deny in a v1 file", `{"schemaVersion":1,"grants":{},"deny":{}}`, "deny requires schemaVersion 2"), + Entry("deny before the version in a v1 file", `{"deny":{},"schemaVersion":1,"grants":{}}`, "deny requires schemaVersion 2"), + Entry("v1 without grants", `{"schemaVersion":1}`, "requires schemaVersion and grants"), + Entry("version 3", `{"schemaVersion":3,"grants":{}}`, "requires a newer LocalAI"), + Entry("a version that is not an integer", `{"schemaVersion":"2","grants":{}}`, "supported versions: 1, 2"), + Entry("a missing version", `{"grants":{}}`, "requires schemaVersion"), + Entry("duplicate deny key", `{"schemaVersion":2,"deny":{},"deny":{}}`, "duplicate"), + Entry("duplicate deny add-on", `{"schemaVersion":2,"deny":{"a":["x."],"a":["y."]}}`, "duplicate"), + Entry("duplicate deny entry", `{"schemaVersion":2,"deny":{"a":["x.","x."]}}`, "duplicate"), + Entry("star next to another entry", `{"schemaVersion":2,"deny":{"a":["*","x."]}}`, "only entry"), + Entry("star as a grants key", `{"schemaVersion":2,"grants":{"*":["x."]}}`, "invalid policy addon ID"), + Entry("star as a grant", `{"schemaVersion":2,"grants":{"a":["*"]}}`, "invalid"), + Entry("a bad deny entry", `{"schemaVersion":2,"deny":{"a":["X."]}}`, "invalid"), + Entry("a bad deny key", `{"schemaVersion":2,"deny":{"A":["x."]}}`, "invalid policy addon ID"), + Entry("null deny", `{"schemaVersion":2,"deny":null}`, "expected"), + Entry("unknown key in v2", `{"schemaVersion":2,"grant":{}}`, "unknown policy key"), + Entry("trailing data", `{"schemaVersion":2,"deny":{}} {}`, "trailing"), + ) + It("bounds the deny list", func() { + ids := []string{} + for i := 0; i < 129; i++ { + ids = append(ids, fmt.Sprintf(`"a-%d":["x."]`, i)) + } + _, err := LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{` + strings.Join(ids[:128], ",") + `}}`)) + Expect(err).NotTo(HaveOccurred()) + _, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{` + strings.Join(ids, ",") + `}}`)) + Expect(err).To(HaveOccurred()) + entries := []string{} + for i := 0; i < 33; i++ { + entries = append(entries, fmt.Sprintf(`"x.%s."`, string(rune('a'+i/26))+string(rune('a'+i%26)))) + } + _, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{"a":[` + strings.Join(entries[:32], ",") + `]}}`)) + Expect(err).NotTo(HaveOccurred()) + _, err = LoadAddonHostPolicyFile(write(`{"schemaVersion":2,"deny":{"a":[` + strings.Join(entries, ",") + `]}}`)) + Expect(err).To(HaveOccurred()) + }) + It("keeps the 64 KiB file limit", func() { + body := `{"schemaVersion":2,"deny":{}}` + _, err := LoadAddonHostPolicyFile(write(body + strings.Repeat(" ", 65537-len(body)))) + Expect(err).To(HaveOccurred()) + }) + It("parses the flag grammar and unions it with the file", func() { + got, err := ParseAddonHostDeny("*=egress., web=workspace.admin ,telegram=*") + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(map[string][]string{"*": {"egress."}, "web": {"workspace.admin"}, "telegram": {"*"}})) + Expect(ParseAddonHostDeny("")).To(BeEmpty()) + for _, bad := range []string{"x", "a=", "A=x.", "a=X.", "a=x.,a=x.", "a=*,a=x.", "=x.", "a=workspace", "*=*,*=x."} { + _, err := ParseAddonHostDeny(bad) + Expect(err).To(HaveOccurred(), bad) + } + Expect(MergeAddonHostDeny(map[string][]string{"a": {"x."}}, map[string][]string{"a": {"x.", "y."}, "b": {"*"}})). + To(Equal(map[string][]string{"a": {"x.", "y."}, "b": {"*"}})) + Expect(MergeAddonHostDeny(map[string][]string{"a": {"x."}}, map[string][]string{"a": {"*"}})). + To(Equal(map[string][]string{"a": {"*"}})) + }) + It("unions the file deny with the flag deny and applies it under a flat list", func() { + policy := write(`{"schemaVersion":2,"grants":{"f":["w."]},"deny":{"a":["x."]}}`) + ops, deny := "o=y.", "a=z.,*=*" + got, err := ResolveAddonHostOps(&ops, nil, &policy, &deny, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Grants).To(Equal(map[string][]string{"o": {"y."}})) + Expect(got.GrantsIgnored).To(BeTrue()) + Expect(got.Deny).To(Equal(map[string][]string{"a": {"x.", "z."}, "*": {"*"}})) + bad := "nonsense" + _, err = ResolveAddonHostOps(nil, nil, &policy, &bad, nil) + Expect(err).To(HaveOccurred()) + }) + }) }) diff --git a/core/config/application_config.go b/core/config/application_config.go index e9c4d13781..306fbe78f1 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -100,6 +100,16 @@ type ApplicationConfig struct { // call. It is startup-only. Nil or empty means no add-on gets a host // operation endpoint. AddonHostOps map[string][]string `json:"-" yaml:"-"` + // AddonHostDeny maps an add-on ID, or "*" for all, to host operation + // prefixes, exact names or "*" that no grant may enable. Startup-only; + // deny set in the UI is stored separately and applies live. + AddonHostDeny map[string][]string `json:"-" yaml:"-"` + // AddonHostBlanket is the blanket approval mode set by the deployment + // ("off", "official", "any"). Empty leaves the choice to the UI. + AddonHostBlanket string `json:"-" yaml:"-"` + // AddonHostUIGrantsLocked makes the UI grant store read-only, so the + // policy file and flags are the only source of grants. + AddonHostUIGrantsLocked bool `json:"-" yaml:"-"` // DiagnosticsRecorder is startup-only and local to this frontend process. DiagnosticsRecorder *diagnostics.Recorder `json:"-" yaml:"-"`