Skip to content

Commit 14a3711

Browse files
feat(p2): add enterprise policy guardrails (#45)
1 parent 3a142f6 commit 14a3711

File tree

7 files changed

+206
-2
lines changed

7 files changed

+206
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Full documentation is available in the [docs](docs/) folder:
3131
- [Configuration & Setup](docs/Configuration.md)
3232
- [Command Reference](docs/commands.md)
3333
- [Distribution](docs/Distribution.md)
34+
- [Enterprise Policy](docs/Policy.md)
3435
- [Troubleshooting](docs/Troubleshooting.md)
3536

3637
## Quick Start

docs/Policy.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Enterprise policy.json
2+
3+
Enterprise deployments can enforce guardrails using a machine-scope `policy.json`.
4+
5+
Default location:
6+
- `%ProgramData%\CloudSQLCTL\policy.json`
7+
8+
Override location (for testing):
9+
- `CLOUDSQLCTL_POLICY_PATH=<path>`
10+
11+
## Example
12+
13+
```json
14+
{
15+
"updates": {
16+
"enabled": false,
17+
"channel": "stable",
18+
"pinnedVersion": "0.4.15"
19+
},
20+
"auth": {
21+
"allowUserLogin": false,
22+
"allowAdcLogin": true,
23+
"allowServiceAccountKey": true,
24+
"allowedScopes": ["Machine"]
25+
}
26+
}
27+
```
28+
29+
## Behavior
30+
31+
- If `updates.enabled` is `false`, `cloudsqlctl upgrade` will fail with a policy error.
32+
- If `updates.channel` is set, `cloudsqlctl upgrade --channel` cannot override it.
33+
- If `updates.pinnedVersion` is set, `--version`, `--pin`, and `--unpin` are restricted.
34+
- `auth.login`, `auth.adc`, and `auth set-service-account` can be allowed/blocked via `auth.*`.
35+

src/commands/auth.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { runPs } from '../system/powershell.js';
77
import fs from 'fs-extra';
88
import path from 'path';
99
import inquirer from 'inquirer';
10+
import { readPolicy, assertPolicyAllowsAuth } from '../core/policy.js';
1011

1112
export const authCommand = new Command('auth')
1213
.description('Manage authentication and credentials');
@@ -32,6 +33,8 @@ authCommand.command('login')
3233
.description('Login via gcloud')
3334
.action(async () => {
3435
try {
36+
const policy = await readPolicy();
37+
assertPolicyAllowsAuth(policy, 'login');
3538
await login();
3639
logger.info('Successfully logged in.');
3740
} catch (error) {
@@ -44,6 +47,8 @@ authCommand.command('adc')
4447
.description('Setup Application Default Credentials')
4548
.action(async () => {
4649
try {
50+
const policy = await readPolicy();
51+
assertPolicyAllowsAuth(policy, 'adc');
4752
await adcLogin();
4853
logger.info('ADC configured successfully.');
4954
} catch (error) {
@@ -78,6 +83,8 @@ authCommand.command('set-service-account')
7883
}
7984

8085
try {
86+
const policy = await readPolicy();
87+
assertPolicyAllowsAuth(policy, 'set-service-account', scope);
8188
if (!await fs.pathExists(file)) {
8289
logger.error(`File not found: ${file}`);
8390
process.exit(1);

src/commands/upgrade.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'path';
33
import { logger } from '../core/logger.js';
44
import { readConfig, writeConfig } from '../core/config.js';
55
import { USER_PATHS } from '../system/paths.js';
6+
import { readPolicy, resolveUpgradePolicy } from '../core/policy.js';
67
import {
78
checkForUpdates,
89
pickAsset,
@@ -37,8 +38,16 @@ export const upgradeCommand = new Command('upgrade')
3738
.action(async (options) => {
3839
try {
3940
const currentVersion = process.env.CLOUDSQLCTL_VERSION || '0.0.0';
41+
const policy = await readPolicy();
4042
const config = await readConfig();
41-
const channel = (options.channel || config.updateChannel || 'stable') as 'stable' | 'beta';
43+
const policyResolved = resolveUpgradePolicy(policy, {
44+
channel: options.channel,
45+
version: options.version,
46+
pin: options.pin,
47+
unpin: options.unpin
48+
});
49+
50+
const channel = ((policyResolved.channel || options.channel || config.updateChannel || 'stable') as 'stable' | 'beta');
4251

4352
if (channel !== 'stable' && channel !== 'beta') {
4453
throw new Error(`Invalid channel '${channel}'. Use 'stable' or 'beta'.`);
@@ -53,7 +62,7 @@ export const upgradeCommand = new Command('upgrade')
5362
} else if (options.channel) {
5463
await writeConfig({ updateChannel: channel });
5564
}
56-
const targetVersion = options.version || options.pin || (options.unpin ? undefined : config.pinnedVersion);
65+
const targetVersion = policyResolved.targetVersion || options.version || options.pin || (options.unpin ? undefined : config.pinnedVersion);
5766

5867
if (!options.json) {
5968
const suffix = targetVersion ? ` (target: ${targetVersion})` : '';

src/core/policy.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import fs from 'fs-extra';
2+
import path from 'path';
3+
import { SYSTEM_PATHS } from '../system/paths.js';
4+
5+
export type PolicyUpdateChannel = 'stable' | 'beta';
6+
export type PolicyScope = 'User' | 'Machine';
7+
8+
export interface EnterprisePolicy {
9+
updates?: {
10+
enabled?: boolean;
11+
channel?: PolicyUpdateChannel;
12+
pinnedVersion?: string;
13+
};
14+
auth?: {
15+
allowUserLogin?: boolean;
16+
allowAdcLogin?: boolean;
17+
allowServiceAccountKey?: boolean;
18+
allowedScopes?: PolicyScope[];
19+
};
20+
}
21+
22+
export interface ResolvedUpgradePolicy {
23+
channel?: PolicyUpdateChannel;
24+
targetVersion?: string;
25+
}
26+
27+
export function getPolicyPath(): string {
28+
const fromEnv = process.env.CLOUDSQLCTL_POLICY_PATH;
29+
if (fromEnv) return path.resolve(fromEnv);
30+
return SYSTEM_PATHS.POLICY_FILE;
31+
}
32+
33+
function normalizeVersion(version: string): string {
34+
return version.startsWith('v') ? version.slice(1) : version;
35+
}
36+
37+
export async function readPolicy(): Promise<EnterprisePolicy | null> {
38+
const policyPath = getPolicyPath();
39+
if (!await fs.pathExists(policyPath)) return null;
40+
41+
const content = await fs.readFile(policyPath, 'utf8');
42+
try {
43+
return JSON.parse(content) as EnterprisePolicy;
44+
} catch (error) {
45+
throw new Error(`Invalid policy.json at ${policyPath}: ${error instanceof Error ? error.message : String(error)}`);
46+
}
47+
}
48+
49+
export function resolveUpgradePolicy(policy: EnterprisePolicy | null, options: { channel?: string; version?: string; pin?: string; unpin?: boolean; }) {
50+
if (!policy) return {} satisfies ResolvedUpgradePolicy;
51+
52+
if (policy.updates?.enabled === false) {
53+
throw new Error('Updates are disabled by enterprise policy.');
54+
}
55+
56+
const enforcedChannel = policy.updates?.channel;
57+
if (enforcedChannel && options.channel && options.channel !== enforcedChannel) {
58+
throw new Error(`Update channel is restricted by enterprise policy (allowed: ${enforcedChannel}).`);
59+
}
60+
61+
const enforcedPinned = policy.updates?.pinnedVersion;
62+
if (enforcedPinned) {
63+
if (options.pin || options.unpin) {
64+
throw new Error('Pin/unpin is managed by enterprise policy.');
65+
}
66+
67+
const requested = options.version ? normalizeVersion(options.version) : undefined;
68+
const enforced = normalizeVersion(enforcedPinned);
69+
if (requested && requested !== enforced) {
70+
throw new Error(`Target version is restricted by enterprise policy (allowed: ${enforced}).`);
71+
}
72+
73+
return { channel: enforcedChannel, targetVersion: enforced };
74+
}
75+
76+
return { channel: enforcedChannel };
77+
}
78+
79+
export function assertPolicyAllowsAuth(policy: EnterprisePolicy | null, action: 'login' | 'adc' | 'set-service-account', scope?: PolicyScope) {
80+
if (!policy) return;
81+
82+
if (action === 'login' && policy.auth?.allowUserLogin === false) {
83+
throw new Error('Interactive gcloud login is disabled by enterprise policy.');
84+
}
85+
if (action === 'adc' && policy.auth?.allowAdcLogin === false) {
86+
throw new Error('ADC login is disabled by enterprise policy.');
87+
}
88+
if (action === 'set-service-account' && policy.auth?.allowServiceAccountKey === false) {
89+
throw new Error('Service account key management is disabled by enterprise policy.');
90+
}
91+
92+
if (action === 'set-service-account' && scope && policy.auth?.allowedScopes && !policy.auth.allowedScopes.includes(scope)) {
93+
throw new Error(`Scope '${scope}' is not allowed by enterprise policy.`);
94+
}
95+
}
96+

src/system/paths.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const SYSTEM_PATHS = {
2525
PROXY_EXE: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'bin', 'cloud-sql-proxy.exe'),
2626
SCRIPTS: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'scripts'),
2727
SECRETS: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'secrets'),
28+
POLICY_FILE: path.join(PROGRAM_DATA, 'CloudSQLCTL', 'policy.json'),
2829
};
2930

3031
export const ENV_VARS = {

tests/policy.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import fs from 'fs-extra';
2+
import os from 'os';
3+
import path from 'path';
4+
import { readPolicy, resolveUpgradePolicy, assertPolicyAllowsAuth } from '../src/core/policy.js';
5+
6+
function tmpFile(name: string) {
7+
return path.join(os.tmpdir(), `cloudsqlctl-${name}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
8+
}
9+
10+
describe('Policy Module', () => {
11+
const originalEnv = process.env.CLOUDSQLCTL_POLICY_PATH;
12+
13+
afterEach(async () => {
14+
if (originalEnv === undefined) {
15+
delete process.env.CLOUDSQLCTL_POLICY_PATH;
16+
} else {
17+
process.env.CLOUDSQLCTL_POLICY_PATH = originalEnv;
18+
}
19+
});
20+
21+
it('returns null if policy does not exist', async () => {
22+
process.env.CLOUDSQLCTL_POLICY_PATH = tmpFile('missing');
23+
const policy = await readPolicy();
24+
expect(policy).toBeNull();
25+
});
26+
27+
it('throws if policy exists but is invalid json', async () => {
28+
const p = tmpFile('invalid');
29+
await fs.writeFile(p, '{not-json', 'utf8');
30+
process.env.CLOUDSQLCTL_POLICY_PATH = p;
31+
await expect(readPolicy()).rejects.toThrow(/Invalid policy\.json/);
32+
await fs.remove(p);
33+
});
34+
35+
it('enforces upgrades disabled', () => {
36+
expect(() => resolveUpgradePolicy({ updates: { enabled: false } }, {})).toThrow(/Updates are disabled/);
37+
});
38+
39+
it('enforces pinned version and channel restrictions', () => {
40+
const policy = { updates: { channel: 'stable', pinnedVersion: '0.4.15' } };
41+
expect(() => resolveUpgradePolicy(policy, { channel: 'beta' })).toThrow(/channel is restricted/i);
42+
expect(() => resolveUpgradePolicy(policy, { pin: '0.4.16' })).toThrow(/Pin\/unpin is managed/i);
43+
expect(() => resolveUpgradePolicy(policy, { version: '0.4.16' })).toThrow(/Target version is restricted/i);
44+
expect(resolveUpgradePolicy(policy, {})).toEqual({ channel: 'stable', targetVersion: '0.4.15' });
45+
expect(resolveUpgradePolicy(policy, { version: 'v0.4.15' })).toEqual({ channel: 'stable', targetVersion: '0.4.15' });
46+
});
47+
48+
it('enforces auth guardrails', () => {
49+
const policy = { auth: { allowUserLogin: false, allowedScopes: ['Machine'] as const } };
50+
expect(() => assertPolicyAllowsAuth(policy, 'login')).toThrow(/disabled/i);
51+
expect(() => assertPolicyAllowsAuth(policy, 'set-service-account', 'User')).toThrow(/not allowed/i);
52+
expect(() => assertPolicyAllowsAuth(policy, 'set-service-account', 'Machine')).not.toThrow();
53+
});
54+
});
55+

0 commit comments

Comments
 (0)