From 3ca176d15db7bfdc280bed40811ade1cb336fcc3 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 22:30:23 +0000 Subject: [PATCH] feat(addons): lock UI host grants while sign-in is off With authentication off every caller that reaches the port is the same local owner, and the checks that tell a person from a script are headers the caller chooses. Any process or agent that could reach the port could grant host operations, set deny rules and turn the blanket approval on. UI host grants are now locked by default in that mode. The policy file and flags still grant, and every write route answers 403 locked_by_deployment with a sentence that says why. The overview and the preview carry the lock and its reason for the page. The new flag --addon-host-ui-grants-without-auth turns the UI grants on knowingly and logs a startup warning. With sign-in on nothing changes. Assisted-by: Claude:claude-sonnet-5-5 go gofmt --- core/application/addon_hostgrants.go | 27 ++++++++-- core/application/addon_hostgrants_test.go | 45 +++++++++++++++- core/cli/run.go | 52 ++++++++++--------- core/config/addon_host_ops.go | 6 +++ core/config/application_config.go | 4 ++ core/services/addons/gateway/hostgrants.go | 45 ++++++++++------ .../addons/gateway/hostgrants_owner.go | 26 +++++++++- 7 files changed, 157 insertions(+), 48 deletions(-) diff --git a/core/application/addon_hostgrants.go b/core/application/addon_hostgrants.go index fcf587005..b5ee04275 100644 --- a/core/application/addon_hostgrants.go +++ b/core/application/addon_hostgrants.go @@ -13,6 +13,7 @@ import ( "sync/atomic" "time" + "github.com/mudler/LocalAI/core/http/auth" "github.com/mudler/LocalAI/core/services/addons" "github.com/mudler/LocalAI/core/services/addons/gateway" "github.com/mudler/LocalAI/core/services/addons/hostgrants" @@ -35,9 +36,11 @@ var errHostGrantsLocked = errors.New("host grants are locked by the deployment") // audit file, the deployment floor and the glue to the add-on manager. Only // the owner has one. A frontend never stores or enforces grants. type addonHostGrants struct { - floor hostgrants.Floor - locked bool - override string + floor hostgrants.Floor + locked bool + // lockedReason is gateway.LockedByDeployment or gateway.LockedNoAuth. + lockedReason string + override string store *hostgrants.Store storeErr error @@ -94,7 +97,15 @@ func (a *Application) hostGrantsFloorFromConfig() hostgrants.Floor { // the deny rules in it cannot be read. func (a *Application) openAddonHostGrants(root string) { cfg := a.applicationConfig - g := &addonHostGrants{floor: a.hostGrantsFloorFromConfig(), locked: cfg.AddonHostUIGrantsLocked, override: cfg.AddonHostBlanket} + g := &addonHostGrants{floor: a.hostGrantsFloorFromConfig(), override: cfg.AddonHostBlanket} + switch { + case cfg.AddonHostUIGrantsLocked: + g.locked, g.lockedReason = true, gateway.LockedByDeployment + case a.AddonOwnershipMode() == auth.AddonModeLocalOwner && !cfg.AddonHostUIGrantsWithoutAuth: + // Without sign-in every caller that reaches the port is the same + // administrator, so a UI write proves nothing about who made it. + g.locked, g.lockedReason = true, gateway.LockedNoAuth + } g.store, g.storeErr = hostgrants.Open(filepath.Join(root, addonHostGrantsFile)) if g.storeErr == nil { var err error @@ -107,6 +118,12 @@ func (a *Application) openAddonHostGrants(root string) { } a.addonGrants = g g.logStartup(cfg.AddonHostOpsSource) + switch { + case g.lockedReason == gateway.LockedNoAuth: + xlog.Warn("Addon host grants are locked because sign-in is off: anyone who can reach this port could change them. Set them in the policy file, turn sign-in on, or pass --addon-host-ui-grants-without-auth to allow UI changes knowingly") + case a.AddonOwnershipMode() == auth.AddonModeLocalOwner && !g.locked: + xlog.Warn("Addon host grants can be changed from the UI although sign-in is off: anyone who can reach this port can grant host operations, set deny rules and turn the blanket approval on") + } if g.storeErr == nil { a.addonGrantStore = g } @@ -617,7 +634,7 @@ func (a *Application) wireHostGrantsOwner(g *gateway.Gateway) error { title, text, risk, ok := hostops.DescribeOperation(op) return gateway.HostOpDescription{Title: title, Text: text, Risk: risk}, ok }, - BlanketOverride: hg.override, Locked: hg.locked, + BlanketOverride: hg.override, Locked: hg.locked, LockedReason: hg.lockedReason, Completed: a.addonManager.OperationCompleted, Admin: g.HostSettingsAdmin, AfterChange: a.AddonHostGrantsAfterChange, diff --git a/core/application/addon_hostgrants_test.go b/core/application/addon_hostgrants_test.go index 64b23325e..ac77cfc8f 100644 --- a/core/application/addon_hostgrants_test.go +++ b/core/application/addon_hostgrants_test.go @@ -110,7 +110,9 @@ var _ = Describe("host grants on the owner", func() { transport, e := gateway.LoadCarrierConfig(keys, "owner", "https://127.0.0.1:"+port) Expect(e).NotTo(HaveOccurred()) ctx, cancel := context.WithCancel(context.Background()) - cfg := &config.ApplicationConfig{Context: ctx, DataPath: ownerData} + // The suite runs in local-owner mode, where UI grants are locked unless the + // administrator turns them on knowingly. Most specs test the grants. + cfg := &config.ApplicationConfig{Context: ctx, DataPath: ownerData, AddonHostUIGrantsWithoutAuth: true} for _, o := range opts { o(cfg) } @@ -357,6 +359,47 @@ var _ = Describe("host grants on the owner", func() { Expect(st.Installed).To(BeFalse()) }) + It("locks the UI grants while sign-in is off, and opens them only when the administrator says so", func() { + a, stop := startOwner(func(c *config.ApplicationConfig) { + c.AddonHostUIGrantsWithoutAuth = false + c.AddonHostOps = map[string][]string{"sample": {"workspace."}} + }) + out, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead}) + Expect(e).NotTo(HaveOccurred()) + Expect(out["locked"]).To(BeTrue()) + Expect(out["lockedReason"]).To(Equal(gateway.LockedNoAuth)) + // Every write is refused with the typed code, and nothing is stored. + before := revision(a) + for _, c := range []gateway.HostGrantsCall{ + {Op: gateway.HostGrantsEdit, AddonID: "sample", OperationID: "e-1", Body: body(gateway.HostGrantsEditRequest{OperationID: "e-1", Ops: []string{"egress.check"}})}, + {Op: gateway.HostGrantsRevoke, AddonID: "sample", OperationID: "r-1", Body: body(gateway.HostGrantsRevokeRequest{OperationID: "r-1"})}, + {Op: gateway.HostGrantsDeny, OperationID: "d-1", Body: body(gateway.HostGrantsDenyRequest{OperationID: "d-1", Deny: map[string][]string{"sample": {"egress."}}})}, + {Op: gateway.HostGrantsBlanket, OperationID: "b-1", Body: body(gateway.HostGrantsBlanketRequest{OperationID: "b-1", Mode: "any", Confirm: gateway.BlanketConfirmAny})}, + } { + _, e = call(a, c) + var he *gateway.HostGrantsError + Expect(errors.As(e, &he)).To(BeTrue(), c.Op) + Expect(he.Type).To(Equal(gateway.HostGrantsLocked), c.Op) + Expect(he.Message).To(ContainSubstring("Sign-in is off")) + } + Expect(revision(a)).To(Equal(before)) + // The preview tells the install dialog, and the policy file still grants. + pv, e := call(a, gateway.HostGrantsCall{Op: gateway.HostGrantsPreview, Body: body(gateway.HostGrantsPreviewRequest{SourceID: "test", AddonID: "sample", Version: "1.2.3"})}) + Expect(e).NotTo(HaveOccurred()) + Expect(pv["locked"]).To(BeTrue()) + Expect(submit(a, "i-1", "install", "1.2.3", "", "")).To(Succeed()) + Expect(ask(a, "workspace.materialize")).To(Equal(http.StatusOK)) + stop() + + // Turned on knowingly, the same server accepts the write. + open, _ := startOwner() + out, e = call(open, gateway.HostGrantsCall{Op: gateway.HostGrantsRead}) + Expect(e).NotTo(HaveOccurred()) + Expect(out["locked"]).To(BeFalse()) + _, e = call(open, gateway.HostGrantsCall{Op: gateway.HostGrantsEdit, AddonID: "sample", OperationID: "e-2", Body: body(gateway.HostGrantsEditRequest{OperationID: "e-2", Revision: revision(open), Ops: []string{"egress.check"}})}) + Expect(e).NotTo(HaveOccurred()) + }) + It("keeps the grants of a split deployment on the owner only", func() { a, _ := startOwner() Expect(install(a, "i-1", "install", "1.2.3", []string{"workspace.materialize"}, "")).To(Succeed()) diff --git a/core/cli/run.go b/core/cli/run.go index 7c7ccaad2..d233b85fd 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -40,31 +40,32 @@ import ( const workerJoinCommand = "local-ai p2p-worker p2p-llama-cpp-rpc" 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"` - AddonsMigrateHostSettings bool `name:"addons-migrate-host-settings" env:"LOCALAI_ADDONS_MIGRATE_HOST_SETTINGS" default:"false" help:"Owner role only: explicitly select this process's effective legacy host policy for first-start migration. Never imports frontend settings; ignored once the owner store exists" group:"addons"` - AddonsOwnerAddress string `name:"addons-owner-address" env:"LOCALAI_ADDONS_OWNER_ADDRESS" help:"Address of the add-on owner carrier, as https://host:port with no path. Use a stable service name, never a pod address. Required for the owner and frontend roles" group:"addons"` - AddonsOwnerListen string `name:"addons-owner-listen" env:"LOCALAI_ADDONS_OWNER_LISTEN" help:"Owner role only: the address the carrier listens on. Empty listens on all interfaces, on the port of --addons-owner-address" group:"addons"` - AddonsCarrierDir string `name:"addons-carrier-dir" env:"LOCALAI_ADDONS_CARRIER_DIR" type:"path" help:"Directory with the carrier key set made by 'local-ai util addons-carrier-keys'. Required for the owner and frontend roles" group:"addons"` - TrustedProxies string `name:"trusted-proxies" env:"LOCALAI_TRUSTED_PROXIES" default:"" help:"Comma-separated IPs and CIDRs of reverse proxies in front of LocalAI. Only when the TCP peer is in this list is the client address taken from X-Forwarded-For (right-most untrusted entry). Used by the access log, login rate limits and traces. Empty trusts nobody" group:"api"` - LogClient bool `name:"log-client" env:"LOCALAI_LOG_CLIENT" default:"false" help:"Add the duration, client address, user agent and request id to the HTTP access log. Off by default. The address is the TCP peer unless it is a trusted proxy (see --trusted-proxies). Turn it on with --log-client" group:"logging"` - AutoUpgradeAddons bool `name:"auto-upgrade-addons" env:"LOCALAI_AUTO_UPGRADE_ADDONS" default:"false" help:"Upgrade installed add-ons on their own when a newer version appears in their library. Off by default; upgrades then stay manual" group:"addons"` - AddonsOfficialLibrary bool `name:"addons-official-library" env:"LOCALAI_ADDONS_OFFICIAL_LIBRARY" default:"true" help:"Register the official add-on library on the first start, when the add-on owner has no sources.json yet. A library the admin removes later stays removed. Turn it off with --addons-official-library=false" group:"addons"` - AddonsBackendLibrary bool `name:"addons-backend-library" env:"LOCALAI_ADDONS_BACKEND_LIBRARY" default:"true" help:"Register the backend library on the first start, when the add-on owner has no sources.json yet. A build from master reads it as the backend source; a tagged build reads the backend gallery until the release cutover. Turn it off to read the backend gallery only" group:"addons"` - OfficialLibraries bool `name:"official-libraries" env:"LOCALAI_OFFICIAL_LIBRARIES" default:"true" help:"Use the official (shipped default) library sources of add-ons, backends and models. Off: they stay in the list, shown as off, and are not read. Sources you added are not affected. Also a runtime setting (official_libraries)" group:"addons"` - AddonsBackendMaxBytes int64 `name:"addons-backend-max-bytes" env:"LOCALAI_ADDONS_BACKEND_MAX_BYTES" default:"8589934592" help:"Largest compressed size, in bytes, of a backend image installed from a library. Add-ons keep their own limit of 512 MiB" group:"addons"` - Pprof bool `name:"pprof" env:"LOCALAI_PPROF" default:"false" help:"Enable the private loopback profiling listener" group:"diagnostics"` - PprofAddress string `name:"pprof-address" env:"LOCALAI_PPROF_ADDRESS" default:"127.0.0.1:6060" help:"Numeric loopback address and port for profiling" group:"diagnostics"` - PprofMutexProfileFraction int `name:"pprof-mutex-profile-fraction" env:"LOCALAI_PPROF_MUTEX_PROFILE_FRACTION" default:"0" help:"Mutex sampling fraction (0 disables sampling); requires pprof" group:"diagnostics"` - PprofBlockProfileRate int `name:"pprof-block-profile-rate" env:"LOCALAI_PPROF_BLOCK_PROFILE_RATE" default:"0" help:"Block sampling rate in nanoseconds (0 disables sampling); requires pprof" group:"diagnostics"` - RequestPhaseTiming bool `name:"request-phase-timing" env:"LOCALAI_REQUEST_PHASE_TIMING" default:"false" help:"Log sanitized request and config-reload phase timings at Info" group:"diagnostics"` + 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"` + AddonHostUIGrantsWithoutAuth bool `name:"addon-host-ui-grants-without-auth" env:"LOCALAI_ADDON_HOST_UI_GRANTS_WITHOUT_AUTH" default:"false" help:"Let the UI change host grants although authentication is off. By default they are locked then, because anyone who can reach this server could change them" 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"` + AddonsMigrateHostSettings bool `name:"addons-migrate-host-settings" env:"LOCALAI_ADDONS_MIGRATE_HOST_SETTINGS" default:"false" help:"Owner role only: explicitly select this process's effective legacy host policy for first-start migration. Never imports frontend settings; ignored once the owner store exists" group:"addons"` + AddonsOwnerAddress string `name:"addons-owner-address" env:"LOCALAI_ADDONS_OWNER_ADDRESS" help:"Address of the add-on owner carrier, as https://host:port with no path. Use a stable service name, never a pod address. Required for the owner and frontend roles" group:"addons"` + AddonsOwnerListen string `name:"addons-owner-listen" env:"LOCALAI_ADDONS_OWNER_LISTEN" help:"Owner role only: the address the carrier listens on. Empty listens on all interfaces, on the port of --addons-owner-address" group:"addons"` + AddonsCarrierDir string `name:"addons-carrier-dir" env:"LOCALAI_ADDONS_CARRIER_DIR" type:"path" help:"Directory with the carrier key set made by 'local-ai util addons-carrier-keys'. Required for the owner and frontend roles" group:"addons"` + TrustedProxies string `name:"trusted-proxies" env:"LOCALAI_TRUSTED_PROXIES" default:"" help:"Comma-separated IPs and CIDRs of reverse proxies in front of LocalAI. Only when the TCP peer is in this list is the client address taken from X-Forwarded-For (right-most untrusted entry). Used by the access log, login rate limits and traces. Empty trusts nobody" group:"api"` + LogClient bool `name:"log-client" env:"LOCALAI_LOG_CLIENT" default:"false" help:"Add the duration, client address, user agent and request id to the HTTP access log. Off by default. The address is the TCP peer unless it is a trusted proxy (see --trusted-proxies). Turn it on with --log-client" group:"logging"` + AutoUpgradeAddons bool `name:"auto-upgrade-addons" env:"LOCALAI_AUTO_UPGRADE_ADDONS" default:"false" help:"Upgrade installed add-ons on their own when a newer version appears in their library. Off by default; upgrades then stay manual" group:"addons"` + AddonsOfficialLibrary bool `name:"addons-official-library" env:"LOCALAI_ADDONS_OFFICIAL_LIBRARY" default:"true" help:"Register the official add-on library on the first start, when the add-on owner has no sources.json yet. A library the admin removes later stays removed. Turn it off with --addons-official-library=false" group:"addons"` + AddonsBackendLibrary bool `name:"addons-backend-library" env:"LOCALAI_ADDONS_BACKEND_LIBRARY" default:"true" help:"Register the backend library on the first start, when the add-on owner has no sources.json yet. A build from master reads it as the backend source; a tagged build reads the backend gallery until the release cutover. Turn it off to read the backend gallery only" group:"addons"` + OfficialLibraries bool `name:"official-libraries" env:"LOCALAI_OFFICIAL_LIBRARIES" default:"true" help:"Use the official (shipped default) library sources of add-ons, backends and models. Off: they stay in the list, shown as off, and are not read. Sources you added are not affected. Also a runtime setting (official_libraries)" group:"addons"` + AddonsBackendMaxBytes int64 `name:"addons-backend-max-bytes" env:"LOCALAI_ADDONS_BACKEND_MAX_BYTES" default:"8589934592" help:"Largest compressed size, in bytes, of a backend image installed from a library. Add-ons keep their own limit of 512 MiB" group:"addons"` + Pprof bool `name:"pprof" env:"LOCALAI_PPROF" default:"false" help:"Enable the private loopback profiling listener" group:"diagnostics"` + PprofAddress string `name:"pprof-address" env:"LOCALAI_PPROF_ADDRESS" default:"127.0.0.1:6060" help:"Numeric loopback address and port for profiling" group:"diagnostics"` + PprofMutexProfileFraction int `name:"pprof-mutex-profile-fraction" env:"LOCALAI_PPROF_MUTEX_PROFILE_FRACTION" default:"0" help:"Mutex sampling fraction (0 disables sampling); requires pprof" group:"diagnostics"` + PprofBlockProfileRate int `name:"pprof-block-profile-rate" env:"LOCALAI_PPROF_BLOCK_PROFILE_RATE" default:"0" help:"Block sampling rate in nanoseconds (0 disables sampling); requires pprof" group:"diagnostics"` + RequestPhaseTiming bool `name:"request-phase-timing" env:"LOCALAI_REQUEST_PHASE_TIMING" default:"false" help:"Log sanitized request and config-reload phase timings at Info" group:"diagnostics"` addonHostOpsFlagPresent bool addonHostPolicyFlagPresent bool @@ -395,6 +396,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) { config.WithAddonHostDeny(hostOps.Deny), config.WithAddonHostBlanket(blanket), config.WithAddonHostUIGrantsLocked(!r.AddonHostUIGrants), + config.WithAddonHostUIGrantsWithoutAuth(r.AddonHostUIGrantsWithoutAuth), config.WithProxyAPIKeyEnvLookup(os.Getenv), config.WithContext(runCtx), config.WithArtifactDownloadConcurrency(r.ArtifactDownloadConcurrency), diff --git a/core/config/addon_host_ops.go b/core/config/addon_host_ops.go index 2767591a6..76284e962 100644 --- a/core/config/addon_host_ops.go +++ b/core/config/addon_host_ops.go @@ -134,6 +134,12 @@ func WithAddonHostBlanket(mode string) AppOption { return func(o *ApplicationConfig) { o.AddonHostBlanket = mode } } +// WithAddonHostUIGrantsWithoutAuth lets the UI change host grants although +// authentication is off. +func WithAddonHostUIGrantsWithoutAuth(on bool) AppOption { + return func(o *ApplicationConfig) { o.AddonHostUIGrantsWithoutAuth = on } +} + // 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/application_config.go b/core/config/application_config.go index fe55a5da4..311c26e0c 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -114,6 +114,10 @@ type ApplicationConfig struct { // AddonHostUIGrantsLocked makes the UI grant store read-only, so the // policy file and flags are the only source of grants. AddonHostUIGrantsLocked bool `json:"-" yaml:"-"` + // AddonHostUIGrantsWithoutAuth turns UI host grants on although + // authentication is off. Without it they are locked in that mode, since + // anyone who can reach the port could change them. + AddonHostUIGrantsWithoutAuth bool `json:"-" yaml:"-"` // DiagnosticsRecorder is startup-only and local to this frontend process. DiagnosticsRecorder *diagnostics.Recorder `json:"-" yaml:"-"` diff --git a/core/services/addons/gateway/hostgrants.go b/core/services/addons/gateway/hostgrants.go index 926b0ab20..28f53dc0c 100644 --- a/core/services/addons/gateway/hostgrants.go +++ b/core/services/addons/gateway/hostgrants.go @@ -402,12 +402,14 @@ type HostGrantsDenyView struct { // HostGrantsView is the response of GET /api/addons/host-grants. type HostGrantsView struct { - Revision uint64 `json:"revision"` - Locked bool `json:"locked"` - Blanket HostBlanketView `json:"blanket"` - Floor HostGrantsFloor `json:"floor"` - Deny HostGrantsDenyView `json:"deny"` - Addons []HostGrantsAddon `json:"addons"` + Revision uint64 `json:"revision"` + Locked bool `json:"locked"` + // LockedReason is LockedByDeployment or LockedNoAuth when Locked. + LockedReason string `json:"lockedReason,omitempty"` + Blanket HostBlanketView `json:"blanket"` + Floor HostGrantsFloor `json:"floor"` + Deny HostGrantsDenyView `json:"deny"` + Addons []HostGrantsAddon `json:"addons"` } // HostBlanketPreview says whether the blanket switch would grant on install. @@ -426,15 +428,17 @@ type HostGrantsPreviewView struct { Official bool `json:"official"` // Added are the operations this version asks for that the installed // version did not. Empty on an install. - Added []string `json:"added"` - Requested HostRequested `json:"requested"` - Ops []HostOpView `json:"ops"` - CanInstall bool `json:"canInstall"` - BlockedReq []string `json:"blockedRequired"` - Revision uint64 `json:"revision"` - Nonce string `json:"nonce"` - Blanket HostBlanketPreview `json:"blanket"` - NonceExpiry string `json:"nonceExpiresAt"` + Added []string `json:"added"` + Requested HostRequested `json:"requested"` + Ops []HostOpView `json:"ops"` + CanInstall bool `json:"canInstall"` + BlockedReq []string `json:"blockedRequired"` + Revision uint64 `json:"revision"` + Nonce string `json:"nonce"` + Locked bool `json:"locked"` + LockedReason string `json:"lockedReason,omitempty"` + Blanket HostBlanketPreview `json:"blanket"` + NonceExpiry string `json:"nonceExpiresAt"` } // HostGrantsAddonResult is the response of an edit. @@ -540,3 +544,14 @@ func (c *Carrier) HostGrants(ctx context.Context, a contract.Actor, call HostGra } return reply.Data, nil } + +// Reasons the UI grants are locked. +const ( + // LockedByDeployment: the policy file and flags are the only source. + LockedByDeployment = "deployment" + // LockedNoAuth: sign-in is off, so anyone who can reach the server could + // change the grants. + LockedNoAuth = "no_auth" + // LockedNoAuthText is the sentence shown with the refusal. + LockedNoAuthText = "Sign-in is off, so anyone who can reach this server could change these; they are set by the administrator in the policy file" +) diff --git a/core/services/addons/gateway/hostgrants_owner.go b/core/services/addons/gateway/hostgrants_owner.go index 17f4712f0..e86463b00 100644 --- a/core/services/addons/gateway/hostgrants_owner.go +++ b/core/services/addons/gateway/hostgrants_owner.go @@ -42,6 +42,9 @@ type HostGrantsConfig struct { BlanketOverride string // Locked refuses every write: the deployment keeps the file the only source. Locked bool + // LockedReason says why: LockedByDeployment or LockedNoAuth. It selects the + // text of the refusal and is shown by the page. + LockedReason string // Completed reports whether a lifecycle operation completed. Nil counts // every entry as completed. Completed func(operationID string) bool @@ -159,7 +162,7 @@ func (o *HostGrantsOwner) Serve(ctx context.Context, a contract.Actor, call Host } if o.cfg.Locked { o.refused(a, call, "locked_by_deployment") - return nil, hgErr(403, HostGrantsLocked, "The deployment keeps host access in its policy file; it cannot be changed here") + return nil, hgErr(403, HostGrantsLocked, o.lockedText()) } } var out any @@ -395,7 +398,7 @@ func (o *HostGrantsOwner) read(ctx context.Context) (HostGrantsView, error) { if err != nil { return HostGrantsView{}, err } - v := HostGrantsView{Revision: doc.Revision, Locked: o.cfg.Locked, + v := HostGrantsView{Revision: doc.Revision, Locked: o.cfg.Locked, LockedReason: o.lockedReason(), Blanket: HostBlanketView{Mode: o.blanketMode(doc), IncludeOptional: doc.Blanket.IncludeOptional, SetBy: "ui", Revision: doc.Blanket.Revision}, Floor: HostGrantsFloor{Source: string(hostgrants.SourceDeployment), Addons: []HostFloorAddon{}}, Deny: HostGrantsDenyView{File: nonNilMap(o.cfg.Floor.Deny), UI: nonNilMap(doc.Deny)}, @@ -465,6 +468,7 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG v := HostGrantsPreviewView{AddonID: rel.AddonID, Title: rel.Title, Digest: rel.Digest, Version: rel.Version, Installed: rel.Installed, Official: rel.Official, Revision: doc.Revision, Ops: []HostOpView{}, BlockedReq: nonNil(plan.BlockedRequired), Added: []string{}, Requested: HostRequested{Required: nonNil(sorted(rel.Required)), Optional: nonNil(sorted(rel.Optional))}} + v.Locked, v.LockedReason = o.cfg.Locked, o.lockedReason() before := unique(installed.Required, installed.Optional) for _, op := range declared { ov := HostOpView{Name: op, Required: req[op], Hint: slices.Contains(hint, op)} @@ -573,6 +577,24 @@ func (o *HostGrantsOwner) find(ctx context.Context, id string) (InstalledAccess, return InstalledAccess{}, o.notFound() } +// lockedReason is the reason the page shows, or "" when writes are open. +func (o *HostGrantsOwner) lockedReason() string { + if !o.cfg.Locked { + return "" + } + if o.cfg.LockedReason == "" { + return LockedByDeployment + } + return o.cfg.LockedReason +} + +func (o *HostGrantsOwner) lockedText() string { + if o.lockedReason() == LockedNoAuth { + return LockedNoAuthText + } + return "The deployment keeps host access in its policy file; it cannot be changed here" +} + func (o *HostGrantsOwner) now() string { return o.cfg.Now().UTC().Format(time.RFC3339) } func (o *HostGrantsOwner) edit(ctx context.Context, a contract.Actor, id string, r HostGrantsEditRequest) (any, error) {