mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 05:29:45 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user