feat(phase-2): workstream 1 — HashiCorp Vault credential storage

Vault is optional — server falls back to bcrypt (Phase 1 behaviour)
when VAULT_ADDR is not set. Full coexistence: existing bcrypt credentials
continue to work until rotated.

Changes:
- src/vault/VaultClient.ts — wraps node-vault KV v2; writeSecret,
  readSecret, verifySecret (constant-time), deleteSecret
- src/db/migrations/005_add_vault_path.sql — vault_path column on credentials
- CredentialRepository — createWithVaultPath, updateVaultPath methods
- CredentialService — routes generate/rotate through Vault when configured;
  bcrypt path unchanged
- OAuth2Service — verifies via Vault when vaultPath set, bcrypt otherwise
- src/app.ts — createVaultClientFromEnv() wired into service layer
- ICredentialRow — vaultPath field added
- docs/devops/environment-variables.md — VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- docs/devops/vault-setup.md — dev quickstart, production config, migration guide
- tests: 33/33 unit tests pass (VaultClient + CredentialService Vault path)
- node-vault + @types/node-vault installed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 15:02:33 +00:00
parent 7593bfe1c1
commit 90a4addb21
14 changed files with 1064 additions and 36 deletions

View File

@@ -7,6 +7,7 @@ import { CredentialService } from '../../../src/services/CredentialService';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { AuditService } from '../../../src/services/AuditService';
import { VaultClient } from '../../../src/vault/VaultClient';
import {
AgentNotFoundError,
CredentialNotFoundError,
@@ -18,10 +19,12 @@ import { IAgent, ICredential, ICredentialRow } from '../../../src/types/index';
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/services/AuditService');
jest.mock('../../../src/vault/VaultClient');
const MockCredentialRepo = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const MockVaultClient = VaultClient as jest.MockedClass<typeof VaultClient>;
const AGENT_ID = uuidv4();
const CREDENTIAL_ID = uuidv4();
@@ -51,6 +54,7 @@ const MOCK_CREDENTIAL: ICredential = {
const MOCK_CREDENTIAL_ROW: ICredentialRow = {
...MOCK_CREDENTIAL,
secretHash: '$2b$10$somehashvalue',
vaultPath: null,
};
const IP = '127.0.0.1';
@@ -205,3 +209,94 @@ describe('CredentialService', () => {
});
});
});
// ─── Vault-path tests ──────────────────────────────────────────────────────
describe('CredentialService — Vault path (Phase 2)', () => {
let service: CredentialService;
let credentialRepo: jest.Mocked<CredentialRepository>;
let agentRepo: jest.Mocked<AgentRepository>;
let auditService: jest.Mocked<AuditService>;
let vaultClient: jest.Mocked<VaultClient>;
const VAULT_PATH = `secret/data/agentidp/agents/${AGENT_ID}/credentials/${CREDENTIAL_ID}`;
const MOCK_VAULT_CREDENTIAL_ROW: ICredentialRow = {
...MOCK_CREDENTIAL,
secretHash: '',
vaultPath: VAULT_PATH,
};
beforeEach(() => {
jest.clearAllMocks();
credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked<CredentialRepository>;
agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
vaultClient = new MockVaultClient('http://localhost:8200', 'token') as jest.Mocked<VaultClient>;
service = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient);
auditService.logEvent.mockResolvedValue({} as never);
});
describe('generateCredential() with Vault', () => {
it('writes secret to Vault and stores the vault_path in the DB', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
vaultClient.writeSecret.mockResolvedValue(VAULT_PATH);
credentialRepo.createWithVaultPath.mockResolvedValue(MOCK_CREDENTIAL);
const result = await service.generateCredential(AGENT_ID, {}, IP, UA);
expect(vaultClient.writeSecret).toHaveBeenCalledWith(
AGENT_ID,
expect.any(String),
expect.any(String),
);
expect(credentialRepo.createWithVaultPath).toHaveBeenCalled();
expect(credentialRepo.create).not.toHaveBeenCalled();
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
});
});
describe('rotateCredential() with Vault', () => {
it('writes new Vault version and updates vault_path in the DB', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW);
vaultClient.writeSecret.mockResolvedValue(VAULT_PATH);
credentialRepo.updateVaultPath.mockResolvedValue(MOCK_CREDENTIAL);
const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA);
expect(vaultClient.writeSecret).toHaveBeenCalledWith(
AGENT_ID,
CREDENTIAL_ID,
expect.any(String),
);
expect(credentialRepo.updateVaultPath).toHaveBeenCalled();
expect(credentialRepo.updateHash).not.toHaveBeenCalled();
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
});
});
describe('revokeCredential() with Vault', () => {
it('revokes DB record and deletes Vault secret', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue(MOCK_VAULT_CREDENTIAL_ROW);
credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
vaultClient.deleteSecret.mockResolvedValue();
await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA);
expect(credentialRepo.revoke).toHaveBeenCalledWith(CREDENTIAL_ID);
expect(vaultClient.deleteSecret).toHaveBeenCalledWith(AGENT_ID, CREDENTIAL_ID);
});
it('does not call Vault delete when credential has no vault_path (bcrypt credential)', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW); // vaultPath: null
credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
await service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA);
expect(vaultClient.deleteSecret).not.toHaveBeenCalled();
});
});
});