mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Allow in-place API token scope edits (#1629)
This commit is contained in:
+11
@@ -636,6 +636,17 @@ In Docker installs, the response includes a restart notice.
|
||||
{ "name": "ansible-script", "scopes": ["monitoring:read"] }
|
||||
```
|
||||
|
||||
### Edit API Token Scopes
|
||||
`PATCH /api/security/tokens/<id>`
|
||||
```json
|
||||
{ "scopes": ["monitoring:read", "settings:read"] }
|
||||
```
|
||||
|
||||
The `scopes` field is required and must contain at least one known scope.
|
||||
Wildcard access (`"*"`) cannot be combined with other scopes. The update takes
|
||||
effect on the token's next request without changing its ID, secret, expiry, or
|
||||
organization bindings.
|
||||
|
||||
### Revoke Token
|
||||
`DELETE /api/security/tokens/<id>`
|
||||
|
||||
|
||||
@@ -273,10 +273,15 @@ instead of reporting drift against a token that cannot execute.
|
||||
Install tokens for the generic host agent flow are minted server-side through
|
||||
the agent install command endpoint. The server decides the token's scopes from
|
||||
the operator's command-execution choice at mint time, because scopes are never
|
||||
upgraded on an existing token, and stamps the install-type and issuance
|
||||
metadata that make the token eligible for one first-use command-channel
|
||||
binding. Frontend surfaces may choose whether commands are requested but must
|
||||
not compose install-token scope lists themselves.
|
||||
upgraded implicitly by an install or repair flow, and stamps the install-type
|
||||
and issuance metadata that make the token eligible for one first-use
|
||||
command-channel binding. The operator-owned
|
||||
`PATCH /api/security/tokens/<id>` scope editor is a separate API/security
|
||||
mutation: it may deliberately change an installed agent token's live
|
||||
authority, but agent lifecycle code must not call it to auto-upgrade, repair,
|
||||
or reconcile command permissions. Frontend install surfaces may choose whether
|
||||
commands are requested for a newly minted credential but must not compose
|
||||
install-token scope lists themselves.
|
||||
|
||||
PVE node setup shared boundaries that render or copy `PulseMonitor`
|
||||
permissions must treat `VM.GuestAgent.Audit` plus `VM.GuestAgent.FileRead` as
|
||||
|
||||
@@ -7583,7 +7583,7 @@ top-level preset array that can reintroduce settings-chunk initialization-order
|
||||
failures in production bundles.
|
||||
That same boundary now also includes
|
||||
`frontend-modern/src/utils/apiTokenPresentation.ts`, so token load/create/
|
||||
revoke errors keep one governed customer-facing message source instead of
|
||||
update/revoke errors keep one governed customer-facing message source instead of
|
||||
reappearing as hook-local strings.
|
||||
That same token surface, together with `frontend-modern/src/api/security.ts`,
|
||||
`internal/api/security.go`, `internal/api/security_tokens.go`, and
|
||||
@@ -7611,9 +7611,18 @@ shared setup helpers must not point API responses or runtime guidance at
|
||||
GitHub `main` for security instructions that the running build already serves
|
||||
locally; those references belong on the shipped `/docs/SECURITY.md` path.
|
||||
That same governed token contract must fail closed on mutation. Limited-scope
|
||||
API tokens may only create, rotate, or delete tokens whose effective scopes
|
||||
are a subset of the caller's own scopes; token-management routes must not let a
|
||||
settings-capable but narrower token revoke or replace a broader credential.
|
||||
API tokens may only create, update, rotate, or delete tokens whose effective
|
||||
scopes are a subset of the caller's own scopes; token-management routes must
|
||||
not let a settings-capable but narrower token revoke, narrow, or replace a
|
||||
broader credential. `PATCH /api/security/tokens/<id>` requires an explicit
|
||||
non-empty canonical `scopes` array and checks both the target's existing scope
|
||||
set and the requested scope set against token-authenticated caller authority.
|
||||
A successful patch preserves token identity, secret hash, expiry, metadata,
|
||||
and organization bindings, takes effect on the next request, and returns the
|
||||
updated metadata record without exposing or rotating the raw secret. The
|
||||
mutation must emit a `token_updated` audit event containing the old and new
|
||||
scope sets, and a persistence failure must restore the complete in-memory
|
||||
record before returning failure.
|
||||
Those owner-bound credentials now also define the effective authenticated
|
||||
principal on governed API routes: when token metadata carries `ownerUserId`,
|
||||
RBAC and audit-facing auth resolution must use that bound user identity rather
|
||||
|
||||
@@ -3332,7 +3332,10 @@ pressed button shell and `aria-pressed` wiring, while
|
||||
`frontend-modern/src/components/shared/selectablePillModel.ts` owns the active
|
||||
and inactive pill class catalog. API token scope surfaces may own the security
|
||||
scope labels and click handlers, but must not recreate rounded-full selector
|
||||
pill class strings locally.
|
||||
pill class strings locally. A token's in-place scope editor may use semantic
|
||||
native checkboxes because it is a multi-select form checklist rather than a
|
||||
pressed pill selector; it must not imitate or fork the selectable-pill class
|
||||
catalog.
|
||||
Filter-toolbar segmented controls must delegate to this primitive rather than
|
||||
calling `segmentedButtonClass` directly, and icon+text labels must render as
|
||||
one inline-flex button label so compact bars keep the v5 single-line control
|
||||
|
||||
@@ -119,6 +119,11 @@ with missing, unknown, or unrelated scopes fail closed.
|
||||
Full access is a deliberate wildcard choice, not the default empty
|
||||
selection. The token creation form must require an explicit scoped preset,
|
||||
custom scope, or Full access selection before a credential can be minted.
|
||||
Existing token rows may open an explicit checkbox checklist for in-place
|
||||
scope editing. That editor must initialize from the token's effective live
|
||||
scopes, preserve wildcard exclusivity, reject empty or unchanged
|
||||
submissions, state that the token value does not rotate, and keep the
|
||||
dialog open when the backend rejects the transition.
|
||||
Stable in-page anchors for sibling API Access onboarding panels are allowed
|
||||
only as navigation into the token creation section; those sibling panels do
|
||||
not own token scope derivation or preset contents.
|
||||
@@ -1297,6 +1302,14 @@ That same trust boundary also governs API token scope identity: legacy
|
||||
migration boundaries, where they must be rewritten immediately into canonical
|
||||
`agent:*` scopes. Live token records and runtime scope checks may not keep the
|
||||
legacy scope names as an active second contract.
|
||||
In-place token scope mutation belongs to that same boundary.
|
||||
`PATCH /api/security/tokens/<id>` must require `settings:write`, normalize and
|
||||
validate the complete replacement scope set, and prevent token-authenticated
|
||||
callers from controlling a target whose existing authority exceeds their own
|
||||
or granting authority they do not hold. Successful edits preserve the token
|
||||
secret and all identity/binding metadata, apply to the next request, and
|
||||
record both sides of the transition in a `token_updated` audit event without
|
||||
including a raw secret or hash.
|
||||
Update-readiness checks may inspect loaded API token metadata to determine
|
||||
whether agent reporting scope exists or has expired, but they must not expose
|
||||
raw token values, token hashes, or owner metadata in the update plan payload.
|
||||
|
||||
@@ -4183,6 +4183,12 @@ token-store write that fails after the nodes write would otherwise leave a
|
||||
persisted source next to a still-live one-shot grant, so a persistently failing
|
||||
token store becomes a repeatable source-creation primitive. Either both stores
|
||||
advanced or neither did.
|
||||
The API-token store also owns atomic in-place scope replacement.
|
||||
`PATCH /api/security/tokens/<id>` may change only the canonical scope slice; it
|
||||
must preserve the stored hash, ID, timestamps, expiry, metadata, and
|
||||
organization bindings. If `SaveAPITokens` fails, the complete prior in-memory
|
||||
record must be restored so runtime authorization cannot diverge from the
|
||||
durable token store.
|
||||
That same shared dependency also assumes generated setup scripts preserve
|
||||
setup-token auth guidance, so adjacent setup flows do not regress back to
|
||||
stale API-token instructions after the backend has already standardized on the
|
||||
|
||||
@@ -209,6 +209,31 @@ describe('SecurityAPI', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTokenScopes', () => {
|
||||
it('patches an encoded token id and returns the updated record', async () => {
|
||||
const updated: APITokenRecord = {
|
||||
id: 'token/1',
|
||||
name: 'Automation',
|
||||
prefix: 'pulse',
|
||||
suffix: '1234',
|
||||
createdAt: '2026-07-30T10:00:00Z',
|
||||
scopes: ['monitoring:read', 'settings:read'],
|
||||
};
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce({ record: updated });
|
||||
|
||||
const result = await SecurityAPI.updateTokenScopes('token/1', [
|
||||
'monitoring:read',
|
||||
'settings:read',
|
||||
]);
|
||||
|
||||
expect(apiFetchJSON).toHaveBeenCalledWith('/api/security/tokens/token%2F1', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ scopes: ['monitoring:read', 'settings:read'] }),
|
||||
});
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteToken', () => {
|
||||
it('deletes a token', async () => {
|
||||
vi.mocked(apiFetchJSON).mockResolvedValueOnce(undefined);
|
||||
|
||||
@@ -49,6 +49,17 @@ export class SecurityAPI {
|
||||
});
|
||||
}
|
||||
|
||||
static async updateTokenScopes(id: string, scopes: string[]): Promise<APITokenRecord> {
|
||||
const response = await apiFetchJSON<{ record: APITokenRecord }>(
|
||||
`/api/security/tokens/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ scopes }),
|
||||
},
|
||||
);
|
||||
return response.record;
|
||||
}
|
||||
|
||||
static async deleteToken(id: string): Promise<void> {
|
||||
await apiFetchJSON(`/api/security/tokens/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -26,6 +26,8 @@ interface APITokenManagerProps {
|
||||
|
||||
export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
const [tokenToRevoke, setTokenToRevoke] = createSignal<APITokenRecord | null>(null);
|
||||
const [tokenToEdit, setTokenToEdit] = createSignal<APITokenRecord | null>(null);
|
||||
const [editScopes, setEditScopes] = createSignal<string[]>([]);
|
||||
const {
|
||||
API_SCOPE_LABELS,
|
||||
API_TOKEN_SCOPES_DOC_URL,
|
||||
@@ -43,6 +45,7 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
formatRelativeTime,
|
||||
handleDelete,
|
||||
handleGenerate,
|
||||
handleUpdateScopes,
|
||||
hasWildcardTokens,
|
||||
hasScopeSelection,
|
||||
isFullAccessSelected,
|
||||
@@ -64,10 +67,56 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
tokenHint,
|
||||
toggleScope,
|
||||
totalTokens,
|
||||
updatingTokenId,
|
||||
wildcardCount,
|
||||
presetMatchesSelection,
|
||||
} = useAPITokenManagerState(props);
|
||||
|
||||
const effectiveScopes = (token: APITokenRecord) =>
|
||||
token.scopes && token.scopes.length > 0 ? token.scopes : ['*'];
|
||||
|
||||
const openScopeEditor = (token: APITokenRecord) => {
|
||||
setEditScopes([...effectiveScopes(token)]);
|
||||
setTokenToEdit(token);
|
||||
};
|
||||
|
||||
const closeScopeEditor = () => {
|
||||
if (updatingTokenId() !== null) return;
|
||||
setTokenToEdit(null);
|
||||
setEditScopes([]);
|
||||
};
|
||||
|
||||
const toggleEditScope = (scope: string) => {
|
||||
setEditScopes((previous) => {
|
||||
if (scope === '*') {
|
||||
return previous.includes('*') ? [] : ['*'];
|
||||
}
|
||||
const scoped = previous.filter((value) => value !== '*');
|
||||
if (scoped.includes(scope)) {
|
||||
return scoped.filter((value) => value !== scope);
|
||||
}
|
||||
return [...scoped, scope];
|
||||
});
|
||||
};
|
||||
|
||||
const scopeSelectionKey = (scopes: string[]) => Array.from(new Set(scopes)).sort().join('\u0000');
|
||||
|
||||
const editScopesChanged = () => {
|
||||
const token = tokenToEdit();
|
||||
return token
|
||||
? scopeSelectionKey(editScopes()) !== scopeSelectionKey(effectiveScopes(token))
|
||||
: false;
|
||||
};
|
||||
|
||||
const saveEditedScopes = async () => {
|
||||
const token = tokenToEdit();
|
||||
if (!token || editScopes().length === 0 || !editScopesChanged()) return;
|
||||
if (await handleUpdateScopes(token, editScopes())) {
|
||||
setTokenToEdit(null);
|
||||
setEditScopes([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-5">
|
||||
<Card padding="none" class="border border-border shadow-sm">
|
||||
@@ -389,16 +438,27 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
render: (token) => (
|
||||
<button
|
||||
onClick={() => setTokenToRevoke(token)}
|
||||
disabled={!canManage()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openScopeEditor(token)}
|
||||
disabled={!canManage()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-blue-600 transition hover:bg-blue-50 hover:text-blue-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-200"
|
||||
>
|
||||
Edit scopes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTokenToRevoke(token)}
|
||||
disabled={!canManage()}
|
||||
class="inline-flex min-h-10 sm:min-h-9 items-center rounded-md px-2.5 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 hover:text-red-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -559,6 +619,106 @@ export const APITokenManager: Component<APITokenManagerProps> = (props) => {
|
||||
</ExternalTextLink>
|
||||
</Card>
|
||||
|
||||
<Show when={tokenToEdit()}>
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
onClose={closeScopeEditor}
|
||||
panelClass="max-w-2xl"
|
||||
ariaLabel="Edit API token scopes"
|
||||
>
|
||||
<div class="w-full space-y-5 p-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-base-content">Edit token scopes</h3>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Changes to{' '}
|
||||
<span class="font-medium text-base-content">
|
||||
{tokenToEdit()!.name || tokenToEdit()!.id}
|
||||
</span>{' '}
|
||||
take effect on its next request. The token value and expiry do not change.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700 dark:bg-amber-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editScopes().includes('*')}
|
||||
onChange={() => toggleEditScope('*')}
|
||||
disabled={updatingTokenId() !== null}
|
||||
class="mt-0.5 h-4 w-4 rounded border-border text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span>
|
||||
<span class="block font-semibold text-amber-900 dark:text-amber-100">
|
||||
Full access
|
||||
</span>
|
||||
<span class="text-amber-800 dark:text-amber-200">
|
||||
Legacy wildcard access to every API capability.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="max-h-[50vh] space-y-4 overflow-y-auto pr-1">
|
||||
<For each={scopeGroups()}>
|
||||
{([group, options]) => (
|
||||
<fieldset class="space-y-2">
|
||||
<legend class="text-[0.7rem] font-semibold uppercase tracking-wide text-muted">
|
||||
{group}
|
||||
</legend>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<For each={options}>
|
||||
{(option) => (
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-md border border-border p-3 text-sm transition hover:bg-surface-hover">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editScopes().includes(option.value)}
|
||||
onChange={() => toggleEditScope(option.value)}
|
||||
disabled={updatingTokenId() !== null}
|
||||
class="mt-0.5 h-4 w-4 rounded border-border text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span>
|
||||
<span class="block font-medium text-base-content">
|
||||
{option.label}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{option.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={editScopes().length === 0}>
|
||||
<p class="text-sm text-red-600 dark:text-red-300">
|
||||
Select at least one scope before saving.
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end gap-3 border-t border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeScopeEditor}
|
||||
disabled={updatingTokenId() !== null}
|
||||
class="rounded-md border border-border px-4 py-2 text-sm font-medium text-base-content hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveEditedScopes()}
|
||||
disabled={
|
||||
editScopes().length === 0 || !editScopesChanged() || updatingTokenId() !== null
|
||||
}
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{updatingTokenId() !== null ? 'Saving…' : 'Save scopes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Show>
|
||||
|
||||
{/* Revoke confirmation modal — token deletion is irreversible
|
||||
and breaks any agents/integrations relying on the token,
|
||||
so guard the action behind an explicit confirm. */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { APITokenManager } from '../APITokenManager';
|
||||
|
||||
const listTokensMock = vi.fn();
|
||||
const createTokenMock = vi.fn();
|
||||
const updateTokenScopesMock = vi.fn();
|
||||
const deleteTokenMock = vi.fn();
|
||||
const fetchAgentCapabilitiesManifestMock = vi.fn();
|
||||
const notificationSuccessMock = vi.fn();
|
||||
@@ -39,6 +40,7 @@ vi.mock('@/api/security', () => ({
|
||||
SecurityAPI: {
|
||||
listTokens: (...args: unknown[]) => listTokensMock(...args),
|
||||
createToken: (...args: unknown[]) => createTokenMock(...args),
|
||||
updateTokenScopes: (...args: unknown[]) => updateTokenScopesMock(...args),
|
||||
deleteToken: (...args: unknown[]) => deleteTokenMock(...args),
|
||||
},
|
||||
}));
|
||||
@@ -148,6 +150,7 @@ describe('APITokenManager', () => {
|
||||
beforeEach(() => {
|
||||
listTokensMock.mockReset();
|
||||
createTokenMock.mockReset();
|
||||
updateTokenScopesMock.mockReset();
|
||||
deleteTokenMock.mockReset();
|
||||
notificationSuccessMock.mockReset();
|
||||
notificationErrorMock.mockReset();
|
||||
@@ -195,6 +198,9 @@ describe('APITokenManager', () => {
|
||||
}),
|
||||
});
|
||||
deleteTokenMock.mockResolvedValue(undefined);
|
||||
updateTokenScopesMock.mockImplementation(
|
||||
async (id: string, scopes: string[]): Promise<APITokenRecord> => makeToken({ id, scopes }),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -528,6 +534,112 @@ describe('APITokenManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a token scope set in place without rotating or revoking it', async () => {
|
||||
const onTokensChanged = vi.fn();
|
||||
listTokensMock.mockResolvedValue([
|
||||
makeToken({
|
||||
id: 'token-edit',
|
||||
name: 'Editable token',
|
||||
scopes: [DOCKER_REPORT_SCOPE],
|
||||
}),
|
||||
]);
|
||||
updateTokenScopesMock.mockResolvedValue(
|
||||
makeToken({
|
||||
id: 'token-edit',
|
||||
name: 'Editable token',
|
||||
scopes: [MONITORING_READ_SCOPE],
|
||||
}),
|
||||
);
|
||||
|
||||
render(() => <APITokenManager onTokensChanged={onTokensChanged} canManage />);
|
||||
|
||||
const tokenName = await screen.findByText('Editable token');
|
||||
const row = tokenName.closest('tr');
|
||||
expect(row).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(row as HTMLTableRowElement).getByRole('button', { name: 'Edit scopes' }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Edit API token scopes' });
|
||||
expect(within(dialog).getByText(/take effect on its next request/i)).toBeInTheDocument();
|
||||
const dockerReport = within(dialog).getByRole('checkbox', {
|
||||
name: /Docker \/ Podman reporting/i,
|
||||
});
|
||||
const monitoringRead = within(dialog).getByRole('checkbox', {
|
||||
name: /Monitoring & alerts \(read\)/i,
|
||||
});
|
||||
expect(dockerReport).toBeChecked();
|
||||
expect(monitoringRead).not.toBeChecked();
|
||||
|
||||
fireEvent.click(dockerReport);
|
||||
fireEvent.click(monitoringRead);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Save scopes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateTokenScopesMock).toHaveBeenCalledWith('token-edit', [MONITORING_READ_SCOPE]);
|
||||
});
|
||||
expect(deleteTokenMock).not.toHaveBeenCalled();
|
||||
expect(createTokenMock).not.toHaveBeenCalled();
|
||||
expect(onTokensChanged).toHaveBeenCalledTimes(1);
|
||||
expect(notificationSuccessMock).toHaveBeenCalledWith('Scopes updated for Editable token.');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Edit API token scopes' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
const updatedRow = screen.getByText('Editable token').closest('tr');
|
||||
expect(
|
||||
within(updatedRow as HTMLTableRowElement).getByText('Monitoring & alerts (read)'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(updatedRow as HTMLTableRowElement).queryByText('Docker / Podman reporting'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires at least one changed scope and keeps failed edits open', async () => {
|
||||
listTokensMock.mockResolvedValue([
|
||||
makeToken({
|
||||
id: 'token-edit-failure',
|
||||
name: 'Protected token',
|
||||
scopes: [DOCKER_REPORT_SCOPE],
|
||||
}),
|
||||
]);
|
||||
updateTokenScopesMock.mockRejectedValueOnce(new Error('Cannot grant scope "monitoring:read"'));
|
||||
|
||||
render(() => <APITokenManager onTokensChanged={vi.fn()} canManage />);
|
||||
|
||||
const tokenName = await screen.findByText('Protected token');
|
||||
const row = tokenName.closest('tr');
|
||||
fireEvent.click(
|
||||
within(row as HTMLTableRowElement).getByRole('button', { name: 'Edit scopes' }),
|
||||
);
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Edit API token scopes' });
|
||||
const save = within(dialog).getByRole('button', { name: 'Save scopes' });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('checkbox', { name: /Docker \/ Podman reporting/i }));
|
||||
expect(
|
||||
within(dialog).getByText('Select at least one scope before saving.'),
|
||||
).toBeInTheDocument();
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('checkbox', { name: /Monitoring & alerts \(read\)/i }),
|
||||
);
|
||||
expect(save).not.toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateTokenScopesMock).toHaveBeenCalledWith('token-edit-failure', [
|
||||
MONITORING_READ_SCOPE,
|
||||
]);
|
||||
});
|
||||
expect(notificationErrorMock).toHaveBeenCalledWith('Unable to update API token scopes.');
|
||||
expect(screen.getByRole('dialog', { name: 'Edit API token scopes' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps governed infrastructure token usage labels on local operator identity', async () => {
|
||||
listTokensMock.mockResolvedValue([
|
||||
makeToken({
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getAPITokenRevealSettingsNote,
|
||||
getAPITokensLoadErrorMessage,
|
||||
getAPITokenRevokeErrorMessage,
|
||||
getAPITokenUpdateErrorMessage,
|
||||
} from '@/utils/apiTokenPresentation';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getPulseBaseUrl } from '@/utils/url';
|
||||
@@ -73,6 +74,7 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
const [tokensLoaded, setTokensLoaded] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
const [updatingTokenId, setUpdatingTokenId] = createSignal<string | null>(null);
|
||||
const [newTokenValue, setNewTokenValue] = createSignal<string | null>(null);
|
||||
const [newTokenRecord, setNewTokenRecord] = createSignal<APITokenRecord | null>(null);
|
||||
const [nameInput, setNameInput] = createSignal('');
|
||||
@@ -391,6 +393,28 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateScopes = async (record: APITokenRecord, scopes: string[]) => {
|
||||
if (!canManage() || scopes.length === 0 || updatingTokenId() !== null) return false;
|
||||
|
||||
setUpdatingTokenId(record.id);
|
||||
try {
|
||||
const updated = await SecurityAPI.updateTokenScopes(record.id, [...scopes].sort());
|
||||
setTokens((previous) => previous.map((token) => (token.id === updated.id ? updated : token)));
|
||||
if (newTokenRecord()?.id === updated.id) {
|
||||
setNewTokenRecord(updated);
|
||||
}
|
||||
notificationStore.success(`Scopes updated for ${getAPITokenDialogName(updated)}.`);
|
||||
props.onTokensChanged?.();
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Failed to update API token scopes', err);
|
||||
notificationStore.error(getAPITokenUpdateErrorMessage(err));
|
||||
return false;
|
||||
} finally {
|
||||
setUpdatingTokenId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isRevealActiveForCurrentToken = () => {
|
||||
const active = tokenRevealState();
|
||||
return newTokenValue() !== null && Boolean(active && active.token === newTokenValue());
|
||||
@@ -447,10 +471,12 @@ export const useAPITokenManagerState = (props: APITokenManagerProps) => {
|
||||
formatRelativeTime,
|
||||
handleDelete,
|
||||
handleGenerate,
|
||||
handleUpdateScopes,
|
||||
hasWildcardTokens,
|
||||
hasScopeSelection,
|
||||
isFullAccessSelected,
|
||||
isGenerating,
|
||||
updatingTokenId,
|
||||
isRevealActiveForCurrentToken,
|
||||
loading,
|
||||
nameInput,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAPITokenRevealSettingsNote,
|
||||
getAPITokensLoadErrorMessage,
|
||||
getAPITokenRevokeErrorMessage,
|
||||
getAPITokenUpdateErrorMessage,
|
||||
} from '@/utils/apiTokenPresentation';
|
||||
|
||||
describe('apiTokenPresentation', () => {
|
||||
@@ -24,6 +25,7 @@ describe('apiTokenPresentation', () => {
|
||||
expect(getAPITokensLoadErrorMessage()).toBe('Unable to load API tokens.');
|
||||
expect(getAPITokenGenerateErrorMessage()).toBe('Unable to generate the API token.');
|
||||
expect(getAPITokenRevokeErrorMessage()).toBe('Unable to revoke the API token.');
|
||||
expect(getAPITokenUpdateErrorMessage()).toBe('Unable to update API token scopes.');
|
||||
});
|
||||
|
||||
it('returns canonical API token settings location copy', () => {
|
||||
@@ -82,6 +84,20 @@ describe('apiTokenPresentation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces token scope denial copy for update failures', () => {
|
||||
const grantError = Object.assign(
|
||||
new Error('Cannot grant scope "monitoring:write": your token does not have this scope'),
|
||||
{ status: 403 },
|
||||
);
|
||||
const targetError = Object.assign(
|
||||
new Error('Cannot update token with scope "*": your token does not have this scope'),
|
||||
{ status: 403 },
|
||||
);
|
||||
|
||||
expect(getAPITokenUpdateErrorMessage(grantError)).toBe(grantError.message);
|
||||
expect(getAPITokenUpdateErrorMessage(targetError)).toBe(targetError.message);
|
||||
});
|
||||
|
||||
it('surfaces required scope when middleware returns missing_scope', () => {
|
||||
const error = Object.assign(new Error('missing_scope'), {
|
||||
status: 403,
|
||||
|
||||
@@ -64,6 +64,21 @@ export function getAPITokenRevokeErrorMessage(): string {
|
||||
return 'Unable to revoke the API token.';
|
||||
}
|
||||
|
||||
export function getAPITokenUpdateErrorMessage(error?: unknown): string {
|
||||
if (error && typeof error === 'object') {
|
||||
const typedError = error as APITokenErrorShape;
|
||||
const message = typedError.message?.trim();
|
||||
if (
|
||||
typedError.status === 403 &&
|
||||
message &&
|
||||
(message.startsWith('Cannot grant scope') || message.startsWith('Cannot update token'))
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return 'Unable to update API token scopes.';
|
||||
}
|
||||
|
||||
export function getAPITokenDockerPodmanUsageCountLabel(count: number): string {
|
||||
return count === 1
|
||||
? API_TOKEN_DOCKER_PODMAN_RUNTIME_LABEL
|
||||
|
||||
@@ -43,6 +43,14 @@ func TestBearerAPITokenScopesDenyReadWriteAndExecRoutes(t *testing.T) {
|
||||
token: "bearer-write-token-123.12345678",
|
||||
wantScopeHint: config.ScopeSettingsWrite,
|
||||
},
|
||||
{
|
||||
name: "scope update route missing settings write",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{"scopes":["monitoring:read"]}`,
|
||||
token: "bearer-write-token-123.12345678",
|
||||
wantScopeHint: config.ScopeSettingsWrite,
|
||||
},
|
||||
{
|
||||
name: "exec route missing ai execute",
|
||||
method: http.MethodPost,
|
||||
|
||||
@@ -292,6 +292,10 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
|
||||
if !ensureSettingsWriteScope(r.config, w, req) {
|
||||
return
|
||||
}
|
||||
if req.Method == http.MethodPatch {
|
||||
r.handleUpdateAPIToken(w, req)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(req.URL.Path, "/rotate") && req.Method == http.MethodPost {
|
||||
r.handleRotateAPIToken(w, req)
|
||||
return
|
||||
|
||||
@@ -293,6 +293,10 @@ type createTokenRequest struct {
|
||||
ExpiresIn *string `json:"expiresIn,omitempty"` // e.g. "24h", "720h", "8760h"
|
||||
}
|
||||
|
||||
type updateTokenRequest struct {
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
|
||||
func (r *Router) auditTokenEvent(req *http.Request, event string, success bool, details string) {
|
||||
user := internalauth.GetUser(req.Context())
|
||||
if user == "" && r != nil && r.config != nil {
|
||||
@@ -369,6 +373,119 @@ func (r *Router) handleCreateAPIToken(w http.ResponseWriter, req *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateAPIToken changes an API token's scopes without rotating its secret.
|
||||
func (r *Router) handleUpdateAPIToken(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPatch {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
tokenID := strings.TrimSpace(strings.TrimPrefix(req.URL.Path, "/api/security/tokens/"))
|
||||
if tokenID == "" || strings.Contains(tokenID, "/") {
|
||||
r.auditTokenEvent(req, "token_updated", false, "Missing or invalid API token ID in update request")
|
||||
http.Error(w, "Token ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var payload updateTokenRequest
|
||||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||||
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Failed to decode scope update for token id=%s", tokenID))
|
||||
log.Warn().Err(err).Str("token_id", tokenID).Msg("Failed to decode API token update request")
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if payload.Scopes == nil {
|
||||
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Missing scopes field for token id=%s", tokenID))
|
||||
http.Error(w, "Scopes field required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
scopes, err := normalizeRequestedScopes(payload.Scopes)
|
||||
if err != nil {
|
||||
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Invalid scope update for token id=%s", tokenID))
|
||||
log.Warn().Err(err).Str("token_id", tokenID).Msg("Invalid scopes provided for API token update")
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
config.Mu.Lock()
|
||||
defer config.Mu.Unlock()
|
||||
|
||||
tokenIndex := -1
|
||||
for idx := range r.config.APITokens {
|
||||
if r.config.APITokens[idx].ID == tokenID {
|
||||
tokenIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if tokenIndex == -1 {
|
||||
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("API token id=%s not found for update", tokenID))
|
||||
http.Error(w, "Token not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
storedRecord := r.config.APITokens[tokenIndex]
|
||||
oldRecord := storedRecord.Clone()
|
||||
|
||||
// A token-authenticated caller must be able to control both sides of the
|
||||
// transition. Checking the existing scopes prevents a limited caller from
|
||||
// narrowing or otherwise mutating a more privileged credential; checking
|
||||
// the requested scopes prevents widening a token beyond the caller's own
|
||||
// authority, including when editing itself.
|
||||
if callerToken := getAPITokenRecordFromRequest(req); callerToken != nil {
|
||||
for _, scope := range oldRecord.Scopes {
|
||||
if !callerToken.HasScope(scope) {
|
||||
r.auditTokenEvent(req, "token_updated", false,
|
||||
fmt.Sprintf("Scope update denied: caller missing existing scope %q on target token id=%s", scope, tokenID))
|
||||
http.Error(w, fmt.Sprintf("Cannot update token with scope %q: your token does not have this scope", scope), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if !callerToken.HasScope(scope) {
|
||||
r.auditTokenEvent(req, "token_updated", false,
|
||||
fmt.Sprintf("Scope update denied: caller cannot grant scope %q on target token id=%s", scope, tokenID))
|
||||
http.Error(w, fmt.Sprintf("Cannot grant scope %q: your token does not have this scope", scope), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.config.APITokens[tokenIndex].Scopes = append([]string(nil), scopes...)
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
r.config.APITokens[tokenIndex] = storedRecord
|
||||
r.auditTokenEvent(req, "token_updated", false, fmt.Sprintf("Failed to persist scope update for token id=%s", tokenID))
|
||||
log.Error().Err(err).Str("token_id", tokenID).Msg("Failed to persist API token scope update")
|
||||
http.Error(w, "Failed to save token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updatedRecord := r.config.APITokens[tokenIndex]
|
||||
r.auditTokenEvent(req, "token_updated", true,
|
||||
fmt.Sprintf(
|
||||
"Updated API token id=%s old_scopes=%s new_scopes=%s",
|
||||
tokenID,
|
||||
strings.Join(oldRecord.Scopes, ","),
|
||||
strings.Join(updatedRecord.Scopes, ","),
|
||||
))
|
||||
|
||||
log.Info().
|
||||
Str("audit_event", "token_updated").
|
||||
Str("token_id", tokenID).
|
||||
Str("token_name", updatedRecord.Name).
|
||||
Strs("old_scopes", oldRecord.Scopes).
|
||||
Strs("new_scopes", updatedRecord.Scopes).
|
||||
Str("client_ip", req.RemoteAddr).
|
||||
Msg("API token scopes updated")
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"record": toAPITokenDTO(updatedRecord),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateRelayMobileAccessToken generates the canonical Pulse Mobile relay runtime token.
|
||||
func (r *Router) handleCreateRelayMobileAccessToken(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
|
||||
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
func TestSecurityTokens_UpdateScopesPreservesCredentialAndAuditsTransition(t *testing.T) {
|
||||
capture := &auditCaptureLogger{}
|
||||
prevLogger := audit.GetLogger()
|
||||
prevManager := GetTenantAuditManager()
|
||||
audit.SetLogger(capture)
|
||||
SetTenantAuditManager(nil)
|
||||
t.Cleanup(func() {
|
||||
audit.SetLogger(prevLogger)
|
||||
SetTenantAuditManager(prevManager)
|
||||
})
|
||||
|
||||
record := newTokenRecord(t, "update-target-token-123.12345678", []string{config.ScopeSettingsRead}, map[string]string{
|
||||
"bound_agent_id": "agent-1",
|
||||
})
|
||||
record.Name = "automation"
|
||||
record.OrgID = "acme"
|
||||
record.OrgIDs = []string{"acme", "beta"}
|
||||
originalHash := record.Hash
|
||||
originalCreatedAt := record.CreatedAt
|
||||
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := &Router{
|
||||
config: cfg,
|
||||
persistence: config.NewConfigPersistence(t.TempDir()),
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/security/tokens/"+record.ID,
|
||||
strings.NewReader(`{"scopes":["monitoring:write","monitoring:read","monitoring:write"]}`),
|
||||
)
|
||||
req = req.WithContext(authpkg.WithUser(req.Context(), "alice"))
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleUpdateAPIToken(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if len(cfg.APITokens) != 1 {
|
||||
t.Fatalf("APITokens len = %d, want 1", len(cfg.APITokens))
|
||||
}
|
||||
|
||||
updated := cfg.APITokens[0]
|
||||
if updated.ID != record.ID || updated.Hash != originalHash || !updated.CreatedAt.Equal(originalCreatedAt) {
|
||||
t.Fatalf("credential identity changed during scope update: %+v", updated)
|
||||
}
|
||||
if updated.Name != "automation" || updated.OrgID != "acme" || len(updated.OrgIDs) != 2 {
|
||||
t.Fatalf("token metadata or org bindings changed during scope update: %+v", updated)
|
||||
}
|
||||
if updated.Metadata["bound_agent_id"] != "agent-1" {
|
||||
t.Fatalf("token metadata changed during scope update: %+v", updated.Metadata)
|
||||
}
|
||||
if got := strings.Join(updated.Scopes, ","); got != "monitoring:read,monitoring:write" {
|
||||
t.Fatalf("stored scopes = %q, want %q", got, "monitoring:read,monitoring:write")
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Record apiTokenDTO `json:"record"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode update response: %v", err)
|
||||
}
|
||||
if response.Record.ID != record.ID {
|
||||
t.Fatalf("response token ID = %q, want %q", response.Record.ID, record.ID)
|
||||
}
|
||||
if got := strings.Join(response.Record.Scopes, ","); got != "monitoring:read,monitoring:write" {
|
||||
t.Fatalf("response scopes = %q, want canonical scopes", got)
|
||||
}
|
||||
|
||||
events, err := capture.Query(audit.QueryFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("query audit events: %v", err)
|
||||
}
|
||||
var updateEvent *audit.Event
|
||||
for i := range events {
|
||||
if events[i].EventType == "token_updated" && events[i].Success {
|
||||
updateEvent = &events[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if updateEvent == nil {
|
||||
t.Fatal("expected successful token_updated audit event")
|
||||
}
|
||||
if updateEvent.User != "alice" {
|
||||
t.Fatalf("audit user = %q, want alice", updateEvent.User)
|
||||
}
|
||||
if !strings.Contains(updateEvent.Details, "old_scopes=settings:read") ||
|
||||
!strings.Contains(updateEvent.Details, "new_scopes=monitoring:read,monitoring:write") {
|
||||
t.Fatalf("audit details do not record scope transition: %q", updateEvent.Details)
|
||||
}
|
||||
if strings.Contains(updateEvent.Details, originalHash) {
|
||||
t.Fatal("audit details leaked token hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityTokens_UpdateScopesValidatesRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
wantStatus int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "wrong method",
|
||||
method: http.MethodPost,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{"scopes":["monitoring:read"]}`,
|
||||
wantStatus: http.StatusMethodNotAllowed,
|
||||
wantBody: "Method not allowed",
|
||||
},
|
||||
{
|
||||
name: "missing token id",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/",
|
||||
body: `{"scopes":["monitoring:read"]}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Token ID required",
|
||||
},
|
||||
{
|
||||
name: "invalid body",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{bad`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Invalid request body",
|
||||
},
|
||||
{
|
||||
name: "missing scopes",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "Scopes field required",
|
||||
},
|
||||
{
|
||||
name: "empty scopes",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{"scopes":[]}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "select at least one scope",
|
||||
},
|
||||
{
|
||||
name: "unknown scope",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{"scopes":["unknown:scope"]}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: `unknown scope "unknown:scope"`,
|
||||
},
|
||||
{
|
||||
name: "wildcard combination",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/token-1",
|
||||
body: `{"scopes":["*","monitoring:read"]}`,
|
||||
wantStatus: http.StatusBadRequest,
|
||||
wantBody: "wildcard '*' cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
method: http.MethodPatch,
|
||||
path: "/api/security/tokens/missing",
|
||||
body: `{"scopes":["monitoring:read"]}`,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantBody: "Token not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
APITokens: []config.APITokenRecord{{
|
||||
ID: "token-1",
|
||||
Name: "target",
|
||||
Hash: "hash",
|
||||
Scopes: []string{config.ScopeSettingsRead},
|
||||
OrgID: "default",
|
||||
}},
|
||||
}
|
||||
router := &Router{config: cfg}
|
||||
req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.handleUpdateAPIToken(rec, req)
|
||||
|
||||
if rec.Code != test.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, test.wantStatus, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), test.wantBody) {
|
||||
t.Fatalf("body = %q, want fragment %q", rec.Body.String(), test.wantBody)
|
||||
}
|
||||
if got := strings.Join(cfg.APITokens[0].Scopes, ","); got != config.ScopeSettingsRead {
|
||||
t.Fatalf("scopes changed after invalid request: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityTokens_UpdateScopesRejectsScopeEscalationForTokenCaller(t *testing.T) {
|
||||
t.Run("caller cannot mutate broader existing token", func(t *testing.T) {
|
||||
target := newTokenRecord(t, "update-broad-target-123.12345678", []string{config.ScopeWildcard}, nil)
|
||||
caller := newTokenRecord(t, "update-limited-caller-123.12345678", []string{
|
||||
config.ScopeSettingsWrite,
|
||||
config.ScopeMonitoringRead,
|
||||
}, nil)
|
||||
cfg := newTestConfigWithTokens(t, target, caller)
|
||||
router := &Router{config: cfg}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/security/tokens/"+target.ID,
|
||||
strings.NewReader(`{"scopes":["monitoring:read"]}`),
|
||||
)
|
||||
req = req.WithContext(authpkg.WithAPIToken(req.Context(), &caller))
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleUpdateAPIToken(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `Cannot update token with scope "*"`) {
|
||||
t.Fatalf("unexpected response: %q", rec.Body.String())
|
||||
}
|
||||
if got := strings.Join(findAPITokenByID(t, cfg.APITokens, target.ID).Scopes, ","); got != config.ScopeWildcard {
|
||||
t.Fatalf("target scopes changed after denied update: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caller cannot grant scope it does not hold", func(t *testing.T) {
|
||||
target := newTokenRecord(t, "update-target-123456.12345678", []string{config.ScopeMonitoringRead}, nil)
|
||||
caller := newTokenRecord(t, "update-caller-123456.12345678", []string{
|
||||
config.ScopeSettingsWrite,
|
||||
config.ScopeMonitoringRead,
|
||||
}, nil)
|
||||
cfg := newTestConfigWithTokens(t, target, caller)
|
||||
router := &Router{config: cfg}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/security/tokens/"+target.ID,
|
||||
strings.NewReader(`{"scopes":["monitoring:read","monitoring:write"]}`),
|
||||
)
|
||||
req = req.WithContext(authpkg.WithAPIToken(req.Context(), &caller))
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleUpdateAPIToken(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusForbidden, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `Cannot grant scope "monitoring:write"`) {
|
||||
t.Fatalf("unexpected response: %q", rec.Body.String())
|
||||
}
|
||||
if got := strings.Join(findAPITokenByID(t, cfg.APITokens, target.ID).Scopes, ","); got != config.ScopeMonitoringRead {
|
||||
t.Fatalf("target scopes changed after denied update: %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caller can narrow itself", func(t *testing.T) {
|
||||
target := newTokenRecord(t, "update-self-token-123.12345678", []string{
|
||||
config.ScopeSettingsWrite,
|
||||
config.ScopeMonitoringRead,
|
||||
}, nil)
|
||||
cfg := newTestConfigWithTokens(t, target)
|
||||
router := &Router{config: cfg}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/security/tokens/"+target.ID,
|
||||
strings.NewReader(`{"scopes":["settings:write"]}`),
|
||||
)
|
||||
req = req.WithContext(authpkg.WithAPIToken(req.Context(), &target))
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleUpdateAPIToken(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if got := strings.Join(cfg.APITokens[0].Scopes, ","); got != config.ScopeSettingsWrite {
|
||||
t.Fatalf("self-narrowed scopes = %q, want %q", got, config.ScopeSettingsWrite)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecurityTokens_UpdateScopesRollsBackWhenPersistenceFails(t *testing.T) {
|
||||
record := newTokenRecord(t, "update-rollback-token-123.12345678", []string{config.ScopeSettingsRead}, nil)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
|
||||
stateDir := filepath.Join(t.TempDir(), "state")
|
||||
persistence := config.NewConfigPersistence(stateDir)
|
||||
if err := os.RemoveAll(stateDir); err != nil {
|
||||
t.Fatalf("remove persistence directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(stateDir, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatalf("create persistence blocker: %v", err)
|
||||
}
|
||||
router := &Router{config: cfg, persistence: persistence}
|
||||
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/security/tokens/"+record.ID,
|
||||
strings.NewReader(`{"scopes":["monitoring:read"]}`),
|
||||
)
|
||||
rec := httptest.NewRecorder()
|
||||
router.handleUpdateAPIToken(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, http.StatusInternalServerError, rec.Body.String())
|
||||
}
|
||||
if got := strings.Join(cfg.APITokens[0].Scopes, ","); got != config.ScopeSettingsRead {
|
||||
t.Fatalf("scopes after failed persistence = %q, want rollback to %q", got, config.ScopeSettingsRead)
|
||||
}
|
||||
}
|
||||
|
||||
func findAPITokenByID(t *testing.T, tokens []config.APITokenRecord, tokenID string) config.APITokenRecord {
|
||||
t.Helper()
|
||||
for _, token := range tokens {
|
||||
if token.ID == tokenID {
|
||||
return token
|
||||
}
|
||||
}
|
||||
t.Fatalf("token %q not found", tokenID)
|
||||
return config.APITokenRecord{}
|
||||
}
|
||||
Reference in New Issue
Block a user