diff --git a/docs/API.md b/docs/API.md index 05a2418e55..0157440994 100644 --- a/docs/API.md +++ b/docs/API.md @@ -784,6 +784,9 @@ Request body: { "passphrase": "use-a-strong-passphrase" } ``` Returns an encrypted export bundle in `data`. Passphrases must be at least 12 characters. +The archive can contain provider and SSO secrets; `settings:read` does not make +its contents safe to share. Follow the [installation, organisation and access +checks](MIGRATION.md#confirm-organisation-and-access) before exporting or importing. ### Import Configuration `POST /api/config/import` (instance admin for the default organization, tenant diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 07c7cc50b6..fac9166194 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -25,6 +25,7 @@ The current export format includes: | API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state | | SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles | | Guest metadata and notes | Host/Docker metadata, agent profiles and assignments | +| — | Organisation definitions, members, invitations and resource shares | | — | AI settings, findings, usage and chat history | | — | Licence files, existing Relay configuration, binaries, service definitions and update backups | @@ -57,9 +58,45 @@ paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#stora The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains) is a third, limited backup; it is not a substitute for either procedure above. +## Confirm organisation and access + +Before either transfer, confirm the **source and destination installation**. +For Enterprise/internal multi-organisation deployments, also confirm the +selected organisation in the **Org Switcher** and its ID in **Settings → +Organization → Overview**; similar display names are not enough. Keep that +selection unchanged until the operation finishes. A configuration export is +not an archive of every organisation on the server. + +When authentication is configured, transfer requires: + +| Access method | Export | Import | +| :--- | :--- | :--- | +| Signed-in browser session, default organisation | Instance administrator | Instance administrator | +| Signed-in browser session, selected non-default organisation | Organisation owner or admin | Organisation owner or admin | +| API token for the selected organisation | `settings:read` | `settings:write` | + +A viewer or editor session is not sufficient. A trusted-proxy identity must +also have administrator authority. An explicitly supplied API token takes +precedence over a browser session: signing in as an administrator does not +enlarge that token's scopes or organisation binding. See the [transfer API +requirements](API.md#export-configuration). + +**The archive passphrase is not a sign-in credential.** It decrypts the file; +it does not grant permission to export or replace settings. Export can contain +provider and SSO secrets, even though its API scope is `settings:read`. Keep it +private. + +Import does not create organisations or transfer their members, invitations +or resource shares. Imported API-token records keep their organisation +bindings; import does not rebind them to a different destination organisation +ID. Plan the intended destination and authorised access separately using the +[organisation guide](MULTI_TENANT.md#organizations), and do not treat a successful +configuration import as proof of a complete multi-organisation migration. + ## Transfer the included configuration -1. On the old server, sign in and open **Settings → System → Recovery → +1. Complete the [organisation and access checks](#confirm-organisation-and-access). + On the old server, sign in and open **Settings → System → Recovery → Create Backup**. Use a strong, unique passphrase of at least 12 characters and store it separately from the downloaded file. With password login, choose **Use a custom passphrase** if you do not want to use that password. @@ -115,6 +152,13 @@ and avoid two active writers. There is no guaranteed five-minute recovery time. ## Troubleshooting +- **401 or 403 when exporting or importing:** check sign-in, the selected + organisation, management role and any API token's binding and required scope. + This is an access failure, not evidence of a wrong archive passphrase. + Do not disable authentication, change organisation headers to bypass a + denial, or repeat an import just to test permissions. If the target changed + unexpectedly or an import's result is uncertain, preserve both installations + and inspect the affected configuration before submitting it again. - **Wrong passphrase or rejected file:** use the original export file and its exact passphrase. Do not delete destination data or regenerate encryption keys to make an import succeed. diff --git a/docs/RECOVERY.md b/docs/RECOVERY.md index 6e9b56e24c..42d93440a9 100644 --- a/docs/RECOVERY.md +++ b/docs/RECOVERY.md @@ -22,6 +22,10 @@ not a VM, container, dataset or PVC. Import **replaces** the included settings and API-token records; it does not merge them. Export the destination's existing configuration before importing, retain the original passphrase privately, and follow the [transfer and validation steps](MIGRATION.md#transfer-the-included-configuration). +First [confirm the installation, organisation and access](MIGRATION.md#confirm-organisation-and-access) +for each side of the transfer. The archive passphrase does not grant permission +to replace settings, and configuration import does not migrate organisation +membership or rebind API tokens to a different organisation. Do not import configuration, restore Pulse data or restart monitoring to clear a guest freeze or an incorrect backup badge. Follow the independent guest-safety checks below and the provider's recovery procedure instead. diff --git a/frontend-modern/public/docs/API.md b/frontend-modern/public/docs/API.md index 05a2418e55..0157440994 100644 --- a/frontend-modern/public/docs/API.md +++ b/frontend-modern/public/docs/API.md @@ -784,6 +784,9 @@ Request body: { "passphrase": "use-a-strong-passphrase" } ``` Returns an encrypted export bundle in `data`. Passphrases must be at least 12 characters. +The archive can contain provider and SSO secrets; `settings:read` does not make +its contents safe to share. Follow the [installation, organisation and access +checks](MIGRATION.md#confirm-organisation-and-access) before exporting or importing. ### Import Configuration `POST /api/config/import` (instance admin for the default organization, tenant diff --git a/frontend-modern/public/docs/MIGRATION.md b/frontend-modern/public/docs/MIGRATION.md index 07c7cc50b6..fac9166194 100644 --- a/frontend-modern/public/docs/MIGRATION.md +++ b/frontend-modern/public/docs/MIGRATION.md @@ -25,6 +25,7 @@ The current export format includes: | API-token records | Server-side host/Docker/Kubernetes agent inventory and enrolment state | | SSO providers configured in Pulse | Legacy environment-configured OIDC settings, external CA/certificate files and custom RBAC roles | | Guest metadata and notes | Host/Docker metadata, agent profiles and assignments | +| — | Organisation definitions, members, invitations and resource shares | | — | AI settings, findings, usage and chat history | | — | Licence files, existing Relay configuration, binaries, service definitions and update backups | @@ -57,9 +58,45 @@ paths](CONFIGURATION.md) and [audit storage and recovery](AUDIT_LOGGING.md#stora The [updater's installation snapshot](AUTO_UPDATE.md#what-an-update-snapshot-contains) is a third, limited backup; it is not a substitute for either procedure above. +## Confirm organisation and access + +Before either transfer, confirm the **source and destination installation**. +For Enterprise/internal multi-organisation deployments, also confirm the +selected organisation in the **Org Switcher** and its ID in **Settings → +Organization → Overview**; similar display names are not enough. Keep that +selection unchanged until the operation finishes. A configuration export is +not an archive of every organisation on the server. + +When authentication is configured, transfer requires: + +| Access method | Export | Import | +| :--- | :--- | :--- | +| Signed-in browser session, default organisation | Instance administrator | Instance administrator | +| Signed-in browser session, selected non-default organisation | Organisation owner or admin | Organisation owner or admin | +| API token for the selected organisation | `settings:read` | `settings:write` | + +A viewer or editor session is not sufficient. A trusted-proxy identity must +also have administrator authority. An explicitly supplied API token takes +precedence over a browser session: signing in as an administrator does not +enlarge that token's scopes or organisation binding. See the [transfer API +requirements](API.md#export-configuration). + +**The archive passphrase is not a sign-in credential.** It decrypts the file; +it does not grant permission to export or replace settings. Export can contain +provider and SSO secrets, even though its API scope is `settings:read`. Keep it +private. + +Import does not create organisations or transfer their members, invitations +or resource shares. Imported API-token records keep their organisation +bindings; import does not rebind them to a different destination organisation +ID. Plan the intended destination and authorised access separately using the +[organisation guide](MULTI_TENANT.md#organizations), and do not treat a successful +configuration import as proof of a complete multi-organisation migration. + ## Transfer the included configuration -1. On the old server, sign in and open **Settings → System → Recovery → +1. Complete the [organisation and access checks](#confirm-organisation-and-access). + On the old server, sign in and open **Settings → System → Recovery → Create Backup**. Use a strong, unique passphrase of at least 12 characters and store it separately from the downloaded file. With password login, choose **Use a custom passphrase** if you do not want to use that password. @@ -115,6 +152,13 @@ and avoid two active writers. There is no guaranteed five-minute recovery time. ## Troubleshooting +- **401 or 403 when exporting or importing:** check sign-in, the selected + organisation, management role and any API token's binding and required scope. + This is an access failure, not evidence of a wrong archive passphrase. + Do not disable authentication, change organisation headers to bypass a + denial, or repeat an import just to test permissions. If the target changed + unexpectedly or an import's result is uncertain, preserve both installations + and inspect the affected configuration before submitting it again. - **Wrong passphrase or rejected file:** use the original export file and its exact passphrase. Do not delete destination data or regenerate encryption keys to make an import succeed. diff --git a/frontend-modern/public/docs/RECOVERY.md b/frontend-modern/public/docs/RECOVERY.md index 6e9b56e24c..42d93440a9 100644 --- a/frontend-modern/public/docs/RECOVERY.md +++ b/frontend-modern/public/docs/RECOVERY.md @@ -22,6 +22,10 @@ not a VM, container, dataset or PVC. Import **replaces** the included settings and API-token records; it does not merge them. Export the destination's existing configuration before importing, retain the original passphrase privately, and follow the [transfer and validation steps](MIGRATION.md#transfer-the-included-configuration). +First [confirm the installation, organisation and access](MIGRATION.md#confirm-organisation-and-access) +for each side of the transfer. The archive passphrase does not grant permission +to replace settings, and configuration import does not migrate organisation +membership or rebind API tokens to a different organisation. Do not import configuration, restore Pulse data or restart monitoring to clear a guest freeze or an incorrect backup badge. Follow the independent guest-safety checks below and the provider's recovery procedure instead. diff --git a/frontend-modern/src/features/docs/__tests__/configurationTransferAccessDocs.test.ts b/frontend-modern/src/features/docs/__tests__/configurationTransferAccessDocs.test.ts new file mode 100644 index 0000000000..dc9ed9307a --- /dev/null +++ b/frontend-modern/src/features/docs/__tests__/configurationTransferAccessDocs.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { renderDocMarkdown } from '../docMarkdown'; + +const frontendRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); +const repoRoot = path.resolve(frontendRoot, '..'); +const readDoc = (name: string) => readFileSync(path.join(repoRoot, 'docs', `${name}.md`), 'utf8'); + +function articleFor(name: string) { + const article = document.createElement('article'); + article.innerHTML = renderDocMarkdown(readDoc(name), name); + return article; +} + +function accessSection() { + const article = articleFor('MIGRATION'); + const heading = article.querySelector('#confirm-organisation-and-access'); + expect(heading).not.toBeNull(); + const section = document.createElement('section'); + for ( + let node = heading!.nextElementSibling; + node && node.tagName !== 'H2'; + node = node.nextElementSibling + ) { + section.append(node.cloneNode(true)); + } + return section; +} + +const textOf = (node: Element) => node.textContent!.replace(/\s+/g, ' '); + +describe('Configuration transfer access guidance', () => { + it('places target checks before transfer and links them from the first step', () => { + const article = articleFor('MIGRATION'); + const headings = [...article.querySelectorAll('h2')].map((heading) => heading.id); + expect(headings.indexOf('confirm-organisation-and-access')).toBeLessThan( + headings.indexOf('transfer-the-included-configuration'), + ); + const firstStep = article.querySelector('#transfer-the-included-configuration + ol > li'); + expect(firstStep?.querySelector('a')?.getAttribute('href')).toBe( + '#confirm-organisation-and-access', + ); + const text = textOf(accessSection()); + expect(text).toContain('source and destination installation'); + expect(text).toContain('Org Switcher'); + expect(text).toContain('its ID in Settings → Organization → Overview'); + expect(text).toContain('Keep that selection unchanged until the operation finishes'); + expect(text).toContain('not an archive of every organisation'); + }); + + it('keeps session management authority and the two API scopes distinct', () => { + const section = accessSection(); + const rows = [...section.querySelectorAll('tbody tr')].map((row) => + [...row.querySelectorAll('td')].map((cell) => cell.textContent), + ); + expect(rows).toEqual([ + [ + 'Signed-in browser session, default organisation', + 'Instance administrator', + 'Instance administrator', + ], + [ + 'Signed-in browser session, selected non-default organisation', + 'Organisation owner or admin', + 'Organisation owner or admin', + ], + ['API token for the selected organisation', 'settings:read', 'settings:write'], + ]); + const text = textOf(section); + expect(text).toContain('A viewer or editor session is not sufficient'); + expect(text).toContain('trusted-proxy identity must also have administrator authority'); + expect(text).toContain('API token takes precedence over a browser session'); + expect(text).toContain("does not enlarge that token's scopes or organisation binding"); + }); + + it('separates decryption from authority and disclaims membership and token remapping', () => { + const text = textOf(accessSection()); + expect(text).toContain('The archive passphrase is not a sign-in credential'); + expect(text).toContain('does not grant permission to export or replace settings'); + expect(text).toContain('provider and SSO secrets'); + expect(text).toContain('Import does not create organisations or transfer their members'); + expect(text).toContain( + 'import does not rebind them to a different destination organisation ID', + ); + const article = articleFor('MIGRATION'); + expect(textOf(article)).toContain( + 'Organisation definitions, members, invitations and resource shares', + ); + }); + + it('explains denials without inviting bypasses or blind import retries', () => { + const article = articleFor('MIGRATION'); + const failure = [...article.querySelectorAll('li')].find((item) => + textOf(item).startsWith('401 or 403 when exporting or importing:'), + ); + expect(failure).toBeDefined(); + const text = textOf(failure!); + expect(text).toContain('access failure, not evidence of a wrong archive passphrase'); + expect(text).toContain('Do not disable authentication, change organisation headers to bypass'); + expect(text).toContain('or repeat an import just to test permissions'); + expect(text).toContain('inspect the affected configuration before submitting it again'); + }); + + it('renders the access checklist links from Recovery and API in the existing viewer', () => { + for (const name of ['RECOVERY', 'API']) { + const link = articleFor(name).querySelector( + 'a[href="/docs/MIGRATION#confirm-organisation-and-access"]', + ); + expect(link, name).not.toBeNull(); + expect(link?.hasAttribute('data-doc-link')).toBe(true); + } + const section = accessSection(); + for (const [name, fragment] of [ + ['API', 'export-configuration'], + ['MULTI_TENANT', 'organizations'], + ]) { + expect(section.querySelector(`a[href="/docs/${name}#${fragment}"]`)).not.toBeNull(); + expect(articleFor(name).querySelector(`#${fragment}`)).not.toBeNull(); + } + }); + + it('ships byte-identical migration, recovery and API documents', () => { + for (const name of ['MIGRATION', 'RECOVERY', 'API']) { + expect(readFileSync(path.join(frontendRoot, 'public/docs', `${name}.md`), 'utf8')).toBe( + readDoc(name), + ); + } + }); +});