diff --git a/core/http/react-ui/e2e/addons-install-file.spec.js b/core/http/react-ui/e2e/addons-install-file.spec.js
index 03d6d4109f..e89e43dd49 100644
--- a/core/http/react-ui/e2e/addons-install-file.spec.js
+++ b/core/http/react-ui/e2e/addons-install-file.spec.js
@@ -38,7 +38,7 @@ const PREVIEW = {
],
}
-async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody } = {}) {
+async function open(page, { file = fileView(), installed = [], uploadStatus = 200, uploadBody, locked } = {}) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true }) }))
const state = await mockAddons(page, { apps: [], installed })
const seen = { uploads: [], described: [], removed: [], preview: [], install: [], dispatch: state.log.dispatch }
@@ -60,7 +60,7 @@ async function open(page, { file = fileView(), installed = [], uploadStatus = 20
const body = req.postData() ? JSON.parse(req.postData()) : null
const json = (payload, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(payload) })
if (path === '/') return json({ revision: 4, locked: false, blanket: { mode: 'off', includeOptional: false, setBy: 'ui', revision: 0 }, floor: { source: 'file', addons: [] }, deny: { file: {}, ui: {} }, addons: [] })
- if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed }) }
+ if (path === '/preview') { seen.preview.push(body); return json({ ...PREVIEW, installed: file.installed, ...(locked ? { locked: true, lockedReason: locked } : {}) }) }
if (path === '/install') {
seen.install.push({ body, confirm: req.headers()['x-localai-confirm'] })
if (!file.installed) {
@@ -141,6 +141,15 @@ test.describe('Install from a file', () => {
expect(seen.install[0].body.action).toBe('upgrade')
})
+ test('a deployment that locks UI host grants cannot install a file', async ({ page }) => {
+ await open(page, { locked: 'no_auth' })
+ await page.goto('/app/addons')
+ await page.getByTestId('install-file-input').setInputFiles({ name: 'm.zip', mimeType: 'application/zip', buffer: Buffer.from('PK') })
+ const dialog = page.getByTestId('install-dialog')
+ await expect(dialog.getByTestId('install-file-locked')).toContainText('Sign-in is off')
+ await expect(dialog.getByTestId('install-confirm')).toBeDisabled()
+ })
+
test('the same version cannot be installed again', async ({ page }) => {
await open(page, { file: fileView({ installed: true, installedVersion: '0.2.0', relation: 'same' }), installed: [installedAddon({ id: 'memory', title: 'Short memory', version: '0.2.0', sourceId: 'local-file', verification: 'sha256' })] })
await page.goto('/app/addons')
diff --git a/core/http/react-ui/src/components/addons/InstallDialog.jsx b/core/http/react-ui/src/components/addons/InstallDialog.jsx
index c94f2652cd..85b396fd0b 100644
--- a/core/http/react-ui/src/components/addons/InstallDialog.jsx
+++ b/core/http/react-ui/src/components/addons/InstallDialog.jsx
@@ -79,7 +79,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
if (!entry) return null
const showHost = host.status === 'ready' && needsStep(host.preview)
const block = showHost ? installBlock(host.preview) : null
- const gate = local ? fileGate(local, { downgradeTicked: downgrade }) : { allowed: true }
+ const gate = local ? fileGate(local, { downgradeTicked: downgrade, locked: Boolean(host.preview && host.preview.locked) }) : { allowed: true }
const start = () => {
if (local) {
// A file installs only with the approval of this dialog, with or without
@@ -117,6 +117,9 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
{t('file.unsignedNotice')}
)}
+ {local && host.preview && host.preview.locked && (
+
{t('file.relation.upgrade', { installed: local.installedVersion, version: local.version })}
)} diff --git a/core/http/react-ui/src/components/addons/fileInstall.js b/core/http/react-ui/src/components/addons/fileInstall.js index f67309b909..fa61474796 100644 --- a/core/http/react-ui/src/components/addons/fileInstall.js +++ b/core/http/react-ui/src/components/addons/fileInstall.js @@ -55,7 +55,8 @@ export function fileEntry(file) { // Whether the file may be installed at all, and what the person must confirm // first. The same version is never installed again; an older one needs a tick. -export function fileGate(file, { downgradeTicked = false } = {}) { +export function fileGate(file, { downgradeTicked = false, locked = false } = {}) { + if (locked) return { allowed: false, reason: 'locked' } if (!file || file.relation === 'same') return { allowed: false, reason: 'same' } if (file.relation === 'downgrade' && !downgradeTicked) return { allowed: false, reason: 'downgrade' } return { allowed: true, reason: '' } diff --git a/core/http/react-ui/tests/unit/addonfile.test.mjs b/core/http/react-ui/tests/unit/addonfile.test.mjs index 0ef33338d6..35761f5efc 100644 --- a/core/http/react-ui/tests/unit/addonfile.test.mjs +++ b/core/http/react-ui/tests/unit/addonfile.test.mjs @@ -42,6 +42,7 @@ test('addonfile: the same version is never installed, an older one needs the tic assert.equal(fileGate(f('upgrade')).allowed, true) assert.equal(fileGate(f('new')).allowed, true) assert.equal(fileGate(null).allowed, false) + assert.deepEqual(fileGate(f('new'), { locked: true }), { allowed: false, reason: 'locked' }) }) test('addonfile: an installed add-on is upgraded and a new one installed', () => { diff --git a/core/services/addons/gateway/hostfiles.go b/core/services/addons/gateway/hostfiles.go index b47e4b8b1b..8c2622c6af 100644 --- a/core/services/addons/gateway/hostfiles.go +++ b/core/services/addons/gateway/hostfiles.go @@ -87,6 +87,12 @@ func (o *HostGrantsOwner) fileCall(ctx context.Context, a contract.Actor, call H return nil, ErrUnavailable } bad := func() (any, error) { return nil, o.invalid() } + // An unsigned file installs only where UI host grants work: when the + // deployment locks them (sign-in off, or a policy file that is the only + // source), nothing can be staged either, and the answer says why. + if call.Op == HostGrantsFileBegin && o.cfg.Locked { + return nil, hgErr(403, HostGrantsLocked, o.lockedText()) + } switch call.Op { case HostGrantsFileBegin: var r fileBeginRequest diff --git a/core/services/addons/gateway/hostfiles_test.go b/core/services/addons/gateway/hostfiles_test.go index a9eec04746..1cf0185e1b 100644 --- a/core/services/addons/gateway/hostfiles_test.go +++ b/core/services/addons/gateway/hostfiles_test.go @@ -183,6 +183,18 @@ var _ = Describe("add-on zip files reach the owner", func() { Expect(typeOf(err)).To(Equal("not_human_surface")) }) + It("stages nothing while the deployment locks the UI host grants", func() { + sim := &fileSim{store: &uploads.Store{Root: filepath.Join(GinkgoT().TempDir(), "uploads")}, max: 1 << 20} + fx := newHGFixture(func(c *HostGrantsConfig) { c.Locked = true; c.LockedReason = LockedNoAuth }) + fx.svc.WithFiles(sim) + g := &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok} + _, err := g.UploadFile(context.Background(), alice, "x.zip", strings.NewReader("x")) + Expect(typeOf(err)).To(Equal("locked_by_deployment")) + Expect(err.Error()).To(Equal(LockedNoAuthText)) + ents, _ := os.ReadDir(sim.store.Root) + Expect(ents).To(BeEmpty()) + }) + It("answers unavailable when the owner has no file service", func() { fx := newHGFixture() g := &Gateway{HostGrantsOwner: fx.svc, Revalidate: ok} diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index c8ce56e482..1fc9f7cd0d 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -1108,6 +1108,15 @@ and the confirmation of an administrator in their own browser session, like every change of what an add-on may do on the host. `--target` and `--api-key` come from `LOCALAI_TARGET` and `LOCALAI_API_KEY` when you leave them out. +### When sign-in is off or the grants are locked + +An unsigned file installs only where UI host grants work. While sign-in is off, +and when a deployment locks the UI grants, the owner refuses to stage a file +(`403 locked_by_deployment`) and the dialog says so. Turn the UI grants on +knowingly with `--addon-host-ui-grants-without-auth`, or turn sign-in on. A +package from a file replaces a package from another library or signer only with +the approval of the dialog, like any package from another source. + ### What the server does with the file The routes are administrator-only. `POST /api/addons/files` takes the zip as a