Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/commands/cloud/cloudflare-token-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ const NOMINAL_PERMISSIONS: CloudflarePermission[] = [
{ key: 'zone_waf', type: 'edit' },
];

// Polylane creates its own webhook destination, notification policies, Workers Issues
// automation and observability alerts on connect, so a read-only token still edits these two.
const ALERT_SUBSCRIPTION_KEYS = new Set(['notifications', 'workers_observability']);

// A read-only token keeps every readable resource but downgrades each 'edit' grant to
// 'read' and drops the action-only grants (run/send/purge), which have no read equivalent.
// Keys are de-duplicated so a resource that appears as both edit and run collapses to one
Expand All @@ -224,6 +228,10 @@ function toReadOnlyPermissions(permissions: CloudflarePermission[]): CloudflareP
if (permission.type !== 'read' && permission.type !== 'edit') continue;
if (keys.has(permission.key)) continue;
keys.add(permission.key);
if (permission.type === 'edit' && ALERT_SUBSCRIPTION_KEYS.has(permission.key)) {
result.push(permission);
continue;
}
result.push({ key: permission.key, type: 'read' });
}
return result;
Expand Down
15 changes: 9 additions & 6 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type Provider =
export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [
{ value: 'aws', label: 'AWS', hint: 'deploys a read-only CloudFormation stack (browser)' },
{ value: 'gcp', label: 'Google Cloud', hint: 'runs an Infrastructure Manager setup script (gcloud)' },
{ value: 'cloudflare', label: 'Cloudflare', hint: 'read-only API token' },
{ value: 'cloudflare', label: 'Cloudflare', hint: 'account API token' },
{ value: 'vercel', label: 'Vercel', hint: 'install the Vercel integration (browser)' },
{ value: 'fly', label: 'Fly.io', hint: 'API token' },
{ value: 'render', label: 'Render', hint: 'API key' },
Expand Down Expand Up @@ -544,14 +544,15 @@ async function browserConnect(
const CLOUDFLARE_MANUAL_STEPS =
'Create the token from any machine with a browser:\n' +
'- Easiest: open your Polylane console > Settings > Clouds > Connect >\n' +
' Cloudflare — its create-token link opens Cloudflare with the read-only\n' +
' token pre-filled. Create it as-is.\n' +
' Cloudflare — its create-token link opens Cloudflare with the\n' +
' permissions pre-filled. Create it as-is.\n' +
'- By hand: open https://dash.cloudflare.com/?to=/:account/api-tokens and\n' +
' create a custom token with Read access for the account and its zones.\n' +
' create a custom token with Read access for the account and its zones,\n' +
' plus Edit on Notifications and Workers Observability.\n' +
'Then come back and paste the token here.';

const CLOUDFLARE_HEADLESS_HINT =
'Create a read-only token from your Polylane console (Settings > Clouds > Connect > Cloudflare — the create-token link comes pre-filled), then re-run:\n' +
'Create a token from your Polylane console (Settings > Clouds > Connect > Cloudflare — the create-token link comes pre-filled), then re-run:\n' +
'polylane cloud connect --provider cloudflare --token <token>';

// Token creation happens in-flow: the CLI opens Cloudflare's account API
Expand All @@ -576,7 +577,9 @@ function cloudflareTokenStep(
}
const ctx = { nonInteractive: config.nonInteractive };
note(
'Polylane connects to Cloudflare with a read-only account API token.\n' +
'Polylane connects to Cloudflare with an account API token that reads\n' +
'your account and edits only Notifications and Workers Observability,\n' +
'so Polylane can send Workers issues and alerts to itself.\n' +
'The CLI can open Cloudflare with the token pre-filled: create it as-is\n' +
'(nothing to edit) and paste it here.\n' +
'You must be a Super Administrator on the Cloudflare account.',
Expand Down
7 changes: 5 additions & 2 deletions test/cloudflare-token-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ describe('buildCloudflareTokenUrl', () => {
assert.equal(parsed.searchParams.get('name'), 'Polylane');
});

it('read-only tokens carry only read permissions', () => {
it('read-only tokens read everything except the alert subscription grants', () => {
const permissions = parsePermissions(buildCloudflareTokenUrl({ readOnly: true }));
assert.ok(permissions.length > 0);
for (const permission of permissions) {
assert.equal(permission.type, 'read');
const expected = permission.key === 'notifications' || permission.key === 'workers_observability' ? 'edit' : 'read';
assert.equal(permission.type, expected, permission.key);
}
assert.ok(permissions.some((p) => p.key === 'notifications' && p.type === 'edit'));
assert.ok(permissions.some((p) => p.key === 'workers_observability' && p.type === 'edit'));
});

it('read-only downgrades edit grants instead of dropping them', () => {
Expand Down
Loading