mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
Integrate reviewed least-privilege token guidance
Change-source: pulse-maintainer
This commit is contained in:
+76
-31
@@ -664,19 +664,36 @@ docker run --init -e HTTPS_ENABLED=true \
|
||||
|
||||
## 🔑 API Tokens
|
||||
|
||||
API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**.
|
||||
API tokens provide scoped, revocable access to Pulse. Manage them in
|
||||
**Settings → API Access**, in the **Security** group. An account without token
|
||||
management permission can review the inventory but cannot create or revoke tokens.
|
||||
|
||||
The token shown during first-run setup is the primary automation API token for
|
||||
that Pulse instance. It is separate from your web login password and is meant
|
||||
for agents, scripts, integrations, kiosks, and temporary setup handoffs. Tokens
|
||||
are shown once; later token rows show only identifying hints such as prefix,
|
||||
suffix, label, scopes, and last-used metadata.
|
||||
that Pulse instance, not your web login password. Do not reuse a full-access
|
||||
setup token for every consumer. Give each agent, script, integration or display
|
||||
only the scopes it needs, with a name that identifies its use. Tokens are shown
|
||||
once; later rows show hints and metadata, not a recoverable secret. Store the
|
||||
secret privately before dismissing it.
|
||||
|
||||
Revoking a token is safe for Pulse itself, but it immediately breaks any agent,
|
||||
script, kiosk, or integration still using that token. When a consumer needs to
|
||||
stay online, create and install a replacement token first, then revoke the old
|
||||
one. An agent whose token has been revoked stops authenticating until it is
|
||||
reinstalled or reconfigured with a valid token.
|
||||
### Replace or revoke a token
|
||||
|
||||
Revoking a token stops consumers still using it from authenticating. For a
|
||||
planned rotation, create a least-privilege replacement, install it privately
|
||||
in each affected consumer and verify a fresh authenticated result before
|
||||
revoking the old token. Last-used metadata alone does not account for every
|
||||
consumer. Do not reinstall an agent or delete its saved identity just to
|
||||
replace a credential; use its private token-file or managed configuration.
|
||||
|
||||
**If a token or kiosk link has been exposed, revoke it promptly**, even if that
|
||||
interrupts monitoring or a display. Replace it through the same private setup
|
||||
path. Do not leave a leaked token active while arranging a gradual rotation.
|
||||
For a server-address change without credential exposure, follow
|
||||
[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) instead.
|
||||
|
||||
Never put a token or session cookie in a diagnostic command, screenshot or
|
||||
issue thread. For scripted API access, use the
|
||||
[private header-file procedure](API.md#api-token-recommended), keep TLS
|
||||
verification enabled and do not share verbose or trace output.
|
||||
|
||||
### Token Scopes
|
||||
|
||||
@@ -684,53 +701,81 @@ reinstalled or reconfigured with a valid token.
|
||||
| ------- | ------------- |
|
||||
| `*` (Full access) | All permissions (legacy, not recommended) |
|
||||
| `monitoring:read` | View dashboards, metrics, alerts |
|
||||
| `monitoring:write` | Acknowledge/silence alerts |
|
||||
| `monitoring:write` | Acknowledge, silence and clear alerts |
|
||||
| `docker:report` | Docker / Podman agent telemetry submission |
|
||||
| `docker:manage` | Docker / Podman container lifecycle actions (restart, stop) |
|
||||
| `kubernetes:report` | Kubernetes agent telemetry submission |
|
||||
| `kubernetes:manage` | Kubernetes cluster management |
|
||||
| `agent:report` | Agent host telemetry submission |
|
||||
| `agent:config:read` | Read agent config payloads |
|
||||
| `agent:manage` | Manage registered agents (unlink/delete/config) |
|
||||
| `agent:exec` | Establish agent command WebSocket connections |
|
||||
| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset |
|
||||
| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission |
|
||||
| `ai:chat` | Use Pulse Assistant chat and read knowledge |
|
||||
| `ai:execute` | Use governed Patrol plans, approvals, actions, and history |
|
||||
| `settings:read` | Read configuration |
|
||||
| `settings:write` | Modify configuration |
|
||||
| `settings:write` | Modify configuration, manage tokens and trigger updates |
|
||||
| `audit:read` | Read audit events, verification results, summaries, and exports |
|
||||
|
||||
### Presets
|
||||
|
||||
The UI offers quick presets for common use cases:
|
||||
Use **New token**, enter a name under **Create token**, select a **Quick preset**
|
||||
and choose **Generate**. The current presets are:
|
||||
|
||||
| Preset | Scopes | Use Case |
|
||||
| -------- | -------- | ---------- |
|
||||
| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays |
|
||||
| **Agent host** | `agent:report`, `agent:config:read`, `agent:manage` | Agent telemetry, configuration fetch, and uninstall cleanup |
|
||||
| **Kiosk / Monitoring** | `monitoring:read` | Read-only dashboard displays |
|
||||
| **Agent** | `agent:report`, `agent:config:read` | Host telemetry and the agent's bound configuration |
|
||||
| **Docker / Podman report** | `docker:report` | Docker / Podman agent (read-only) |
|
||||
| **Docker / Podman manage** | `docker:report`, `docker:manage` | Docker / Podman agent with actions |
|
||||
| **Settings read** | `settings:read` | Read-only config access |
|
||||
| **Settings admin** | `settings:read`, `settings:write` | Full config access |
|
||||
| **Audit read** | `audit:read` | Audit events, verification history, summaries and exports |
|
||||
|
||||
**Reporting is not remote execution.** The Agent preset does not grant
|
||||
`agent:manage` or `agent:exec`; Docker / Podman report does not grant
|
||||
`docker:manage`. Add lifecycle or action authority only for the operation you
|
||||
intend, not to fix a missing reading. A settings-read token can read sensitive
|
||||
configuration and diagnostics; it is not a public-display credential. See
|
||||
[agent security](AGENT_SECURITY.md) for the separate collector and action-runner
|
||||
requirements.
|
||||
|
||||
If **Patrol external agent** is offered, its scopes come from the current Patrol
|
||||
requirements. Use the displayed set rather than guessing or adding **Full access**.
|
||||
The separate **Full access** choice grants the legacy `*` wildcard, not a
|
||||
least-privilege preset. Hiding controls does not reduce a token's permissions.
|
||||
|
||||
### Kiosk Mode
|
||||
|
||||
For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues:
|
||||
For a wall monitor, create a dedicated **Kiosk / Monitoring** token with only
|
||||
`monitoring:read`. Never use **Full access**, a settings token or your
|
||||
administrator's logged-in browser profile on an unattended display.
|
||||
|
||||
1. Go to **Settings → Security → API Tokens**
|
||||
2. Click **New token** and select the **Kiosk / Dashboard** preset
|
||||
3. Copy the generated token
|
||||
4. Access Pulse via URL with token:
|
||||
```text
|
||||
https://your-pulse-url/?token=YOUR_TOKEN_HERE
|
||||
```
|
||||
1. Open Pulse at its trusted **HTTPS** address and go to **Settings → API Access**.
|
||||
2. Use **New token**, name the display, select **Kiosk / Monitoring** and
|
||||
choose **Generate**. Store the secret privately.
|
||||
3. After closing the token-reveal dialog, the **Magic Kiosk Link** offers
|
||||
**Copy Link** for this monitoring-only token. Transfer that link privately
|
||||
to the display; do not put it in shared bookmarks, screenshots or reports.
|
||||
4. Check the display using the link in a separate browser profile with no
|
||||
administrator session. Confirm it shows the intended monitoring data and
|
||||
does not offer settings or write actions. A hidden control alone is not
|
||||
proof of restricted access.
|
||||
|
||||
**Kiosk tokens:**
|
||||
- Grant read-only dashboard access (`monitoring:read` scope)
|
||||
- Hide the Settings tab automatically
|
||||
- Work without cookies (token in URL)
|
||||
- Can be revoked anytime from the UI
|
||||
The link is a **bearer credential**: anyone with it can read the permitted
|
||||
infrastructure data until the token expires or is revoked. The `kiosk=1`
|
||||
option only hides navigation and filters; it does not authenticate a browser
|
||||
or change the token's scopes.
|
||||
|
||||
> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks.
|
||||
Pulse removes the token from the page URL after loading and keeps it in that
|
||||
tab's session storage. **The initial request already carried the token**;
|
||||
HTTPS and the cleaned address bar do not remove copies from proxy/server logs,
|
||||
browser history or the clipboard. Do not rely on this session surviving a
|
||||
closed tab, browser restart or cleared site data. Keep the original link private
|
||||
if the display needs it again, and clear clipboard copies after setup.
|
||||
|
||||
If the display or link is lost or exposed, revoke its dedicated token in
|
||||
**API Access** and create a replacement. Revoking a display token should not
|
||||
require rotating unrelated agents' credentials.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ const targets: Record<string, string> = {
|
||||
troubleshooting: '/docs/TROUBLESHOOTING#recovery-mode',
|
||||
truenas: '/docs/TRUENAS',
|
||||
installation: '/docs/INSTALL',
|
||||
configuration: '/docs/CONFIGURATION#-api-tokens',
|
||||
};
|
||||
|
||||
window.history.replaceState({}, '', targets[scenario] ?? targets.plain);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Inspect the existing shipped help using the production Docs renderer.
|
||||
// This creates no token, authenticates no client and performs no rotation.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { chromium, webkit } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const root = '/workspace/frontend-modern';
|
||||
const artifacts = path.join(root, 'node_modules', 'token-guidance-browser');
|
||||
fs.mkdirSync(artifacts, { recursive: true });
|
||||
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
|
||||
const server = await createServer({
|
||||
root,
|
||||
configFile: path.join(root, 'vite.config.ts'),
|
||||
cacheDir: path.join(root, 'node_modules', '.vite-token-guidance'),
|
||||
server: { host: '127.0.0.1', port: 5253, strictPort: true },
|
||||
optimizeDeps: { noDiscovery: true, include: [] },
|
||||
});
|
||||
const results = [];
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
for (const [engine, width] of [['chromium', 1280], ['webkit', 390]]) {
|
||||
browser = await { chromium, webkit }[engine].launch(engine === 'chromium'
|
||||
? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
|
||||
: { headless: true });
|
||||
const page = await browser.newPage({ viewport: { width, height: 1000 },
|
||||
...(engine === 'webkit' ? { isMobile: true, hasTouch: true } : {}) });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
await page.goto('http://127.0.0.1:5253/browser-tests/docs-fragment-navigation.html?scenario=configuration',
|
||||
{ waitUntil: 'domcontentloaded', timeout: 60_000 });
|
||||
const tokens = page.getByRole('heading', { name: '🔑 API Tokens', exact: true });
|
||||
await tokens.waitFor();
|
||||
await page.waitForFunction(() => document.activeElement?.id === '-api-tokens');
|
||||
if (engine === 'webkit') {
|
||||
await page.evaluate(async () => {
|
||||
document.documentElement.classList.add('dark');
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
await Promise.all(document.getAnimations().map((animation) => animation.finished.catch(() => {})));
|
||||
});
|
||||
}
|
||||
const presetTable = page.getByRole('table').filter({ has: page.getByRole('columnheader',
|
||||
{ name: 'Preset', exact: true }) });
|
||||
const agent = presetTable.getByRole('row').filter({ has: page.getByRole('cell',
|
||||
{ name: 'Agent', exact: true }) });
|
||||
assert.equal(await agent.getByRole('cell').nth(1).innerText(), 'agent:report, agent:config:read');
|
||||
assert.equal(await presetTable.getByRole('row').count(), 8);
|
||||
const scopesTable = page.getByRole('table').filter({ has: page.getByRole('columnheader',
|
||||
{ name: 'Scope', exact: true }) });
|
||||
assert.equal(await scopesTable.getByRole('row').count(), 17);
|
||||
for (const table of [presetTable, scopesTable]) {
|
||||
assert.ok(await table.evaluate((element) => {
|
||||
const parent = element.parentElement;
|
||||
return parent.dataset.docTableScroll !== undefined &&
|
||||
getComputedStyle(parent).overflowX === 'auto' &&
|
||||
[...element.querySelectorAll('thead th')].every((header) => header.scope === 'col');
|
||||
}));
|
||||
}
|
||||
const body = (await page.locator('article').innerText()).replace(/\s+/g, ' ');
|
||||
for (const text of ['Settings → API Access', 'Reporting is not remote execution',
|
||||
'not to fix a missing reading', 'revoke it promptly', 'even if that interrupts monitoring',
|
||||
'Magic Kiosk Link', 'Copy Link', 'no administrator session', 'bearer credential',
|
||||
'initial request already carried the token', 'proxy/server logs',
|
||||
'Do not rely on this session surviving']) assert.ok(body.includes(text), text);
|
||||
assert.ok(!body.includes('?token=YOUR_TOKEN_HERE'));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
for (const [name, heading] of [
|
||||
['rotation', tokens],
|
||||
['presets', page.getByRole('heading', { name: 'Presets', exact: true })],
|
||||
['kiosk', page.getByRole('heading', { name: 'Kiosk Mode', exact: true })],
|
||||
]) {
|
||||
await heading.evaluate((element) => element.scrollIntoView({ block: 'start' }));
|
||||
await page.screenshot({ path: path.join(artifacts, `${engine}-${name}.png`) });
|
||||
}
|
||||
for (const [name, href, heading, id] of [
|
||||
['private header-file procedure', '/docs/API#api-token-recommended',
|
||||
'API Token (Recommended)', 'api-token-recommended'],
|
||||
['agent retargeting', '/docs/UNIFIED_AGENT#moving-pulse-to-a-new-address',
|
||||
'Moving Pulse to a new address', 'moving-pulse-to-a-new-address'],
|
||||
]) {
|
||||
const link = page.getByRole('link', { name, exact: true });
|
||||
assert.equal(await link.getAttribute('href'), href);
|
||||
await link.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL(`**${href}`);
|
||||
await page.getByRole('heading', { name: heading, exact: true }).waitFor();
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.id), id);
|
||||
await page.goBack();
|
||||
await tokens.waitFor();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
results.push({ engine, version: browser.version(), width,
|
||||
tokenAnchorFocus: true, leastPrivilegeAgentRow: true, actualPresetCount: 7,
|
||||
scopeCount: 16, semanticContainedTables: true, noDocumentOverflow: true,
|
||||
kioskCredentialWarning: true, keyboardApiHelp: true, keyboardAgentRetargeting: true, errors });
|
||||
await browser.close();
|
||||
browser = undefined;
|
||||
}
|
||||
const files = ['public/docs/CONFIGURATION.md', 'browser-tests/token-guidance.cjs',
|
||||
'browser-tests/docs-fragment-navigation.tsx', 'src/features/docs/docMarkdown.ts',
|
||||
'src/pages/Docs.tsx'].map((file) => ({ file,
|
||||
sha256: crypto.createHash('sha256').update(fs.readFileSync(path.join(root, file))).digest('hex') }));
|
||||
const result = { result: 'passed', playwright: require('playwright/package.json').version,
|
||||
scope: 'shipped token guidance rendering and keyboard help links; no token/native operation performed',
|
||||
dompurify: JSON.parse(fs.readFileSync(path.join(root, 'node_modules/dompurify/package.json'))).version,
|
||||
files, results };
|
||||
fs.writeFileSync(path.join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n');
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
if (browser) await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -664,19 +664,36 @@ docker run --init -e HTTPS_ENABLED=true \
|
||||
|
||||
## 🔑 API Tokens
|
||||
|
||||
API tokens provide scoped, revocable access to Pulse. Manage tokens in **Settings → Security → API Tokens**.
|
||||
API tokens provide scoped, revocable access to Pulse. Manage them in
|
||||
**Settings → API Access**, in the **Security** group. An account without token
|
||||
management permission can review the inventory but cannot create or revoke tokens.
|
||||
|
||||
The token shown during first-run setup is the primary automation API token for
|
||||
that Pulse instance. It is separate from your web login password and is meant
|
||||
for agents, scripts, integrations, kiosks, and temporary setup handoffs. Tokens
|
||||
are shown once; later token rows show only identifying hints such as prefix,
|
||||
suffix, label, scopes, and last-used metadata.
|
||||
that Pulse instance, not your web login password. Do not reuse a full-access
|
||||
setup token for every consumer. Give each agent, script, integration or display
|
||||
only the scopes it needs, with a name that identifies its use. Tokens are shown
|
||||
once; later rows show hints and metadata, not a recoverable secret. Store the
|
||||
secret privately before dismissing it.
|
||||
|
||||
Revoking a token is safe for Pulse itself, but it immediately breaks any agent,
|
||||
script, kiosk, or integration still using that token. When a consumer needs to
|
||||
stay online, create and install a replacement token first, then revoke the old
|
||||
one. An agent whose token has been revoked stops authenticating until it is
|
||||
reinstalled or reconfigured with a valid token.
|
||||
### Replace or revoke a token
|
||||
|
||||
Revoking a token stops consumers still using it from authenticating. For a
|
||||
planned rotation, create a least-privilege replacement, install it privately
|
||||
in each affected consumer and verify a fresh authenticated result before
|
||||
revoking the old token. Last-used metadata alone does not account for every
|
||||
consumer. Do not reinstall an agent or delete its saved identity just to
|
||||
replace a credential; use its private token-file or managed configuration.
|
||||
|
||||
**If a token or kiosk link has been exposed, revoke it promptly**, even if that
|
||||
interrupts monitoring or a display. Replace it through the same private setup
|
||||
path. Do not leave a leaked token active while arranging a gradual rotation.
|
||||
For a server-address change without credential exposure, follow
|
||||
[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address) instead.
|
||||
|
||||
Never put a token or session cookie in a diagnostic command, screenshot or
|
||||
issue thread. For scripted API access, use the
|
||||
[private header-file procedure](API.md#api-token-recommended), keep TLS
|
||||
verification enabled and do not share verbose or trace output.
|
||||
|
||||
### Token Scopes
|
||||
|
||||
@@ -684,53 +701,81 @@ reinstalled or reconfigured with a valid token.
|
||||
| ------- | ------------- |
|
||||
| `*` (Full access) | All permissions (legacy, not recommended) |
|
||||
| `monitoring:read` | View dashboards, metrics, alerts |
|
||||
| `monitoring:write` | Acknowledge/silence alerts |
|
||||
| `monitoring:write` | Acknowledge, silence and clear alerts |
|
||||
| `docker:report` | Docker / Podman agent telemetry submission |
|
||||
| `docker:manage` | Docker / Podman container lifecycle actions (restart, stop) |
|
||||
| `kubernetes:report` | Kubernetes agent telemetry submission |
|
||||
| `kubernetes:manage` | Kubernetes cluster management |
|
||||
| `agent:report` | Agent host telemetry submission |
|
||||
| `agent:config:read` | Read agent config payloads |
|
||||
| `agent:manage` | Manage registered agents (unlink/delete/config) |
|
||||
| `agent:exec` | Establish agent command WebSocket connections |
|
||||
| `agent:manage` | Agent lifecycle/configuration changes and unregistering; not needed by the reporting preset |
|
||||
| `agent:exec` | Establish agent command WebSocket connections; not a reporting permission |
|
||||
| `ai:chat` | Use Pulse Assistant chat and read knowledge |
|
||||
| `ai:execute` | Use governed Patrol plans, approvals, actions, and history |
|
||||
| `settings:read` | Read configuration |
|
||||
| `settings:write` | Modify configuration |
|
||||
| `settings:write` | Modify configuration, manage tokens and trigger updates |
|
||||
| `audit:read` | Read audit events, verification results, summaries, and exports |
|
||||
|
||||
### Presets
|
||||
|
||||
The UI offers quick presets for common use cases:
|
||||
Use **New token**, enter a name under **Create token**, select a **Quick preset**
|
||||
and choose **Generate**. The current presets are:
|
||||
|
||||
| Preset | Scopes | Use Case |
|
||||
| -------- | -------- | ---------- |
|
||||
| **Kiosk / Dashboard** | `monitoring:read` | Read-only dashboard displays |
|
||||
| **Agent host** | `agent:report`, `agent:config:read`, `agent:manage` | Agent telemetry, configuration fetch, and uninstall cleanup |
|
||||
| **Kiosk / Monitoring** | `monitoring:read` | Read-only dashboard displays |
|
||||
| **Agent** | `agent:report`, `agent:config:read` | Host telemetry and the agent's bound configuration |
|
||||
| **Docker / Podman report** | `docker:report` | Docker / Podman agent (read-only) |
|
||||
| **Docker / Podman manage** | `docker:report`, `docker:manage` | Docker / Podman agent with actions |
|
||||
| **Settings read** | `settings:read` | Read-only config access |
|
||||
| **Settings admin** | `settings:read`, `settings:write` | Full config access |
|
||||
| **Audit read** | `audit:read` | Audit events, verification history, summaries and exports |
|
||||
|
||||
**Reporting is not remote execution.** The Agent preset does not grant
|
||||
`agent:manage` or `agent:exec`; Docker / Podman report does not grant
|
||||
`docker:manage`. Add lifecycle or action authority only for the operation you
|
||||
intend, not to fix a missing reading. A settings-read token can read sensitive
|
||||
configuration and diagnostics; it is not a public-display credential. See
|
||||
[agent security](AGENT_SECURITY.md) for the separate collector and action-runner
|
||||
requirements.
|
||||
|
||||
If **Patrol external agent** is offered, its scopes come from the current Patrol
|
||||
requirements. Use the displayed set rather than guessing or adding **Full access**.
|
||||
The separate **Full access** choice grants the legacy `*` wildcard, not a
|
||||
least-privilege preset. Hiding controls does not reduce a token's permissions.
|
||||
|
||||
### Kiosk Mode
|
||||
|
||||
For unattended displays (wall monitors, dashboards), use a kiosk token to avoid cookie persistence issues:
|
||||
For a wall monitor, create a dedicated **Kiosk / Monitoring** token with only
|
||||
`monitoring:read`. Never use **Full access**, a settings token or your
|
||||
administrator's logged-in browser profile on an unattended display.
|
||||
|
||||
1. Go to **Settings → Security → API Tokens**
|
||||
2. Click **New token** and select the **Kiosk / Dashboard** preset
|
||||
3. Copy the generated token
|
||||
4. Access Pulse via URL with token:
|
||||
```text
|
||||
https://your-pulse-url/?token=YOUR_TOKEN_HERE
|
||||
```
|
||||
1. Open Pulse at its trusted **HTTPS** address and go to **Settings → API Access**.
|
||||
2. Use **New token**, name the display, select **Kiosk / Monitoring** and
|
||||
choose **Generate**. Store the secret privately.
|
||||
3. After closing the token-reveal dialog, the **Magic Kiosk Link** offers
|
||||
**Copy Link** for this monitoring-only token. Transfer that link privately
|
||||
to the display; do not put it in shared bookmarks, screenshots or reports.
|
||||
4. Check the display using the link in a separate browser profile with no
|
||||
administrator session. Confirm it shows the intended monitoring data and
|
||||
does not offer settings or write actions. A hidden control alone is not
|
||||
proof of restricted access.
|
||||
|
||||
**Kiosk tokens:**
|
||||
- Grant read-only dashboard access (`monitoring:read` scope)
|
||||
- Hide the Settings tab automatically
|
||||
- Work without cookies (token in URL)
|
||||
- Can be revoked anytime from the UI
|
||||
The link is a **bearer credential**: anyone with it can read the permitted
|
||||
infrastructure data until the token expires or is revoked. The `kiosk=1`
|
||||
option only hides navigation and filters; it does not authenticate a browser
|
||||
or change the token's scopes.
|
||||
|
||||
> **Security note**: URL tokens appear in browser history and server logs. Use only for read-only dashboard access on trusted networks.
|
||||
Pulse removes the token from the page URL after loading and keeps it in that
|
||||
tab's session storage. **The initial request already carried the token**;
|
||||
HTTPS and the cleaned address bar do not remove copies from proxy/server logs,
|
||||
browser history or the clipboard. Do not rely on this session surviving a
|
||||
closed tab, browser restart or cleared site data. Keep the original link private
|
||||
if the display needs it again, and clear clipboard copies after setup.
|
||||
|
||||
If the display or link is lost or exposed, revoke its dedicated token in
|
||||
**API Access** and create a replacement. Revoking a display token should not
|
||||
require rotating unrelated agents' credentials.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { API_SCOPE_OPTIONS } from '@/constants/apiScopes';
|
||||
import { getAPITokenScopePresets } from '@/components/Settings/apiTokenManagerModel';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
const configuration = readFileSync(path.join(repoRoot, 'docs/CONFIGURATION.md'), 'utf8');
|
||||
const tokens = configuration.split('## 🔑 API Tokens')[1]?.split('\n## TrueNAS')[0] ?? '';
|
||||
const normalized = tokens.replace(/\s+/g, ' ');
|
||||
|
||||
function tableRows(section: string) {
|
||||
return section
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('|') && !/^\|\s*-/.test(line))
|
||||
.slice(1)
|
||||
.map((line) =>
|
||||
line
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((cell) => cell.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
describe('shipped API Access guidance', () => {
|
||||
it('names the existing token controls rather than the retired navigation', () => {
|
||||
expect(normalized).toContain('Settings → API Access');
|
||||
expect(normalized).not.toContain('Security → API Tokens');
|
||||
for (const control of ['New token', 'Create token', 'Quick preset', 'Generate']) {
|
||||
expect(normalized).toContain(control);
|
||||
}
|
||||
expect(normalized).toContain('cannot create or revoke tokens');
|
||||
});
|
||||
|
||||
it('documents every UI scope without inventing capabilities', () => {
|
||||
const scopeSection = tokens.split('### Token Scopes')[1]?.split('### Presets')[0] ?? '';
|
||||
const scopes = tableRows(scopeSection).map(([scope]) => scope.match(/`([^`]+)`/)?.[1]);
|
||||
expect(scopes.sort()).toEqual(['*', ...API_SCOPE_OPTIONS.map((scope) => scope.value)].sort());
|
||||
});
|
||||
|
||||
it('matches names and exact least-privilege scope sets to the actual preset model', () => {
|
||||
const presetSection = tokens.split('### Presets')[1]?.split('### Kiosk Mode')[0] ?? '';
|
||||
const documented = tableRows(presetSection).map(([label, scopes]) => ({
|
||||
label: label.replace(/\*\*/g, ''),
|
||||
scopes: [...scopes.matchAll(/`([^`]+)`/g)].map((match) => match[1]),
|
||||
}));
|
||||
expect(documented).toEqual(
|
||||
getAPITokenScopePresets().map(({ label, scopes }) => ({ label, scopes })),
|
||||
);
|
||||
expect(normalized).toContain('Reporting is not remote execution');
|
||||
expect(normalized).toContain('not to fix a missing reading');
|
||||
expect(normalized).toContain('separate collector and action-runner');
|
||||
});
|
||||
|
||||
it('distinguishes backend-required Patrol scopes and wildcard authority from presets', () => {
|
||||
expect(normalized).toContain('its scopes come from the current Patrol requirements');
|
||||
expect(normalized).toContain('legacy `*` wildcard, not a least-privilege preset');
|
||||
expect(normalized).toContain("Hiding controls does not reduce a token's permissions");
|
||||
});
|
||||
|
||||
it('keeps credential rotation separate from identity deletion and exposure response', () => {
|
||||
const rotation =
|
||||
tokens.split('### Replace or revoke a token')[1]?.split('### Token Scopes')[0] ?? '';
|
||||
const text = rotation.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('fresh authenticated result before revoking the old token');
|
||||
expect(text).toContain('Last-used metadata alone does not account for every consumer');
|
||||
expect(text).toContain('Do not reinstall an agent or delete its saved identity');
|
||||
expect(text).toContain('revoke it promptly');
|
||||
expect(text).toContain('even if that interrupts monitoring or a display');
|
||||
expect(text).toContain('Do not leave a leaked token active');
|
||||
expect(text).toContain('[agent retargeting](UNIFIED_AGENT.md#moving-pulse-to-a-new-address)');
|
||||
expect(text).toContain('[private header-file procedure](API.md#api-token-recommended)');
|
||||
});
|
||||
|
||||
it('does not present a hidden dashboard or cleaned URL as a credential boundary', () => {
|
||||
const kiosk = tokens.split('### Kiosk Mode')[1] ?? '';
|
||||
const text = kiosk.replace(/\s+/g, ' ');
|
||||
expect(text).toContain('Kiosk / Monitoring');
|
||||
expect(text).toContain('only `monitoring:read`');
|
||||
expect(text).toContain('Magic Kiosk Link');
|
||||
expect(text).toContain('Copy Link');
|
||||
expect(text).toContain('HTTPS');
|
||||
expect(text).toContain('no administrator session');
|
||||
expect(text).toContain('bearer credential');
|
||||
expect(text).toContain('it does not authenticate a browser or change');
|
||||
expect(text).toContain('initial request already carried the token');
|
||||
expect(text).toContain('proxy/server logs');
|
||||
expect(text).toContain('session storage');
|
||||
expect(text).toContain('Do not rely on this session surviving');
|
||||
expect(text).not.toContain('?token=YOUR_TOKEN_HERE');
|
||||
expect(text).not.toContain('avoid cookie persistence issues');
|
||||
});
|
||||
|
||||
it('ships the same scope guidance the UI links to', () => {
|
||||
expect(
|
||||
readFileSync(path.join(repoRoot, 'frontend-modern/public/docs/CONFIGURATION.md'), 'utf8'),
|
||||
).toBe(configuration);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user