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
94 changes: 91 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,91 @@ echo $MY_SECRET
exit
```

### 11. OIDC Setup (CI/CD Trust Policy)
Generate Terraform templates to establish OIDC-based trust between your CI provider and cloud provider. This allows your CI pipelines to authenticate via OpenID Connect instead of long-lived cloud credentials.

```bash
# Interactive wizard
vault oidc

# With environment suffix (generates vault-oidc-aws-prod.tf)
vault oidc --env prod
```

**Supported cloud providers:** AWS, Azure, GCP
**Supported CI providers:** GitHub Actions, GitLab CI

| Cloud | Terraform resources created |
|-------|----------------------------|
| AWS | IAM OIDC provider, trust policy, IAM role, role ARN output |
| Azure | Azure AD application, federated identity credential, object ID output |
| GCP | Workload identity pool, pool provider, service account, IAM binding, `project_id` variable |

After generating the template, apply it with:
```bash
terraform init
terraform apply -var="project_id=$GCP_PROJECT_ID" # GCP only
terraform apply # AWS / Azure
```

### CI Runtime (vault ci)

> **KMS Key:** The OIDC templates configure trust between CI and your cloud provider, but do **not** create the encryption key (KMS key). Create the key manually in your cloud console, encrypt your vault's Group Master Key with it, and pass the ciphertext as an environment variable.

Inside your CI pipeline, `vault ci` uses OIDC to decrypt secrets at runtime. Configure these environment variables in your CI secrets:

**AWS**
```bash
VAULT_AWS_ROLE_ARN=arn:aws:iam::123456789:role/vault-ci-role
VAULT_KMS_CIPHERTEXT=base64-encrypted-kms-ciphertext
```

**Azure**
```bash
VAULT_AZURE_TENANT_ID=your-tenant-id
VAULT_AZURE_CLIENT_ID=your-app-registration-client-id
VAULT_AZURE_KEY_VAULT_URL=https://myvault.vault.azure.net
VAULT_AZURE_KEY_NAME=my-key
VAULT_AZURE_CIPHERTEXT=base64-encrypted-ciphertext
# Optional: override auto-detection
# VAULT_CLOUD_PROVIDER=azure
```
> **GitLab users:** Configure your `.gitlab-ci.yml` `id_tokens` with audience `api://AzureADTokenExchange` for the Azure path.

**GCP**
```bash
VAULT_GCP_PROJECT_NUMBER=123456789
VAULT_GCP_POOL_ID=vault-pool
VAULT_GCP_PROVIDER_ID=github
VAULT_GCP_PROJECT_ID=my-project # defaults to PROJECT_NUMBER
VAULT_GCP_KMS_LOCATION=global # defaults to global
VAULT_GCP_KMS_KEY_RING=my-keyring
VAULT_GCP_KMS_KEY_NAME=my-key
VAULT_GCP_CIPHERTEXT=base64-encrypted-ciphertext
```

**Disambiguation:** If multiple cloud env vars are set, use:
```bash
VAULT_CLOUD_PROVIDER=aws|azure|gcp
```

#### GitHub Actions Workflow Example

```yaml
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- uses: actions/checkout@v4
- run: vault ci npm start
env:
VAULT_AWS_ROLE_ARN: ${{ secrets.VAULT_AWS_ROLE_ARN }}
VAULT_KMS_CIPHERTEXT: ${{ secrets.VAULT_KMS_CIPHERTEXT }}
```

---

## β€Ί Agent Mode (AI Support)
Expand All @@ -149,10 +234,13 @@ Vault is designed for the age of AI. When you run an agent with Vault (e.g., `va

## β€Ί Contributing & Forking

This project is built with a **feature-first modular architecture**. To add a new command:
1. Create `src/features/your-feature/your-feature.ts` for logic.
2. Create `src/features/your-feature/tui.ts` for interactions.
This project is built with a **feature-first modular architecture** and follows TDD (Test-Driven Development). To add a new command:
1. Write failing tests in `test/features/your-feature/`.
2. Create `src/features/your-feature/` for logic, TUI, templates, and providers.
3. Add the route to `src/cli.ts`.
4. Run `npm test` to ensure all tests pass and coverage thresholds are met.

See `src/features/oidc/` for a complete example of a feature with templates, providers, and full test coverage.

---

Expand Down
20 changes: 14 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,22 +141,30 @@ export function runCli() {
program
.command('oidc')
.description('Setup OpenID Connect trust policies for CI/CD environments')
.action(() => {
.option('-f, --force', 'Overwrite existing .tf file without prompting')
.option('-d, --dry-run', 'Preview template without writing to disk')
.action((options) => {
const globalOpts = program.opts();
runOidcCommand({ env: globalOpts.env }).catch(err => {
runOidcCommand({ env: globalOpts.env, force: options.force, dryRun: options.dryRun }).catch(err => {
console.error(err);
process.exit(1);
});
});

program
.command('ci')
.description('Headless execution runner for CI environments utilizing OIDC to decrypt the vault')
.argument('<command...>', 'Command to execute inside CI')
.description(
'Headless CI runner: uses OIDC to authenticate to cloud KMS, decrypts the vault, and executes a command.\n' +
'Set cloud provider via env vars: VAULT_AWS_ROLE_ARN, VAULT_AZURE_TENANT_ID, or VAULT_GCP_PROJECT_NUMBER.\n' +
'Use VAULT_CLOUD_PROVIDER to disambiguate if multiple are set.'
)
.argument('[command...]', 'Command to execute inside CI (omit with --check)')
.option('-c, --check', 'Verify OIDC→KMS chain without executing a command')
.passThroughOptions()
.action((commandArgs: string[]) => {
.action((commandArgs: string[], options) => {
const globalOpts = program.opts();
runCiCommand(commandArgs, { env: globalOpts.env }).catch(err => {
const opts = { env: globalOpts.env, check: options.check };
runCiCommand(commandArgs || [], opts).catch(err => {
console.error(err);
process.exit(1);
});
Expand Down
134 changes: 123 additions & 11 deletions src/features/ci/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,151 @@ import _sodium from 'libsodium-wrappers';
import { getGithubOidcToken } from '../oidc/providers/github.js';
import { getGitlabOidcToken } from '../oidc/providers/gitlab.js';
import { decryptWithAwsKms } from '../oidc/providers/aws.js';
import { CloudKmsProvider } from '../oidc/providers/types.js';
import { AwsKmsProvider } from '../oidc/providers/aws-kms.js';
import { AzureKeyVaultProvider } from '../oidc/providers/azure-keyvault.js';
import { GcpKmsProvider } from '../oidc/providers/gcp-kms.js';
import { decryptLocalVault, LocalVaultPayload } from '../../core/envelope.js';
import { execWithEnv } from '../../core/exec.js';
import { getLocalVaultPath, getLocalVaultFile } from '../../core/vault-file.js';

function detectCiProvider(): 'github' | 'gitlab' {
type CiProvider = 'github' | 'gitlab';
type CloudProvider = 'aws' | 'azure' | 'gcp';

function detectCiProvider(): CiProvider {
if (process.env.GITHUB_ACTIONS) return 'github';
if (process.env.GITLAB_CI) return 'gitlab';
throw new Error('Unrecognized CI environment. Supported: GitHub Actions, GitLab CI.');
}

export async function runCiCommand(commandArgs: string[], options: { env?: string } = {}) {
function detectCloudProvider(): CloudProvider {
const hasAws = !!process.env.VAULT_AWS_ROLE_ARN;
const hasAzure = !!process.env.VAULT_AZURE_TENANT_ID;
const hasGcp = !!process.env.VAULT_GCP_PROJECT_NUMBER;

const explicit = process.env.VAULT_CLOUD_PROVIDER?.toLowerCase();

// If user explicitly set VAULT_CLOUD_PROVIDER, use it (validates required vars later)
if (explicit) {
if (explicit === 'aws') return 'aws';
if (explicit === 'azure') return 'azure';
if (explicit === 'gcp') return 'gcp';
throw new Error(`Invalid VAULT_CLOUD_PROVIDER: "${explicit}". Must be aws, azure, or gcp.`);
}

// If multiple cloud providers have their vars set, require explicit selection
const count = [hasAws, hasAzure, hasGcp].filter(Boolean).length;
if (count > 1) {
throw new Error(
'Multiple cloud provider environment variables detected. ' +
'Set VAULT_CLOUD_PROVIDER=aws|azure|gcp to disambiguate.'
);
}

if (hasAws) return 'aws';
if (hasAzure) return 'azure';
if (hasGcp) return 'gcp';

throw new Error(
'No cloud provider configured. Set VAULT_AWS_ROLE_ARN, VAULT_AZURE_TENANT_ID, or VAULT_GCP_PROJECT_NUMBER.'
);
}

/**
* Returns the OIDC audience the CI provider should use when requesting a JWT.
*/
function getAudience(cloud: CloudProvider): string {
switch (cloud) {
case 'aws': return 'sts.amazonaws.com';
case 'azure': return 'api://AzureADTokenExchange';
case 'gcp': return 'https://iam.googleapis.com';
}
}

/**
* Creates the correct CloudKmsProvider from environment variables.
*/
function createKmsProvider(cloud: CloudProvider): CloudKmsProvider {
switch (cloud) {
case 'aws': {
const roleArn = process.env.VAULT_AWS_ROLE_ARN!;
const ciphertext = process.env.VAULT_KMS_CIPHERTEXT!;
if (!ciphertext) throw new Error('Missing VAULT_KMS_CIPHERTEXT.');
return new AwsKmsProvider(roleArn, ciphertext);
}
case 'azure': {
const tenantId = process.env.VAULT_AZURE_TENANT_ID!;
const clientId = process.env.VAULT_AZURE_CLIENT_ID!;
const keyVaultUrl = process.env.VAULT_AZURE_KEY_VAULT_URL!;
const keyName = process.env.VAULT_AZURE_KEY_NAME!;
const ciphertext = process.env.VAULT_AZURE_CIPHERTEXT!;
if (!clientId) throw new Error('Missing VAULT_AZURE_CLIENT_ID.');
if (!keyVaultUrl) throw new Error('Missing VAULT_AZURE_KEY_VAULT_URL.');
if (!keyName) throw new Error('Missing VAULT_AZURE_KEY_NAME.');
if (!ciphertext) throw new Error('Missing VAULT_AZURE_CIPHERTEXT.');
return new AzureKeyVaultProvider(tenantId, clientId, keyVaultUrl, keyName, ciphertext);
}
case 'gcp': {
const projectNumber = process.env.VAULT_GCP_PROJECT_NUMBER!;
const poolId = process.env.VAULT_GCP_POOL_ID!;
const providerId = process.env.VAULT_GCP_PROVIDER_ID!;
const projectId = process.env.VAULT_GCP_PROJECT_ID || projectNumber;
const location = process.env.VAULT_GCP_KMS_LOCATION || 'global';
const keyRing = process.env.VAULT_GCP_KMS_KEY_RING!;
const cryptoKey = process.env.VAULT_GCP_KMS_KEY_NAME!;
const ciphertext = process.env.VAULT_GCP_CIPHERTEXT!;
if (!poolId) throw new Error('Missing VAULT_GCP_POOL_ID.');
if (!providerId) throw new Error('Missing VAULT_GCP_PROVIDER_ID.');
if (!keyRing) throw new Error('Missing VAULT_GCP_KMS_KEY_RING.');
if (!cryptoKey) throw new Error('Missing VAULT_GCP_KMS_KEY_NAME.');
if (!ciphertext) throw new Error('Missing VAULT_GCP_CIPHERTEXT.');
return new GcpKmsProvider(
projectNumber, poolId, providerId,
projectId, location, keyRing, cryptoKey, ciphertext
);
}
}
}

export async function runCiCommand(commandArgs: string[], options: { env?: string; check?: boolean } = {}) {
// --check mode: verify OIDC→KMS chain works without vault file or command execution
if (options.check) {
const cloudProvider = detectCloudProvider();
const ciProvider = detectCiProvider();

let jwt: string;
if (ciProvider === 'github') {
jwt = await getGithubOidcToken(getAudience(cloudProvider));
} else {
jwt = getGitlabOidcToken();
}

const kmsProvider = createKmsProvider(cloudProvider);
const result = await kmsProvider.decrypt(jwt);
_sodium.memzero(result);
return;
}

const vaultPath = getLocalVaultPath(options.env);
const vaultFile = getLocalVaultFile(options.env);
if (!fs.existsSync(vaultPath)) {
throw new Error(`Environment vault not found: ${vaultFile}`);
}

const roleArn = process.env.VAULT_AWS_ROLE_ARN;
const kmsCiphertext = process.env.VAULT_KMS_CIPHERTEXT;

if (!roleArn || !kmsCiphertext) {
throw new Error('Missing VAULT_AWS_ROLE_ARN or VAULT_KMS_CIPHERTEXT environment variables.');
}

const cloudProvider = detectCloudProvider();
const ciProvider = detectCiProvider();

// Get OIDC JWT from the CI environment
let jwt: string;
if (ciProvider === 'github') {
jwt = await getGithubOidcToken('sts.amazonaws.com');
jwt = await getGithubOidcToken(getAudience(cloudProvider));
} else {
jwt = getGitlabOidcToken();
}

const gmk = await decryptWithAwsKms(jwt, roleArn, kmsCiphertext);
// Decrypt the Group Master Key using the cloud provider's KMS
const kmsProvider = createKmsProvider(cloudProvider);
const gmk = await kmsProvider.decrypt(jwt);

await _sodium.ready;

Expand Down
39 changes: 34 additions & 5 deletions src/features/oidc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import * as p from '@clack/prompts';
import { runTui } from './tui.js';
import { generateAwsTemplate } from './templates/aws.js';
import { generateAzureTemplate } from './templates/azure.js';
import { generateGcpTemplate } from './templates/gcp.js';

export async function runOidcCommand(options: { env?: string } = {}): Promise<void> {
export async function runOidcCommand(options: { env?: string; force?: boolean; dryRun?: boolean } = {}): Promise<void> {
p.intro('Vault OIDC Setup');

const answers = await runTui(options.env);
Expand All @@ -15,16 +16,44 @@ export async function runOidcCommand(options: { env?: string } = {}): Promise<vo
templateStr = generateAwsTemplate(answers.ciProvider, answers.repo, answers.branch, answers.environment);
} else if (answers.cloudProvider === 'azure') {
templateStr = generateAzureTemplate(answers.ciProvider, answers.repo, answers.branch, answers.environment);
} else if (answers.cloudProvider === 'gcp') {
templateStr = generateGcpTemplate(answers.ciProvider, answers.repo, answers.branch, answers.environment);
} else {
p.cancel(`Cloud provider ${answers.cloudProvider} is not fully supported yet.`);
process.exit(1);
return;
throw new Error(`Unsupported cloud provider: ${answers.cloudProvider}. Supported: aws, azure, gcp.`);
}

const filename = `vault-oidc-${answers.cloudProvider}${options.env ? `-${options.env}` : ''}.tf`;
const outPath = path.join(process.cwd(), filename);

await fs.writeFile(outPath, templateStr.trim() + '\n', 'utf-8');
// Check if file exists and prompt before overwriting (skip for dry run)
if (!options.dryRun) {
try {
await fs.access(outPath);
if (!options.force) {
const overwrite = await p.confirm({
message: `${filename} already exists. Overwrite?`
});
if (!overwrite || p.isCancel(overwrite)) {
p.outro('Aborted β€” file was not overwritten.');
return;
}
}
} catch {
// File does not exist, proceed
}
}

if (options.dryRun) {
console.log(templateStr.trim());
p.outro(`Dry run complete. No file was written.`);
return;
}

try {
await fs.writeFile(outPath, templateStr.trim() + '\n', 'utf-8');
} catch (err) {
throw new Error(`Failed to write ${filename}: ${(err as Error).message}`);
}

p.outro(`Success! ${filename} generated.\nNext steps: run 'terraform init' and 'terraform apply' to provision your trust policy.`);
}
19 changes: 19 additions & 0 deletions src/features/oidc/providers/aws-kms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CloudKmsProvider } from './types.js';
import { decryptWithAwsKms } from './aws.js';

export class AwsKmsProvider implements CloudKmsProvider {
readonly name = 'aws';

/**
* @param roleArn - The ARN of the IAM role to assume (VAULT_AWS_ROLE_ARN)
* @param ciphertextBase64 - The base64-encoded KMS ciphertext (VAULT_KMS_CIPHERTEXT)
*/
constructor(
private readonly roleArn: string,
private readonly ciphertextBase64: string
) {}

async decrypt(jwt: string): Promise<Uint8Array> {
return decryptWithAwsKms(jwt, this.roleArn, this.ciphertextBase64);
}
}
Loading
Loading