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) {