mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 12:33:16 -05:00
feat(addons): add schemaVersion 2 host policy with a deny list
A policy file may now carry a deny object. Deny rules name an add-on ID or "*" and list prefixes, exact operation names or the single token "*". Schema 1 files parse as before; a deny key in a schema 1 file, or a version above 2, is an error that names the problem. The selected file is now always opened, even when a flat --addon-host-ops list replaces its grants, because a deny must not vanish when someone adds a flag. An invalid selected file still stops startup. Add --addon-host-deny (LOCALAI_ADDON_HOST_DENY), unioned with the file, and the --addon-host-blanket and --addon-host-ui-grants flags with their config options. Assisted-by: Claude:claude-sonnet-5-5 go
This commit is contained in:
7 files changed
+442
-104
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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:"-"`
|
||||
|
||||
Reference in new issue
Block a user