From a6dd41ffc410743f27da598c65e93300a576d92f Mon Sep 17 00:00:00 2001 From: Kinin-Code-Offical <125186556+Kinin-Code-Offical@users.noreply.github.com> Date: Mon, 22 Dec 2025 06:11:03 +0300 Subject: [PATCH] feat(p2): add enterprise policy guardrails --- README.md | 1 + docs/Policy.md | 35 +++++++++++++++ src/commands/auth.ts | 7 +++ src/commands/upgrade.ts | 13 +++++- src/core/policy.ts | 96 +++++++++++++++++++++++++++++++++++++++++ src/system/paths.ts | 1 + tests/policy.test.ts | 55 +++++++++++++++++++++++ 7 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 docs/Policy.md create mode 100644 src/core/policy.ts create mode 100644 tests/policy.test.ts diff --git a/README.md b/README.md index 644480f..c363abf 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Full documentation is available in the [docs](docs/) folder: - [Configuration & Setup](docs/Configuration.md) - [Command Reference](docs/commands.md) - [Distribution](docs/Distribution.md) + - [Enterprise Policy](docs/Policy.md) - [Troubleshooting](docs/Troubleshooting.md) ## Quick Start diff --git a/docs/Policy.md b/docs/Policy.md new file mode 100644 index 0000000..0845721 --- /dev/null +++ b/docs/Policy.md @@ -0,0 +1,35 @@ +# Enterprise policy.json + +Enterprise deployments can enforce guardrails using a machine-scope `policy.json`. + +Default location: +- `%ProgramData%\CloudSQLCTL\policy.json` + +Override location (for testing): +- `CLOUDSQLCTL_POLICY_PATH=` + +## Example + +```json +{ + "updates": { + "enabled": false, + "channel": "stable", + "pinnedVersion": "0.4.15" + }, + "auth": { + "allowUserLogin": false, + "allowAdcLogin": true, + "allowServiceAccountKey": true, + "allowedScopes": ["Machine"] + } +} +``` + +## Behavior + +- If `updates.enabled` is `false`, `cloudsqlctl upgrade` will fail with a policy error. +- If `updates.channel` is set, `cloudsqlctl upgrade --channel` cannot override it. +- If `updates.pinnedVersion` is set, `--version`, `--pin`, and `--unpin` are restricted. +- `auth.login`, `auth.adc`, and `auth set-service-account` can be allowed/blocked via `auth.*`. + diff --git a/src/commands/auth.ts b/src/commands/auth.ts index c63e1cf..459df0b 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -7,6 +7,7 @@ import { runPs } from '../system/powershell.js'; import fs from 'fs-extra'; import path from 'path'; import inquirer from 'inquirer'; +import { readPolicy, assertPolicyAllowsAuth } from '../core/policy.js'; export const authCommand = new Command('auth') .description('Manage authentication and credentials'); @@ -32,6 +33,8 @@ authCommand.command('login') .description('Login via gcloud') .action(async () => { try { + const policy = await readPolicy(); + assertPolicyAllowsAuth(policy, 'login'); await login(); logger.info('Successfully logged in.'); } catch (error) { @@ -44,6 +47,8 @@ authCommand.command('adc') .description('Setup Application Default Credentials') .action(async () => { try { + const policy = await readPolicy(); + assertPolicyAllowsAuth(policy, 'adc'); await adcLogin(); logger.info('ADC configured successfully.'); } catch (error) { @@ -78,6 +83,8 @@ authCommand.command('set-service-account') } try { + const policy = await readPolicy(); + assertPolicyAllowsAuth(policy, 'set-service-account', scope); if (!await fs.pathExists(file)) { logger.error(`File not found: ${file}`); process.exit(1); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index fbd5f65..fa0d7b1 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -3,6 +3,7 @@ import path from 'path'; import { logger } from '../core/logger.js'; import { readConfig, writeConfig } from '../core/config.js'; import { USER_PATHS } from '../system/paths.js'; +import { readPolicy, resolveUpgradePolicy } from '../core/policy.js'; import { checkForUpdates, pickAsset, @@ -37,8 +38,16 @@ export const upgradeCommand = new Command('upgrade') .action(async (options) => { try { const currentVersion = process.env.CLOUDSQLCTL_VERSION || '0.0.0'; + const policy = await readPolicy(); const config = await readConfig(); - const channel = (options.channel || config.updateChannel || 'stable') as 'stable' | 'beta'; + const policyResolved = resolveUpgradePolicy(policy, { + channel: options.channel, + version: options.version, + pin: options.pin, + unpin: options.unpin + }); + + const channel = ((policyResolved.channel || options.channel || config.updateChannel || 'stable') as 'stable' | 'beta'); if (channel !== 'stable' && channel !== 'beta') { throw new Error(`Invalid channel '${channel}'. Use 'stable' or 'beta'.`); @@ -53,7 +62,7 @@ export const upgradeCommand = new Command('upgrade') } else if (options.channel) { await writeConfig({ updateChannel: channel }); } - const targetVersion = options.version || options.pin || (options.unpin ? undefined : config.pinnedVersion); + const targetVersion = policyResolved.targetVersion || options.version || options.pin || (options.unpin ? undefined : config.pinnedVersion); if (!options.json) { const suffix = targetVersion ? ` (target: ${targetVersion})` : ''; diff --git a/src/core/policy.ts b/src/core/policy.ts new file mode 100644 index 0000000..5a4e8b1 --- /dev/null +++ b/src/core/policy.ts @@ -0,0 +1,96 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { SYSTEM_PATHS } from '../system/paths.js'; + +export type PolicyUpdateChannel = 'stable' | 'beta'; +export type PolicyScope = 'User' | 'Machine'; + +export interface EnterprisePolicy { + updates?: { + enabled?: boolean; + channel?: PolicyUpdateChannel; + pinnedVersion?: string; + }; + auth?: { + allowUserLogin?: boolean; + allowAdcLogin?: boolean; + allowServiceAccountKey?: boolean; + allowedScopes?: PolicyScope[]; + }; +} + +export interface ResolvedUpgradePolicy { + channel?: PolicyUpdateChannel; + targetVersion?: string; +} + +export function getPolicyPath(): string { + const fromEnv = process.env.CLOUDSQLCTL_POLICY_PATH; + if (fromEnv) return path.resolve(fromEnv); + return SYSTEM_PATHS.POLICY_FILE; +} + +function normalizeVersion(version: string): string { + return version.startsWith('v') ? version.slice(1) : version; +} + +export async function readPolicy(): Promise { + const policyPath = getPolicyPath(); + if (!await fs.pathExists(policyPath)) return null; + + const content = await fs.readFile(policyPath, 'utf8'); + try { + return JSON.parse(content) as EnterprisePolicy; + } catch (error) { + throw new Error(`Invalid policy.json at ${policyPath}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function resolveUpgradePolicy(policy: EnterprisePolicy | null, options: { channel?: string; version?: string; pin?: string; unpin?: boolean; }) { + if (!policy) return {} satisfies ResolvedUpgradePolicy; + + if (policy.updates?.enabled === false) { + throw new Error('Updates are disabled by enterprise policy.'); + } + + const enforcedChannel = policy.updates?.channel; + if (enforcedChannel && options.channel && options.channel !== enforcedChannel) { + throw new Error(`Update channel is restricted by enterprise policy (allowed: ${enforcedChannel}).`); + } + + const enforcedPinned = policy.updates?.pinnedVersion; + if (enforcedPinned) { + if (options.pin || options.unpin) { + throw new Error('Pin/unpin is managed by enterprise policy.'); + } + + const requested = options.version ? normalizeVersion(options.version) : undefined; + const enforced = normalizeVersion(enforcedPinned); + if (requested && requested !== enforced) { + throw new Error(`Target version is restricted by enterprise policy (allowed: ${enforced}).`); + } + + return { channel: enforcedChannel, targetVersion: enforced }; + } + + return { channel: enforcedChannel }; +} + +export function assertPolicyAllowsAuth(policy: EnterprisePolicy | null, action: 'login' | 'adc' | 'set-service-account', scope?: PolicyScope) { + if (!policy) return; + + if (action === 'login' && policy.auth?.allowUserLogin === false) { + throw new Error('Interactive gcloud login is disabled by enterprise policy.'); + } + if (action === 'adc' && policy.auth?.allowAdcLogin === false) { + throw new Error('ADC login is disabled by enterprise policy.'); + } + if (action === 'set-service-account' && policy.auth?.allowServiceAccountKey === false) { + throw new Error('Service account key management is disabled by enterprise policy.'); + } + + if (action === 'set-service-account' && scope && policy.auth?.allowedScopes && !policy.auth.allowedScopes.includes(scope)) { + throw new Error(`Scope '${scope}' is not allowed by enterprise policy.`); + } +} + diff --git a/src/system/paths.ts b/src/system/paths.ts index 457b4dd..7f3c682 100644 --- a/src/system/paths.ts +++ b/src/system/paths.ts @@ -25,6 +25,7 @@ export const SYSTEM_PATHS = { PROXY_EXE: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'bin', 'cloud-sql-proxy.exe'), SCRIPTS: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'scripts'), SECRETS: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'secrets'), + POLICY_FILE: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'policy.json'), }; export const ENV_VARS = { diff --git a/tests/policy.test.ts b/tests/policy.test.ts new file mode 100644 index 0000000..2654ccf --- /dev/null +++ b/tests/policy.test.ts @@ -0,0 +1,55 @@ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { readPolicy, resolveUpgradePolicy, assertPolicyAllowsAuth } from '../src/core/policy.js'; + +function tmpFile(name: string) { + return path.join(os.tmpdir(), `cloudsqlctl-${name}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`); +} + +describe('Policy Module', () => { + const originalEnv = process.env.CLOUDSQLCTL_POLICY_PATH; + + afterEach(async () => { + if (originalEnv === undefined) { + delete process.env.CLOUDSQLCTL_POLICY_PATH; + } else { + process.env.CLOUDSQLCTL_POLICY_PATH = originalEnv; + } + }); + + it('returns null if policy does not exist', async () => { + process.env.CLOUDSQLCTL_POLICY_PATH = tmpFile('missing'); + const policy = await readPolicy(); + expect(policy).toBeNull(); + }); + + it('throws if policy exists but is invalid json', async () => { + const p = tmpFile('invalid'); + await fs.writeFile(p, '{not-json', 'utf8'); + process.env.CLOUDSQLCTL_POLICY_PATH = p; + await expect(readPolicy()).rejects.toThrow(/Invalid policy\.json/); + await fs.remove(p); + }); + + it('enforces upgrades disabled', () => { + expect(() => resolveUpgradePolicy({ updates: { enabled: false } }, {})).toThrow(/Updates are disabled/); + }); + + it('enforces pinned version and channel restrictions', () => { + const policy = { updates: { channel: 'stable', pinnedVersion: '0.4.15' } }; + expect(() => resolveUpgradePolicy(policy, { channel: 'beta' })).toThrow(/channel is restricted/i); + expect(() => resolveUpgradePolicy(policy, { pin: '0.4.16' })).toThrow(/Pin\/unpin is managed/i); + expect(() => resolveUpgradePolicy(policy, { version: '0.4.16' })).toThrow(/Target version is restricted/i); + expect(resolveUpgradePolicy(policy, {})).toEqual({ channel: 'stable', targetVersion: '0.4.15' }); + expect(resolveUpgradePolicy(policy, { version: 'v0.4.15' })).toEqual({ channel: 'stable', targetVersion: '0.4.15' }); + }); + + it('enforces auth guardrails', () => { + const policy = { auth: { allowUserLogin: false, allowedScopes: ['Machine'] as const } }; + expect(() => assertPolicyAllowsAuth(policy, 'login')).toThrow(/disabled/i); + expect(() => assertPolicyAllowsAuth(policy, 'set-service-account', 'User')).toThrow(/not allowed/i); + expect(() => assertPolicyAllowsAuth(policy, 'set-service-account', 'Machine')).not.toThrow(); + }); +}); +