Clarify the target and authority for configuration recovery (#3229)

**Clarify the target and authority for configuration recovery**

Put installation and organisation checks before backup transfer.
Separate session management authority, API scopes and archive
decryption; disclose excluded membership state and retained token
bindings. Link the checklist from Recovery and API help, with registered
renderer and mirror controls.

Contract-Neutral: Documents existing configuration-transfer scope and
authorization only; no runtime, route, API, permission or recovery
contract changes.

**Match the documentation viewer routing marker in access controls**

The focused run found that data-doc-link is an empty presence marker,
not the string true. Assert presence without weakening the exact
destination checks, and apply the locked formatter output to the long
token-binding assertion.

Contract-Neutral: Registered test expectation and formatting repair
only; rendered documentation and runtime contracts are unchanged.

---
Fleet candidate `20261025T000604Z-web-product` from the `web-product`
lane. Reviewed tip `bc0e53e577d7f8a4e4810af2853a7e3874f2ee84` on `main`.

[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-25 00:59:05 +00:00
committed by GitHub
7 changed files with 235 additions and 2 deletions
+3
View File
@@ -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
+45 -1
View File
@@ -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.
+4
View File
@@ -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.
+3
View File
@@ -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
+45 -1
View File
@@ -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.
+4
View File
@@ -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.
@@ -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),
);
}
});
});