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
246 changes: 246 additions & 0 deletions src/commands/cloud/cloudflare-token-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Duplicated from coreplanelabs/nominal
// apps/console/app/utils/cloudflare-token-url.ts (buildCloudflareTokenUrl) —
// the source of truth the console connect flow and the docs codegen both use.
// The Polylane API does not serve this URL yet, so the CLI carries its own
// copy; keep it in sync with the nominal file when the permission set changes.
// The generated URL is ~13KB (the permission list rides in a query param):
// fine for a browser to open, never print it to a terminal.
type CloudflarePermissionType = 'read' | 'edit' | 'run' | 'send' | 'purge';

type CloudflarePermission = {
key: string;
type: CloudflarePermissionType;
};

// Account-owned token permission keys, derived from the dashboard permission-group
// labels (key = label minus its trailing type segment). Zero Trust is read-only;
// billing/token-mint/SSO/SCIM/OAuth writes and user-scoped groups are deliberately omitted.
const NOMINAL_PERMISSIONS: CloudflarePermission[] = [
{ key: 'access', type: 'read' },
{ key: 'access_acct', type: 'read' },
{ key: 'access_app', type: 'read' },
{ key: 'access_audit_log', type: 'read' },
{ key: 'access_certificate', type: 'read' },
{ key: 'access_custom_page', type: 'read' },
{ key: 'access_device_posture', type: 'read' },
{ key: 'access_group', type: 'read' },
{ key: 'access_idp', type: 'read' },
{ key: 'access_key', type: 'read' },
{ key: 'access_org', type: 'read' },
{ key: 'access_policy', type: 'read' },
{ key: 'access_policy_test', type: 'read' },
{ key: 'access_population', type: 'read' },
{ key: 'access_saml_certificate', type: 'read' },
{ key: 'access_scim_log', type: 'read' },
{ key: 'access_service_token', type: 'read' },
{ key: 'access_ssh_auditing', type: 'read' },
{ key: 'access_tag', type: 'read' },
{ key: 'access_users', type: 'read' },
{ key: 'account_abuse_protection_pii', type: 'read' },
{ key: 'account_analytics', type: 'read' },
{ key: 'account_api_gateway', type: 'edit' },
{ key: 'account_api_tokens', type: 'read' },
{ key: 'account_custom_asset', type: 'edit' },
{ key: 'account_custom_error_rules', type: 'edit' },
{ key: 'account_custom_pages', type: 'edit' },
{ key: 'account_disable_esc', type: 'edit' },
{ key: 'account_dns_settings', type: 'edit' },
{ key: 'account_firewall_access_rules', type: 'edit' },
{ key: 'account_logs', type: 'edit' },
{ key: 'account_rule_lists', type: 'edit' },
{ key: 'account_rule_policies', type: 'edit' },
{ key: 'account_rulesets', type: 'edit' },
{ key: 'account_security_center_insights', type: 'edit' },
{ key: 'account_settings', type: 'edit' },
{ key: 'account_ssl_and_certificates', type: 'edit' },
{ key: 'account_waf', type: 'edit' },
{ key: 'account_waiting_rooms', type: 'read' },
{ key: 'address_maps', type: 'edit' },
{ key: 'agent-memory', type: 'edit' },
{ key: 'agw', type: 'edit' },
{ key: 'agw', type: 'run' },
{ key: 'ai', type: 'edit' },
{ key: 'ai_search', type: 'edit' },
{ key: 'ai_search', type: 'run' },
{ key: 'ai_search_index', type: 'edit' },
{ key: 'aiaudit', type: 'edit' },
{ key: 'aig', type: 'edit' },
{ key: 'aig', type: 'run' },
{ key: 'analytics', type: 'read' },
{ key: 'api_gateway', type: 'edit' },
{ key: 'apps', type: 'edit' },
{ key: 'argotunnel', type: 'read' },
{ key: 'artifacts', type: 'edit' },
{ key: 'billing', type: 'read' },
{ key: 'bot_management', type: 'edit' },
{ key: 'bot_management_feedback', type: 'edit' },
{ key: 'browser_rendering', type: 'edit' },
{ key: 'cache', type: 'purge' },
{ key: 'cache_settings', type: 'edit' },
{ key: 'calls', type: 'edit' },
{ key: 'casb', type: 'read' },
{ key: 'cf_agents', type: 'edit' },
{ key: 'challenge_widgets', type: 'edit' },
{ key: 'chinanetwork_steering', type: 'edit' },
{ key: 'cloud_connector', type: 'edit' },
{ key: 'cloud_email_security', type: 'edit' },
{ key: 'cloudchamber', type: 'edit' },
{ key: 'cloudforce_one', type: 'edit' },
{ key: 'config_settings', type: 'edit' },
{ key: 'connectivity_directory', type: 'edit' },
{ key: 'constellation', type: 'edit' },
{ key: 'containers', type: 'edit' },
{ key: 'custom_errors', type: 'edit' },
{ key: 'custom_pages', type: 'edit' },
{ key: 'd1', type: 'edit' },
{ key: 'd1_metadata', type: 'read' },
{ key: 'ddos_botnet_feed', type: 'edit' },
{ key: 'ddos_protection', type: 'edit' },
{ key: 'dls', type: 'read' },
{ key: 'dns', type: 'edit' },
{ key: 'dns_firewall', type: 'edit' },
{ key: 'dns_view', type: 'edit' },
{ key: 'domain_page_shield', type: 'edit' },
{ key: 'dynamic_redirect', type: 'edit' },
{ key: 'email_routing_address', type: 'edit' },
{ key: 'email_routing_rule', type: 'edit' },
{ key: 'email_routing_suppression', type: 'edit' },
{ key: 'email_security_dmarcreports', type: 'edit' },
{ key: 'email_sending', type: 'edit' },
{ key: 'fbm', type: 'edit' },
{ key: 'firewall_for_ai', type: 'edit' },
{ key: 'firewall_services', type: 'edit' },
{ key: 'flagship', type: 'edit' },
{ key: 'flagship', type: 'run' },
{ key: 'fraud_detection', type: 'edit' },
{ key: 'fraud_events', type: 'edit' },
{ key: 'granular_workers_scripts', type: 'read' },
{ key: 'healthcheck', type: 'edit' },
{ key: 'http_applications', type: 'edit' },
{ key: 'http_ddos_managed_ruleset', type: 'edit' },
{ key: 'images', type: 'edit' },
{ key: 'images_metadata', type: 'read' },
{ key: 'integration', type: 'edit' },
{ key: 'intel', type: 'edit' },
{ key: 'iot', type: 'edit' },
{ key: 'ip_prefix', type: 'edit' },
{ key: 'ip_prefix_bgp_on_demand', type: 'edit' },
{ key: 'l4_ddos_managed_ruleset', type: 'edit' },
{ key: 'load_balancers', type: 'edit' },
{ key: 'load_balancers_account', type: 'edit' },
{ key: 'load_balancing_monitors_and_pools', type: 'edit' },
{ key: 'logs', type: 'edit' },
{ key: 'magic_firewall', type: 'edit' },
{ key: 'magic_transit', type: 'edit' },
{ key: 'magic_wan', type: 'edit' },
{ key: 'managed_headers', type: 'edit' },
{ key: 'mass_url_redirects', type: 'edit' },
{ key: 'mcp_portals', type: 'edit' },
{ key: 'moq', type: 'edit' },
{ key: 'notifications', type: 'edit' },
{ key: 'oauth_client', type: 'read' },
{ key: 'origin', type: 'edit' },
{ key: 'page', type: 'edit' },
{ key: 'page_rules', type: 'edit' },
{ key: 'page_shield', type: 'edit' },
{ key: 'pcaps_api', type: 'edit' },
{ key: 'pipelines', type: 'edit' },
{ key: 'pipelines', type: 'send' },
{ key: 'pubsub', type: 'edit' },
{ key: 'query_cache', type: 'edit' },
{ key: 'queues', type: 'edit' },
{ key: 'queues_metadata', type: 'read' },
{ key: 'r2_catalog', type: 'edit' },
{ key: 'r2_catalog_sql', type: 'read' },
{ key: 'radar', type: 'read' },
{ key: 'rag', type: 'edit' },
{ key: 'rag', type: 'run' },
{ key: 'realtime', type: 'edit' },
{ key: 'registrar_domains', type: 'edit' },
{ key: 'reports_application_security_report', type: 'read' },
{ key: 'request_tracer', type: 'read' },
{ key: 'resource_library', type: 'edit' },
{ key: 'resource_sharing', type: 'read' },
{ key: 'response_compression', type: 'edit' },
{ key: 'sanitize', type: 'edit' },
{ key: 'secrets_store', type: 'edit' },
{ key: 'select_configuration', type: 'edit' },
{ key: 'snippets', type: 'edit' },
{ key: 'ssl_and_certificates', type: 'edit' },
{ key: 'sso_connector', type: 'read' },
{ key: 'stream', type: 'edit' },
{ key: 'stream_metadata', type: 'read' },
{ key: 'tag', type: 'edit' },
{ key: 'teams', type: 'read' },
{ key: 'teams_cds_compute_account', type: 'read' },
{ key: 'teams_connector_cloudflared', type: 'read' },
{ key: 'teams_connector_warp', type: 'read' },
{ key: 'teams_connectors', type: 'read' },
{ key: 'teams_dex', type: 'edit' },
{ key: 'teams_networks', type: 'read' },
{ key: 'teams_pii', type: 'read' },
{ key: 'teams_resilience', type: 'read' },
{ key: 'transform_rules', type: 'edit' },
{ key: 'trust_and_safety', type: 'edit' },
{ key: 'url_scanner', type: 'edit' },
{ key: 'vectorize', type: 'edit' },
{ key: 'waiting_rooms', type: 'edit' },
{ key: 'web3_hostnames', type: 'edit' },
{ key: 'websearch', type: 'run' },
{ key: 'workers_ci', type: 'edit' },
{ key: 'workers_kv_storage', type: 'edit' },
{ key: 'workers_kv_storage_metadata', type: 'read' },
{ key: 'workers_observability', type: 'edit' },
{ key: 'workers_observability_telemetry', type: 'edit' },
{ key: 'workers_r2', type: 'edit' },
{ key: 'workers_r2_bucket_item', type: 'edit' },
{ key: 'workers_r2_metadata', type: 'read' },
{ key: 'workers_routes', type: 'edit' },
{ key: 'workers_scripts', type: 'edit' },
{ key: 'workers_tail', type: 'read' },
{ key: 'workers_websearch', type: 'edit' },
{ key: 'zaraz', type: 'edit' },
{ key: 'zaraz_edit', type: 'edit' },
{ key: 'zone', type: 'edit' },
{ key: 'zone_access', type: 'read' },
{ key: 'zone_custom_asset', type: 'edit' },
{ key: 'zone_disable_esc', type: 'edit' },
{ key: 'zone_dns_settings', type: 'edit' },
{ key: 'zone_security_center_insights', type: 'edit' },
{ key: 'zone_settings', type: 'edit' },
{ key: 'zone_transform_rules', type: 'edit' },
{ key: 'zone_versioning', type: 'edit' },
{ key: 'zone_waf', type: 'edit' },
];

// 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
// read entry.
function toReadOnlyPermissions(permissions: CloudflarePermission[]): CloudflarePermission[] {
const keys = new Set<string>();
const result: CloudflarePermission[] = [];
for (const permission of permissions) {
if (permission.type !== 'read' && permission.type !== 'edit') continue;
if (keys.has(permission.key)) continue;
keys.add(permission.key);
result.push({ key: permission.key, type: 'read' });
}
return result;
}

export function buildCloudflareTokenUrl(options?: {
permissions?: CloudflarePermission[];
name?: string;
readOnly?: boolean;
}): string {
const base = options?.permissions ?? NOMINAL_PERMISSIONS;
const permissions = options?.readOnly ? toReadOnlyPermissions(base) : base;
const name = options?.name ?? 'Polylane';
const params = new URLSearchParams({
to: '/:account/api-tokens',
permissionGroupKeys: JSON.stringify(permissions),
name,
});
return `https://dash.cloudflare.com/?${params.toString()}`;
}
103 changes: 80 additions & 23 deletions src/commands/cloud/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {
choiceStep,
secretStep,
SKIPPED,
type WizardStep,
} from '../helpers';
import { buildCloudflareTokenUrl } from './cloudflare-token-url';
import type { CloudAccount } from '../../generated/types';
import { CLIError } from '../../errors/base';
import { ExitCode } from '../../errors/codes';
Expand All @@ -27,6 +29,7 @@ import {
note,
promptSelectOrBack,
promptConfirmOrBack,
promptPasswordOrBack,
} from '../../utils/prompt';

type ConnectBody = Parameters<PolylaneAPI['cloudAccountsConnect']>[0];
Expand Down Expand Up @@ -202,6 +205,74 @@ async function browserConnect(
return confirmBrowserConnect(config, check, `${label} to connect`);
}

// Where to create the token when this terminal cannot open a browser. The
// pre-filled creation URL is ~13KB (the permission list rides in a query
// param), so it is never printed — the console's connect screen carries the
// same link, and the short dashboard URL covers doing it fully by hand.
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' +
'- 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' +
'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' +
'polylane cloud connect --provider cloudflare --token <token>';

// Token creation happens in-flow: the CLI opens Cloudflare's account API
// token screen pre-filled with the read-only token, waits for the paste, and
// never routes through the docs site. secretStep is not reusable here because
// it prints its link everywhere it appears — this URL must only ever be
// handed to a browser.
function cloudflareTokenStep(
config: Config,
args: Record<string, unknown>,
noBrowser: boolean,
set: (value: string) => void
): WizardStep {
return async () => {
const fromFlag = getArgString(args, 'token');
if (fromFlag !== undefined) {
set(fromFlag);
return SKIPPED;
}
if (!isInteractive(config.nonInteractive)) {
throw new CLIError('Missing required flag: --token', ExitCode.USAGE, CLOUDFLARE_HEADLESS_HINT);
}
const ctx = { nonInteractive: config.nonInteractive };
note(
'Polylane connects to Cloudflare with a read-only account API token.\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.',
'Cloudflare API token'
);
let openIt = !noBrowser;
if (!noBrowser) {
const answer = await promptConfirmOrBack(ctx, 'Open Cloudflare in your browser to create the token?', true);
if (answer === BACK) return BACK;
openIt = answer;
}
if (openIt) {
process.stderr.write('Opening your browser to create the token… paste it here when done.\n');
openBrowser(buildCloudflareTokenUrl({ readOnly: true }));
Comment thread
claude[bot] marked this conversation as resolved.
// openBrowser is best-effort and the pre-filled URL must never be
// printed, so a silent spawn failure would leave the paste prompt with
// no way forward — always show the manual fallback too.
note(CLOUDFLARE_MANUAL_STEPS, "If the browser didn't open");
} else {
note(CLOUDFLARE_MANUAL_STEPS, 'No browser on this machine');
}
const value = await promptPasswordOrBack(ctx, 'Cloudflare API token (paste it here)');
if (value === BACK) return BACK;
set(value);
return;
};
}

// Each wizard step can go back to the previous one; backing out of the first
// returns BACK to re-open provider selection.
async function connectProvider(
Expand Down Expand Up @@ -329,30 +400,16 @@ async function connectProvider(
...(subscribeToAlarms ? { subscribeToAlarms } : {}),
};
} else if (provider === 'cloudflare') {
// Always read-only. The docs page offers two pre-filled tokens (read+write
// first, read-only second), so the copy has to name the read-only one by
// its button label: a token minted from the other link and pasted here
// would be stored under a read-only label it does not have, and every
// write for the account would then be refused with no way to re-enable it.
// Read-only is also what both console connect surfaces send.
// Always read-only: that is what both console connect surfaces send, and
// the pre-filled URL mints a token with every write downgraded to read. A
// broader token pasted here would be stored under a read-only label it
// does not have, and every write for the account would then be refused
// with no way to re-enable it.
let token = '';
const ok = await runSteps([
secretStep(
config,
args,
'token',
'--token',
{
message: 'Cloudflare API token',
instructions:
'On the docs page, use the "Create read-only token" link: it opens Cloudflare\'s account API token screen with a pre-filled, read-only token. Create it as-is and paste it here. You must be a Super Administrator on the account.',
link: 'https://docs.polylane.com/integrations/cloudflare',
linkLabel: 'Create the token (use the read-only link)',
},
(v) => {
token = v;
}
),
cloudflareTokenStep(config, args, noBrowser, (v) => {
token = v;
}),
]);
if (!ok) return BACK;
body = { workspaceId, provider: 'cloudflare', token, readOnly: true };
Expand Down Expand Up @@ -491,7 +548,7 @@ export const cloudConnectCommand: Command = {
{ flag: '--organization <org>', description: 'PlanetScale organization', type: 'string' },
// Render
{ flag: '--api-key <key>', description: 'Render API key', type: 'string' },
{ flag: '--no-browser', description: 'AWS / Vercel / PlanetScale / Supabase: print the URL instead of opening it', type: 'boolean' },
{ flag: '--no-browser', description: 'AWS / Vercel / PlanetScale / Supabase: print the URL instead of opening it; Cloudflare: show manual token-creation steps instead of opening the browser', type: 'boolean' },
{ flag: '--reconnect', description: 'AWS / Vercel / PlanetScale / Supabase / Kubernetes: run the connect flow even when the provider is already connected', type: 'boolean' },
],
examples: [
Expand Down
Loading
Loading