Skip to content

Commit 901c298

Browse files
Jorge Vidaurreclaude
andcommitted
feat: squads credentials — per-squad GCP service account management
New command: `squads credentials create|create-all|rotate|list|revoke` - Creates per-squad GCP service accounts with least-privilege IAM roles - Keys stored at ~/.squads/secrets/{squad}-sa-key.json - Execution engine auto-injects GOOGLE_APPLICATION_CREDENTIALS into agent sessions - 10 squads mapped: analytics, customer, data, engineering, finance, growth, intelligence, marketing, operations, product Solves: agents blocked on BQ, Sheets, Search Console, Cloud SQL access because all credentials were founder-personal. Now each squad has its own service account with only the permissions it needs. Usage: squads credentials create-all # provision all squads squads credentials list # show status squads credentials rotate <squad> # rotate key squads credentials revoke <squad> # delete SA Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6eae8c8 commit 901c298

3 files changed

Lines changed: 380 additions & 0 deletions

File tree

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import { registerObservabilityCommands } from './commands/observability.js';
6464
import { registerTierCommand } from './commands/tier.js';
6565
import { registerServicesCommands } from './commands/services.js';
6666
import { registerGoalsCommand } from './commands/goals.js';
67+
import { registerCredentialsCommand } from './commands/credentials.js';
6768

6869
// All other command handlers are lazy-loaded via dynamic import() inside
6970
// action handlers. Only the invoked command's dependencies are loaded,
@@ -1061,6 +1062,7 @@ registerObservabilityCommands(program);
10611062
registerTierCommand(program);
10621063
registerServicesCommands(program);
10631064
registerGoalsCommand(program);
1065+
registerCredentialsCommand(program);
10641066

10651067
// Providers command - show LLM CLI availability for multi-LLM support
10661068
program

src/commands/credentials.ts

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
/**
2+
* squads credentials — manage per-squad GCP service accounts and credentials.
3+
*
4+
* Creates, rotates, lists, and revokes service accounts so agents
5+
* can access the APIs they need without founder intervention.
6+
*/
7+
8+
import { Command } from 'commander';
9+
import { execSync } from 'child_process';
10+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
11+
import { join, basename } from 'path';
12+
import { findSquadsDir } from '../lib/squad-parser.js';
13+
import { colors, bold, RESET, writeLine, icons } from '../lib/terminal.js';
14+
import { homedir } from 'os';
15+
16+
// ── Permission mapping per squad ────────────────────────────────────────
17+
// Each squad gets ONLY the GCP roles it needs. Principle of least privilege.
18+
19+
interface SquadPermissions {
20+
roles: string[];
21+
apis: string[]; // APIs to enable on the project
22+
description: string;
23+
}
24+
25+
const SQUAD_PERMISSIONS: Record<string, SquadPermissions> = {
26+
analytics: {
27+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser'],
28+
apis: ['bigquery.googleapis.com'],
29+
description: 'BQ telemetry read access',
30+
},
31+
data: {
32+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser', 'roles/cloudsql.client'],
33+
apis: ['bigquery.googleapis.com', 'sqladmin.googleapis.com'],
34+
description: 'BQ read + Cloud SQL client',
35+
},
36+
finance: {
37+
roles: ['roles/drive.file', 'roles/sheets.editor'],
38+
apis: ['sheets.googleapis.com', 'drive.googleapis.com'],
39+
description: 'Google Sheets + Drive for financial models',
40+
},
41+
marketing: {
42+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser'],
43+
apis: ['bigquery.googleapis.com', 'searchconsole.googleapis.com'],
44+
description: 'BQ read + Search Console',
45+
},
46+
engineering: {
47+
roles: ['roles/cloudsql.admin', 'roles/run.developer', 'roles/secretmanager.secretAccessor'],
48+
apis: ['sqladmin.googleapis.com', 'run.googleapis.com', 'secretmanager.googleapis.com'],
49+
description: 'Cloud SQL admin + Cloud Run deploy + secrets',
50+
},
51+
customer: {
52+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser'],
53+
apis: ['bigquery.googleapis.com'],
54+
description: 'BQ telemetry for user analysis',
55+
},
56+
intelligence: {
57+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser'],
58+
apis: ['bigquery.googleapis.com'],
59+
description: 'BQ read for intelligence queries',
60+
},
61+
product: {
62+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser'],
63+
apis: ['bigquery.googleapis.com'],
64+
description: 'BQ telemetry for product analytics',
65+
},
66+
growth: {
67+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser'],
68+
apis: ['bigquery.googleapis.com'],
69+
description: 'BQ telemetry for growth metrics',
70+
},
71+
operations: {
72+
roles: ['roles/bigquery.dataViewer', 'roles/bigquery.jobUser', 'roles/monitoring.viewer'],
73+
apis: ['bigquery.googleapis.com', 'monitoring.googleapis.com'],
74+
description: 'BQ read + monitoring for ops health',
75+
},
76+
};
77+
78+
const SECRETS_DIR = join(homedir(), '.squads', 'secrets');
79+
const SA_SUFFIX = '-agent';
80+
81+
function getProject(): string {
82+
try {
83+
return execSync('gcloud config get-value project 2>/dev/null', { encoding: 'utf-8' }).trim();
84+
} catch {
85+
throw new Error('No GCP project configured. Run: gcloud config set project <project-id>');
86+
}
87+
}
88+
89+
function saEmail(squad: string, project: string): string {
90+
return `${squad}${SA_SUFFIX}@${project}.iam.gserviceaccount.com`;
91+
}
92+
93+
function keyPath(squad: string): string {
94+
return join(SECRETS_DIR, `${squad}-sa-key.json`);
95+
}
96+
97+
function ensureSecretsDir(): void {
98+
if (!existsSync(SECRETS_DIR)) {
99+
mkdirSync(SECRETS_DIR, { recursive: true });
100+
}
101+
}
102+
103+
function gcloudExec(cmd: string, silent = false): string {
104+
try {
105+
return execSync(cmd, { encoding: 'utf-8', stdio: silent ? 'pipe' : 'inherit' }).trim();
106+
} catch (e) {
107+
const msg = e instanceof Error ? e.message : String(e);
108+
if (msg.includes('Reauthentication')) {
109+
throw new Error('gcloud auth expired. Run: gcloud auth login');
110+
}
111+
throw e;
112+
}
113+
}
114+
115+
// ── Commands ────────────────────────────────────────────────────────────
116+
117+
async function createCredential(squad: string, opts: { force?: boolean }): Promise<void> {
118+
const project = getProject();
119+
const email = saEmail(squad, project);
120+
const key = keyPath(squad);
121+
const perms = SQUAD_PERMISSIONS[squad];
122+
123+
if (!perms) {
124+
writeLine(` ${icons.error} ${colors.red}No permission mapping for squad "${squad}"${RESET}`);
125+
writeLine(` ${colors.dim}Known squads: ${Object.keys(SQUAD_PERMISSIONS).join(', ')}${RESET}`);
126+
return;
127+
}
128+
129+
if (existsSync(key) && !opts.force) {
130+
writeLine(` ${icons.warning} ${colors.yellow}Credential already exists: ${key}${RESET}`);
131+
writeLine(` ${colors.dim}Use --force to recreate${RESET}`);
132+
return;
133+
}
134+
135+
ensureSecretsDir();
136+
137+
writeLine(` ${bold}Creating service account for ${squad}${RESET}`);
138+
writeLine(` ${colors.dim}${perms.description}${RESET}`);
139+
writeLine();
140+
141+
// 1. Enable required APIs
142+
for (const api of perms.apis) {
143+
writeLine(` ${colors.dim}Enabling ${api}...${RESET}`);
144+
try {
145+
gcloudExec(`gcloud services enable ${api} --project ${project} 2>/dev/null`, true);
146+
} catch { /* already enabled or no permission — continue */ }
147+
}
148+
149+
// 2. Create service account (or skip if exists)
150+
try {
151+
gcloudExec(`gcloud iam service-accounts describe ${email} --project ${project} 2>/dev/null`, true);
152+
writeLine(` ${colors.dim}Service account exists: ${email}${RESET}`);
153+
} catch {
154+
writeLine(` Creating ${email}...`);
155+
gcloudExec(`gcloud iam service-accounts create ${squad}${SA_SUFFIX} --display-name "Squads ${squad} agent" --project ${project}`);
156+
}
157+
158+
// 3. Grant IAM roles
159+
for (const role of perms.roles) {
160+
writeLine(` ${colors.dim}Granting ${role}...${RESET}`);
161+
try {
162+
gcloudExec(
163+
`gcloud projects add-iam-policy-binding ${project} --member="serviceAccount:${email}" --role="${role}" --condition=None --quiet 2>/dev/null`,
164+
true,
165+
);
166+
} catch { /* role may already be bound */ }
167+
}
168+
169+
// 4. Create and download key
170+
if (existsSync(key) && opts.force) {
171+
unlinkSync(key);
172+
}
173+
writeLine(` ${colors.dim}Creating key...${RESET}`);
174+
gcloudExec(`gcloud iam service-accounts keys create ${key} --iam-account=${email} --project ${project}`);
175+
176+
writeLine();
177+
writeLine(` ${icons.success} ${colors.green}${squad}${RESET} credential ready`);
178+
writeLine(` ${colors.dim}Key: ${key}${RESET}`);
179+
writeLine(` ${colors.dim}Roles: ${perms.roles.join(', ')}${RESET}`);
180+
writeLine();
181+
}
182+
183+
async function rotateCredential(squad: string): Promise<void> {
184+
const project = getProject();
185+
const email = saEmail(squad, project);
186+
const key = keyPath(squad);
187+
188+
if (!existsSync(key)) {
189+
writeLine(` ${icons.error} ${colors.red}No credential found for ${squad}. Run: squads credentials create ${squad}${RESET}`);
190+
return;
191+
}
192+
193+
// Read old key to get key ID for deletion
194+
const oldKeyData = JSON.parse(readFileSync(key, 'utf-8'));
195+
const oldKeyId = oldKeyData.private_key_id;
196+
197+
writeLine(` ${bold}Rotating ${squad} credential${RESET}`);
198+
199+
// Create new key first
200+
const tmpKey = key + '.new';
201+
gcloudExec(`gcloud iam service-accounts keys create ${tmpKey} --iam-account=${email} --project ${project}`);
202+
203+
// Replace old key file
204+
unlinkSync(key);
205+
const { renameSync } = await import('fs');
206+
renameSync(tmpKey, key);
207+
208+
// Delete old key from GCP
209+
if (oldKeyId) {
210+
try {
211+
gcloudExec(
212+
`gcloud iam service-accounts keys delete ${oldKeyId} --iam-account=${email} --project ${project} --quiet`,
213+
true,
214+
);
215+
} catch { /* old key may already be expired */ }
216+
}
217+
218+
writeLine(` ${icons.success} ${colors.green}${squad}${RESET} credential rotated`);
219+
writeLine(` ${colors.dim}New key: ${key}${RESET}`);
220+
writeLine();
221+
}
222+
223+
async function listCredentials(): Promise<void> {
224+
ensureSecretsDir();
225+
const squadsDir = findSquadsDir();
226+
const allSquads = Object.keys(SQUAD_PERMISSIONS).sort();
227+
228+
writeLine();
229+
writeLine(` ${bold}Squad Credentials${RESET}`);
230+
writeLine();
231+
writeLine(` ${'Squad'.padEnd(16)} ${'Status'.padEnd(10)} ${'Roles'.padEnd(40)} Key`);
232+
writeLine(` ${'-'.repeat(90)}`);
233+
234+
for (const squad of allSquads) {
235+
const key = keyPath(squad);
236+
const perms = SQUAD_PERMISSIONS[squad];
237+
const hasKey = existsSync(key);
238+
const status = hasKey ? `${colors.green}active${RESET}` : `${colors.dim}none${RESET} `;
239+
const roles = perms.roles.map(r => r.split('/')[1]).join(', ');
240+
241+
writeLine(` ${squad.padEnd(16)} ${status} ${colors.dim}${roles.slice(0, 38).padEnd(40)}${RESET} ${hasKey ? '~/.squads/secrets/' + basename(key) : ''}`);
242+
}
243+
244+
// Show squads without permission mapping
245+
if (squadsDir) {
246+
const dirs = readdirSync(squadsDir).filter(d =>
247+
existsSync(join(squadsDir, d, 'SQUAD.md')) && !SQUAD_PERMISSIONS[d]
248+
);
249+
if (dirs.length > 0) {
250+
writeLine();
251+
writeLine(` ${colors.dim}Squads without permission mapping: ${dirs.join(', ')}${RESET}`);
252+
writeLine(` ${colors.dim}Add to SQUAD_PERMISSIONS in credentials.ts if they need GCP access.${RESET}`);
253+
}
254+
}
255+
256+
writeLine();
257+
}
258+
259+
async function revokeCredential(squad: string): Promise<void> {
260+
const project = getProject();
261+
const email = saEmail(squad, project);
262+
const key = keyPath(squad);
263+
264+
writeLine(` ${bold}Revoking ${squad} credential${RESET}`);
265+
266+
// Delete local key
267+
if (existsSync(key)) {
268+
unlinkSync(key);
269+
writeLine(` ${colors.dim}Deleted local key${RESET}`);
270+
}
271+
272+
// Delete all keys from GCP
273+
try {
274+
const keysJson = gcloudExec(
275+
`gcloud iam service-accounts keys list --iam-account=${email} --project ${project} --format=json 2>/dev/null`,
276+
true,
277+
);
278+
const keys = JSON.parse(keysJson);
279+
for (const k of keys) {
280+
if (k.keyType === 'USER_MANAGED') {
281+
gcloudExec(
282+
`gcloud iam service-accounts keys delete ${k.name.split('/').pop()} --iam-account=${email} --project ${project} --quiet`,
283+
true,
284+
);
285+
}
286+
}
287+
writeLine(` ${colors.dim}Deleted remote keys${RESET}`);
288+
} catch { /* SA may not exist */ }
289+
290+
// Delete service account
291+
try {
292+
gcloudExec(`gcloud iam service-accounts delete ${email} --project ${project} --quiet`);
293+
writeLine(` ${colors.dim}Deleted service account${RESET}`);
294+
} catch { /* already deleted */ }
295+
296+
writeLine(` ${icons.success} ${colors.green}${squad}${RESET} credential revoked`);
297+
writeLine();
298+
}
299+
300+
async function createAll(opts: { force?: boolean }): Promise<void> {
301+
const squads = Object.keys(SQUAD_PERMISSIONS).sort();
302+
writeLine(` ${bold}Creating credentials for ${squads.length} squads${RESET}`);
303+
writeLine();
304+
305+
for (const squad of squads) {
306+
await createCredential(squad, opts);
307+
}
308+
309+
writeLine(` ${bold}Done.${RESET} Run ${colors.cyan}squads credentials list${RESET} to verify.`);
310+
writeLine();
311+
}
312+
313+
// ── Register ────────────────────────────────────────────────────────────
314+
315+
export function registerCredentialsCommand(program: Command): void {
316+
const creds = program
317+
.command('credentials')
318+
.description('Manage per-squad GCP service accounts and credentials');
319+
320+
creds
321+
.command('create <squad>')
322+
.description('Create a service account and key for a squad')
323+
.option('--force', 'Recreate even if credential exists')
324+
.action(async (squad: string, opts) => {
325+
if (squad === '--all') {
326+
await createAll(opts);
327+
} else {
328+
await createCredential(squad, opts);
329+
}
330+
});
331+
332+
creds
333+
.command('create-all')
334+
.description('Create credentials for all squads with permission mappings')
335+
.option('--force', 'Recreate even if credentials exist')
336+
.action(async (opts) => {
337+
await createAll(opts);
338+
});
339+
340+
creds
341+
.command('rotate <squad>')
342+
.description('Rotate a squad credential (create new key, delete old)')
343+
.action(async (squad: string) => {
344+
await rotateCredential(squad);
345+
});
346+
347+
creds
348+
.command('list')
349+
.description('List all squad credentials and their status')
350+
.action(async () => {
351+
await listCredentials();
352+
});
353+
354+
creds
355+
.command('revoke <squad>')
356+
.description('Delete a squad service account and all keys')
357+
.action(async (squad: string) => {
358+
await revokeCredential(squad);
359+
});
360+
}
361+
362+
// ── Helper for execution engine ─────────────────────────────────────────
363+
364+
/**
365+
* Resolve the credential path for a squad. Returns the path to the
366+
* service account key file if it exists, or undefined.
367+
* Used by the execution engine to inject GOOGLE_APPLICATION_CREDENTIALS.
368+
*/
369+
export function resolveSquadCredential(squad: string): string | undefined {
370+
const key = keyPath(squad);
371+
return existsSync(key) ? key : undefined;
372+
}

0 commit comments

Comments
 (0)