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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions docs/Policy.md
Original file line number Diff line number Diff line change
@@ -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=<path>`
Comment on lines +8 to +9
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documenting CLOUDSQLCTL_POLICY_PATH as an override for the machine-scope policy means any local user can point the CLI at a non-existent or user-controlled JSON file, causing readPolicy to return null and effectively disabling enterprise policy enforcement for auth and upgrade commands. Because environment variables are entirely under the caller's control (including per-process overrides that bypass system-wide settings), this undermines the guarantee that policy guardrails are centrally enforced and non-bypassable. Consider restricting or disabling this override in production/enterprise scenarios (e.g., only honoring it in explicit dev/test modes or when running with admin context) so that the machine-scope policy cannot be trivially bypassed.

Suggested change
Override location (for testing):
- `CLOUDSQLCTL_POLICY_PATH=<path>`
Override location (development/testing on non-managed machines only):
- `CLOUDSQLCTL_POLICY_PATH=<path>`
- Do **not** rely on this override in locked-down or enterprise-managed environments, as it allows the caller to bypass the machine-scope policy.

Copilot uses AI. Check for mistakes.

## Example

```json
{
"updates": {
"enabled": false,
"channel": "stable",
"pinnedVersion": "0.4.15"
},
"auth": {
"allowUserLogin": false,
"allowAdcLogin": true,
"allowServiceAccountKey": true,
"allowedScopes": ["Machine"]
}
}
```
Comment on lines +13 to +27
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation example shows updates.enabled set to false alongside channel and pinnedVersion, but this is contradictory. When updates.enabled is false, the upgrade command will fail immediately (line 52-54 of src/core/policy.ts), so the channel and pinnedVersion settings would never be evaluated. Consider either removing enabled: false from the example or providing separate examples for different policy scenarios to avoid confusion.

Copilot uses AI. Check for mistakes.

## 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.*`.

7 changes: 7 additions & 0 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 11 additions & 2 deletions src/commands/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'.`);
Expand All @@ -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})` : '';
Expand Down
96 changes: 96 additions & 0 deletions src/core/policy.ts
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +33 to +34
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The normalizeVersion function only handles the 'v' prefix case, but doesn't validate that the remaining string is actually a valid version format. Consider adding validation to ensure the version string follows semantic versioning or expected format (e.g., "1.2.3"), especially since this is used for comparison in policy enforcement where incorrect versions could bypass restrictions.

Suggested change
function normalizeVersion(version: string): string {
return version.startsWith('v') ? version.slice(1) : version;
const SEMVER_REGEX = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
function normalizeVersion(version: string): string {
const normalized = version.startsWith('v') ? version.slice(1) : version;
if (!SEMVER_REGEX.test(normalized)) {
throw new Error(`Invalid version format '${version}'. Expected semantic version like '1.2.3'.`);
}
return normalized;

Copilot uses AI. Check for mistakes.
}

export async function readPolicy(): Promise<EnterprisePolicy | null> {
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)}`);
}
Comment on lines +37 to +46
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readPolicy function performs no validation on the structure or values of the parsed JSON. While TypeScript types provide compile-time safety, at runtime the JSON could contain invalid values (e.g., updates.channel = "invalid", allowedScopes = ["InvalidScope"]). Consider adding runtime validation to ensure the policy adheres to expected schemas, especially for enum-like fields such as channel (stable/beta) and allowedScopes (User/Machine).

Copilot uses AI. Check for mistakes.
}

export function resolveUpgradePolicy(policy: EnterprisePolicy | null, options: { channel?: string; version?: string; pin?: string; unpin?: boolean; }) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Explicitly type the return value of resolveUpgradePolicy to keep the ResolvedUpgradePolicy contract stable.

The current implementation relies on inference and only the early return is checked with satisfies ResolvedUpgradePolicy. Other return paths aren’t validated against that shape. Declaring the return type as : ResolvedUpgradePolicy ensures all branches conform and prevents future changes from adding or omitting fields without a type error.

Suggested change
export function resolveUpgradePolicy(policy: EnterprisePolicy | null, options: { channel?: string; version?: string; pin?: string; unpin?: boolean; }) {
export function resolveUpgradePolicy(policy: EnterprisePolicy | null, options: { channel?: string; version?: string; pin?: string; unpin?: boolean; }): ResolvedUpgradePolicy {

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.`);
Comment on lines +92 to +93
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When policy.auth.allowedScopes is defined but empty (e.g., []), the includes check on line 92 will always return false, blocking all service account operations regardless of scope. While this might be intentional, consider handling this edge case explicitly or documenting this behavior, as an empty array might be a configuration mistake rather than an intent to block all scopes.

Suggested change
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.`);
if (action === 'set-service-account' && scope && policy.auth?.allowedScopes) {
if (policy.auth.allowedScopes.length === 0) {
throw new Error("Service account scope is not allowed: enterprise policy 'allowedScopes' is configured but empty, so no scopes are permitted.");
}
if (!policy.auth.allowedScopes.includes(scope)) {
throw new Error(`Scope '${scope}' is not allowed by enterprise policy.`);
}

Copilot uses AI. Check for mistakes.
}
}

1 change: 1 addition & 0 deletions src/system/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
55 changes: 55 additions & 0 deletions tests/policy.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Comment on lines +21 to +24
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a positive-path test for successfully reading a valid policy file, including the default path behavior

Currently we only cover failure modes (missing file, invalid JSON). Please add a test that writes a valid policy.json, sets CLOUDSQLCTL_POLICY_PATH to it (and/or relies on the default SYSTEM_PATHS.POLICY_FILE via a temporary override/mocking), and asserts that readPolicy returns the expected EnterprisePolicy. This will verify the happy-path parsing and path resolution behavior.

Suggested implementation:

    it('returns null if policy does not exist', async () => {
        process.env.CLOUDSQLCTL_POLICY_PATH = tmpFile('missing');
        const policy = await readPolicy();
        expect(policy).toBeNull();
    });

    it('reads a valid policy file from CLOUDSQLCTL_POLICY_PATH', async () => {
        const file = tmpFile('policy');

        const policyContent = {
            // Adjust this shape to the minimal valid EnterprisePolicy for your implementation
            // (e.g. allowedProjects, rules, etc.)
            exampleKey: 'example-value',
        };

        await fs.promises.writeFile(file, JSON.stringify(policyContent), 'utf8');

        process.env.CLOUDSQLCTL_POLICY_PATH = file;

        const policy = await readPolicy();

        expect(policy).not.toBeNull();
        expect(policy).toMatchObject(policyContent);
    });
  1. Ensure fs is imported at the top of tests/policy.test.ts:
    • import fs from 'fs'; (or consistent with the existing import style in this file).
  2. The policyContent object in the new test must be updated to match a valid EnterprisePolicy according to your production readPolicy validation logic (e.g., required fields, structure). The test currently assumes that readPolicy returns the parsed JSON as-is and uses toMatchObject to allow additional properties such as defaults.
  3. To fully cover the default path behavior requested in the review comment, add a second happy-path test that:
    • Mocks or overrides the constant used by readPolicy for the default policy path (likely SYSTEM_PATHS.POLICY_FILE) so it points to a temporary file (similar to tmpFile('policy-default')).
    • Writes a valid policy JSON to that temporary file.
    • Ensures process.env.CLOUDSQLCTL_POLICY_PATH is unset.
    • Asserts that readPolicy() returns the expected EnterprisePolicy when relying on the default path.
      The exact mocking approach depends on how SYSTEM_PATHS (or equivalent) is imported in the production module (e.g., jest.mock, jest.spyOn, or a helper in your test utilities).

});

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);
Comment on lines +29 to +32
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The temporary file created in this test is not cleaned up if the assertion fails. If the test throws an error before reaching line 32, the temporary file will remain in the system's temp directory. Consider wrapping the test logic in a try-finally block or using an afterEach hook to ensure cleanup even when tests fail.

Suggested change
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);
try {
await fs.writeFile(p, '{not-json', 'utf8');
process.env.CLOUDSQLCTL_POLICY_PATH = p;
await expect(readPolicy()).rejects.toThrow(/Invalid policy\.json/);
} finally {
await fs.remove(p);
}

Copilot uses AI. Check for mistakes.
});
Comment on lines +21 to +33
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no test case that verifies reading a valid, well-formed policy.json file. The tests only cover missing files (line 21-25) and invalid JSON (line 27-33), but don't verify that a correctly formatted policy file is parsed successfully with all fields intact. Consider adding a test that writes a complete policy JSON, reads it, and verifies all fields are correctly parsed.

Copilot uses AI. Check for mistakes.

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' });
});
Comment on lines +39 to +46
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the case where a policy sets only channel without pinnedVersion. The function returns different results in line 76 (returns only channel) versus line 73 (returns both channel and targetVersion). Add a test to verify that when only policy.updates.channel is set, the function correctly returns just the enforced channel without a targetVersion.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +46
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a policy enforces a channel restriction but the user doesn't provide any channel option, the code correctly uses the enforced channel. However, there's no test case covering the scenario where policy.updates.channel is set WITHOUT pinnedVersion and the user provides a version option. In this case, the function would return just the enforced channel (line 76), which seems correct, but this behavior should be explicitly tested to ensure channel enforcement works independently of version constraints.

Copilot uses AI. Check for mistakes.

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();
});
Comment on lines +48 to +53
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test for auth guardrails only covers allowUserLogin and allowedScopes, but doesn't test the allowAdcLogin and allowServiceAccountKey policy fields that are checked on lines 85-90 of src/core/policy.ts. Consider adding test cases to verify that these policy settings are enforced correctly for the 'adc' and 'set-service-account' actions.

Copilot uses AI. Check for mistakes.
});