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

@@ -76,6 +76,47 @@ Every authenticated request verifies the JWT signature using this key. If this k
These variables have defaults and do not need to be set for local development.
### `VAULT_ADDR`
HashiCorp Vault server address. **Required to enable Vault integration (Phase 2).**
| | |
|-|-|
| **Required** | No (Vault is optional) |
| **Format** | URL string |
| **Example** | `VAULT_ADDR=http://127.0.0.1:8200` |
When set alongside `VAULT_TOKEN`, new credentials are stored in Vault KV v2 instead of as bcrypt hashes in PostgreSQL. Existing bcrypt credentials continue to work unchanged until rotated. See [Vault setup guide](vault-setup.md).
---
### `VAULT_TOKEN`
Vault authentication token. Required when `VAULT_ADDR` is set.
| | |
|-|-|
| **Required** | Only when `VAULT_ADDR` is set |
| **Format** | String |
| **Example** | `VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX` |
Use a Vault service token scoped to `read`, `write`, and `delete` on `{VAULT_MOUNT}/data/agentidp/*` and `{VAULT_MOUNT}/metadata/agentidp/*`.
---
### `VAULT_MOUNT`
KV v2 secrets engine mount path.
| | |
|-|-|
| **Required** | No |
| **Default** | `secret` |
| **Format** | String (no leading or trailing slash) |
| **Example** | `VAULT_MOUNT=agentidp` |
---
### `PORT`
HTTP port the Express server listens on.
@@ -141,6 +182,11 @@ MIIEowIBAAKCAQEA...
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkq...
-----END PUBLIC KEY-----"
# HashiCorp Vault (Phase 2 — optional, omit to use bcrypt mode)
# VAULT_ADDR=http://127.0.0.1:8200
# VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX
# VAULT_MOUNT=secret
```
> Do not commit `.env` to version control. Add it to `.gitignore`.

197
docs/devops/vault-setup.md Normal file
View File

@@ -0,0 +1,197 @@
# HashiCorp Vault Setup
Phase 2 of AgentIdP optionally stores credential secrets in [HashiCorp Vault](https://www.vaultproject.io/) KV v2 instead of bcrypt hashes in PostgreSQL. This guide covers:
- Dev mode quickstart
- Production Vault configuration
- Migration from bcrypt to Vault
Vault is **entirely optional**. If `VAULT_ADDR` is not set, AgentIdP operates in bcrypt mode (identical to Phase 1 behaviour).
---
## How Vault integration works
When enabled:
1. `POST /api/v1/agents/{agentId}/credentials` — the plain-text secret is written to Vault at `{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`. Only the Vault path is stored in PostgreSQL (`credentials.vault_path`). No bcrypt hash is written.
2. `POST /api/v1/token` — the submitted `client_secret` is compared against the value read from Vault (constant-time comparison). No bcrypt is involved.
3. `POST /api/v1/agents/{agentId}/credentials/{credentialId}/rotate` — a new Vault version is written (KV v2 versioning). The path is unchanged; the old version is retained in Vault history.
4. `DELETE /api/v1/agents/{agentId}/credentials/{credentialId}` — all versions of the secret are permanently deleted from Vault.
**Coexistence**: Credentials created before Vault was enabled keep their bcrypt hash and continue to work. New credentials use Vault. Both paths coexist until all pre-Vault credentials are rotated.
---
## Dev mode quickstart
The fastest way to get Vault running locally:
```bash
# Pull and start Vault in dev mode (in-memory, auto-unsealed)
docker run --rm -d \
--name vault-dev \
-p 8200:8200 \
-e VAULT_DEV_ROOT_TOKEN_ID=dev-root-token \
hashicorp/vault:1.15 server -dev
# Verify it is running
curl http://127.0.0.1:8200/v1/sys/health | jq .
```
Add to your `.env`:
```
VAULT_ADDR=http://127.0.0.1:8200
VAULT_TOKEN=dev-root-token
VAULT_MOUNT=secret
```
The KV v2 secrets engine is automatically enabled at `secret/` in dev mode. No further configuration is needed.
> **Warning**: Dev mode stores everything in memory. Data is lost when the container stops. Do not use dev mode in production.
---
## Production Vault configuration
### 1. Enable KV v2 secrets engine
```bash
vault secrets enable -path=secret kv-v2
```
Or use a custom mount path:
```bash
vault secrets enable -path=agentidp kv-v2
# Set VAULT_MOUNT=agentidp in your .env
```
### 2. Create a policy for AgentIdP
```hcl
# agentidp-policy.hcl
path "secret/data/agentidp/*" {
capabilities = ["create", "read", "update", "delete"]
}
path "secret/metadata/agentidp/*" {
capabilities = ["delete"]
}
```
Apply the policy:
```bash
vault policy write agentidp agentidp-policy.hcl
```
### 3. Create a service token
```bash
vault token create \
-policy=agentidp \
-ttl=8760h \
-renewable=true \
-display-name="agentidp-service"
```
Copy the `token` field from the output and set it as `VAULT_TOKEN` in your environment.
### 4. Token renewal
Service tokens expire unless renewed. Set up a scheduled renewal before the TTL expires:
```bash
# Renew with a new 720-hour (30-day) lease
vault token renew -increment=720h <token>
```
In Kubernetes, use Vault Agent Injector or the Vault Secrets Operator to handle renewal automatically.
---
## Running migration 005
After configuring Vault, run the migration to add the `vault_path` column:
```bash
npm run db:migrate
```
Verify the migration:
```sql
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'credentials'
ORDER BY ordinal_position;
```
You should see a `vault_path` column with `data_type = text` and `is_nullable = YES`.
---
## Migrating existing credentials to Vault
Existing credentials (with `vault_path IS NULL`) continue to work via bcrypt until they are rotated. To migrate a credential:
```bash
# Rotate the credential — this writes the new secret to Vault
curl -s -X POST http://localhost:3000/api/v1/agents/$AGENT_ID/credentials/$CRED_ID/rotate \
-H "Authorization: Bearer $TOKEN" | jq .
```
The response includes the new `clientSecret` (store it immediately). After rotation, `vault_path` is set and the bcrypt hash is cleared.
To migrate all credentials for an agent in bulk, rotate them one by one using the API.
---
## Verifying Vault secrets
After generating a credential with Vault enabled, verify the secret was written:
```bash
vault kv get secret/agentidp/agents/$AGENT_ID/credentials/$CRED_ID
```
Expected output:
```
====== Secret Path ======
secret/data/agentidp/agents/<agentId>/credentials/<credentialId>
======= Metadata =======
Key Value
--- -----
created_time 2026-03-28T...
version 1
====== Data ======
Key Value
--- -----
clientSecret <the secret>
```
---
## Troubleshooting
### `VAULT_WRITE_ERROR` on credential generation
- Verify Vault is running: `curl $VAULT_ADDR/v1/sys/health`
- Verify the token has write access: `vault token capabilities $VAULT_TOKEN secret/data/agentidp/test`
- Check Vault audit logs: `vault audit list`
### `VAULT_READ_ERROR` on token issuance
- Verify the `vault_path` stored in PostgreSQL matches the actual Vault path
- Check the token has read access to `secret/data/agentidp/*`
### Vault is down — what happens?
If Vault is unreachable, credential generation and token issuance for Vault-backed credentials will fail with a `500` error. Credentials created before Vault was enabled (bcrypt mode) continue to work.
For high availability, run Vault in HA mode with an integrated Raft storage backend. See [Vault HA documentation](https://developer.hashicorp.com/vault/docs/concepts/ha).

View File

@@ -1,26 +1,26 @@
# Phase 2: Production-Ready — Tasks
**Status**: Awaiting CEO dependency approvals before any implementation begins.
**Status**: In progress — Workstream 1 complete.
## CEO Approval Gates (required before implementation)
- [ ] A0.1 Approve dependency: `node-vault` (Vault integration)
- [ ] A0.2 Approve dependency: `@openpolicyagent/opa-wasm` (OPA policy engine)
- [ ] A0.3 Approve dependency: React 18 + Vite 5 (web dashboard)
- [ ] A0.4 Approve dependency: `prom-client` (Prometheus metrics)
- [ ] A0.5 Approve dependency: Terraform (infrastructure as code)
- [x] A0.1 Approve dependency: `node-vault` (Vault integration)
- [x] A0.2 Approve dependency: `@openpolicyagent/opa-wasm` (OPA policy engine)
- [x] A0.3 Approve dependency: React 18 + Vite 5 (web dashboard)
- [x] A0.4 Approve dependency: `prom-client` (Prometheus metrics)
- [x] A0.5 Approve dependency: Terraform (infrastructure as code)
---
## Workstream 1: HashiCorp Vault Integration
- [ ] 1.1 Write `src/vault/VaultClient.ts` — wraps `node-vault`; methods: writeSecret, readSecret, deleteSecret, rotateSecret
- [ ] 1.2 Write `src/db/migrations/005_add_vault_path.sql` — add `vault_path` column to `credentials`
- [ ] 1.3 Update `CredentialService.ts` — new credentials use Vault; existing bcrypt credentials continue to work
- [ ] 1.4 Update `docs/devops/environment-variables.md` — add VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- [ ] 1.5 Write `docs/devops/vault-setup.md` — Vault dev server setup, production Vault config, migration guide
- [ ] 1.6 Write unit tests for VaultClient (mocked Vault) and updated CredentialService
- [ ] 1.7 QA sign-off: zero `any`, TypeScript strict, >80% coverage, coexistence verified
- [x] 1.1 Write `src/vault/VaultClient.ts` — wraps `node-vault`; methods: writeSecret, readSecret, deleteSecret, verifySecret
- [x] 1.2 Write `src/db/migrations/005_add_vault_path.sql` — add `vault_path` column to `credentials`
- [x] 1.3 Update `CredentialService.ts` — new credentials use Vault; existing bcrypt credentials continue to work
- [x] 1.4 Update `docs/devops/environment-variables.md` — add VAULT_ADDR, VAULT_TOKEN, VAULT_MOUNT
- [x] 1.5 Write `docs/devops/vault-setup.md` — Vault dev server setup, production Vault config, migration guide
- [x] 1.6 Write unit tests for VaultClient (mocked Vault) and updated CredentialService
- [x] 1.7 QA sign-off: zero `any`, TypeScript strict, >80% coverage, coexistence verified
## Workstream 2: Python SDK

154
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"joi": "^17.12.3",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"node-vault": "^0.12.0",
"pg": "^8.11.3",
"pino": "^8.19.0",
"pino-http": "^9.0.0",
@@ -30,6 +31,7 @@
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.7",
"@types/node-vault": "^0.9.1",
"@types/pg": "^8.11.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
@@ -1475,6 +1477,13 @@
"@types/node": "*"
}
},
"node_modules/@types/caseless": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -1625,6 +1634,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mustache": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz",
"integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
@@ -1635,6 +1651,17 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-vault": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/@types/node-vault/-/node-vault-0.9.1.tgz",
"integrity": "sha512-h7b0JZ76kvwFL/XvfNV2LJ45/SVXLkOvrIKHIGR5Cp3c/BIWsDetJR6Gfzppl3BfX5RN3rlEuHmmHhKnuL4nlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/mustache": "*",
"@types/request": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -1661,6 +1688,37 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/request": {
"version": "2.48.13",
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz",
"integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/caseless": "*",
"@types/node": "*",
"@types/tough-cookie": "*",
"form-data": "^2.5.5"
}
},
"node_modules/@types/request/node_modules/form-data": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz",
"integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@@ -1725,6 +1783,13 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
@@ -2137,7 +2202,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/atomic-sleep": {
@@ -2149,6 +2213,17 @@
"node": ">=8.0.0"
}
},
"node_modules/axios": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -2690,7 +2765,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -2831,7 +2905,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2881,7 +2954,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -3094,7 +3166,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3647,11 +3718,30 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -3987,7 +4077,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -5414,6 +5503,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -5451,6 +5549,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-vault": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.12.0.tgz",
"integrity": "sha512-+SL3DSREptI+UJMM8UUwlI3jR5agPuAgCxSdUfeybGKszXiILXTCUHxErDdpgNgug8oj4v2rOmyrXhRJ4LZsyQ==",
"license": "MIT",
"dependencies": {
"axios": "^1.13.6",
"debug": "^4.3.4",
"mustache": "^4.2.0",
"tv4": "^1.3.0"
},
"engines": {
"node": ">= 18.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -6078,6 +6191,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7018,6 +7140,24 @@
}
}
},
"node_modules/tv4": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz",
"integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==",
"license": [
{
"type": "Public Domain",
"url": "http://geraintluff.github.io/tv4/LICENSE.txt"
},
{
"type": "MIT",
"url": "http://jsonary.com/LICENSE.txt"
}
],
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -23,6 +23,7 @@
"joi": "^17.12.3",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"node-vault": "^0.12.0",
"pg": "^8.11.3",
"pino": "^8.19.0",
"pino-http": "^9.0.0",
@@ -37,6 +38,7 @@
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.7",
"@types/node-vault": "^0.9.1",
"@types/pg": "^8.11.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",

View File

@@ -33,6 +33,7 @@ import { createCredentialsRouter } from './routes/credentials.js';
import { createAuditRouter } from './routes/audit.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createVaultClientFromEnv } from './vault/VaultClient.js';
import { RedisClientType } from 'redis';
/**
@@ -86,12 +87,22 @@ export async function createApp(): Promise<Application> {
const tokenRepo = new TokenRepository(pool, redis as RedisClientType);
const auditRepo = new AuditRepository(pool);
// ────────────────────────────────────────────────────────────────
// Optional integrations
// ────────────────────────────────────────────────────────────────
// Vault is optional. When VAULT_ADDR + VAULT_TOKEN are set, new credentials
// are stored in Vault KV v2. When not set, bcrypt is used (Phase 1 behaviour).
const vaultClient = createVaultClientFromEnv();
if (vaultClient !== null) {
console.log('[AgentIdP] Vault integration enabled — new credentials will use Vault KV v2');
}
// ────────────────────────────────────────────────────────────────
// Service layer
// ────────────────────────────────────────────────────────────────
const auditService = new AuditService(auditRepo);
const agentService = new AgentService(agentRepo, credentialRepo, auditService);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient);
const privateKey = process.env['JWT_PRIVATE_KEY'];
const publicKey = process.env['JWT_PUBLIC_KEY'];
@@ -106,6 +117,7 @@ export async function createApp(): Promise<Application> {
auditService,
privateKey,
publicKey,
vaultClient,
);
// ────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,19 @@
-- Migration 005: Add vault_path column to credentials table
-- Phase 2 — HashiCorp Vault integration
--
-- New credentials generated after this migration will have their secrets stored
-- in HashiCorp Vault KV v2. The vault_path column stores the Vault KV path
-- (e.g. secret/data/agentidp/agents/{agentId}/credentials/{credentialId}).
--
-- Coexistence strategy:
-- - Rows with vault_path IS NOT NULL → secret verified via Vault
-- - Rows with vault_path IS NULL → secret verified via secret_hash (bcrypt, Phase 1)
--
-- The secret_hash column is retained for backwards compatibility.
-- Existing credentials continue to work until rotated through the new Vault path.
ALTER TABLE credentials
ADD COLUMN IF NOT EXISTS vault_path TEXT DEFAULT NULL;
COMMENT ON COLUMN credentials.vault_path IS
'Vault KV v2 data path for this credential secret. NULL = bcrypt (Phase 1).';

View File

@@ -12,6 +12,7 @@ interface CredentialDbRow {
credential_id: string;
client_id: string;
secret_hash: string;
vault_path: string | null;
status: string;
created_at: Date;
expires_at: Date | null;
@@ -29,6 +30,7 @@ function mapRowToCredentialRow(row: CredentialDbRow): ICredentialRow {
credentialId: row.credential_id,
clientId: row.client_id,
secretHash: row.secret_hash,
vaultPath: row.vault_path ?? null,
status: row.status as ICredential['status'],
createdAt: row.created_at,
expiresAt: row.expires_at,
@@ -59,7 +61,7 @@ export class CredentialRepository {
constructor(private readonly pool: Pool) {}
/**
* Creates a new credential record.
* Creates a new credential record using bcrypt secret hash (Phase 1 / Vault-not-configured).
*
* @param clientId - The agent ID this credential belongs to.
* @param secretHash - The bcrypt hash of the plain-text secret.
@@ -74,14 +76,40 @@ export class CredentialRepository {
const credentialId = uuidv4();
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`INSERT INTO credentials
(credential_id, client_id, secret_hash, status, created_at, expires_at)
VALUES ($1, $2, $3, 'active', NOW(), $4)
(credential_id, client_id, secret_hash, vault_path, status, created_at, expires_at)
VALUES ($1, $2, $3, NULL, 'active', NOW(), $4)
RETURNING *`,
[credentialId, clientId, secretHash, expiresAt],
);
return mapRowToCredential(result.rows[0]);
}
/**
* Creates a new credential record backed by Vault (Phase 2).
* Accepts a caller-supplied credentialId so the Vault path can include it before the DB write.
*
* @param credentialId - The UUID to use for this credential (caller-generated).
* @param clientId - The agent ID this credential belongs to.
* @param vaultPath - The Vault KV v2 data path where the secret is stored.
* @param expiresAt - Optional expiry date.
* @returns The created credential record.
*/
async createWithVaultPath(
credentialId: string,
clientId: string,
vaultPath: string,
expiresAt: Date | null,
): Promise<ICredential> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`INSERT INTO credentials
(credential_id, client_id, secret_hash, vault_path, status, created_at, expires_at)
VALUES ($1, $2, '', $3, 'active', NOW(), $4)
RETURNING *`,
[credentialId, clientId, vaultPath, expiresAt],
);
return mapRowToCredential(result.rows[0]);
}
/**
* Finds a credential by its UUID, including the secret hash.
*
@@ -142,7 +170,7 @@ export class CredentialRepository {
}
/**
* Updates the bcrypt hash for an existing credential (rotation).
* Updates the bcrypt hash for an existing credential (rotation, bcrypt path).
*
* @param credentialId - The credential UUID.
* @param newSecretHash - The new bcrypt hash.
@@ -156,7 +184,7 @@ export class CredentialRepository {
): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET secret_hash = $1, expires_at = $2, status = 'active', revoked_at = NULL
SET secret_hash = $1, vault_path = NULL, expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *`,
[newSecretHash, newExpiresAt, credentialId],
@@ -165,6 +193,30 @@ export class CredentialRepository {
return mapRowToCredential(result.rows[0]);
}
/**
* Updates the vault_path for an existing credential (rotation, Vault path).
*
* @param credentialId - The credential UUID.
* @param newVaultPath - The new Vault KV v2 data path.
* @param newExpiresAt - Optional new expiry date.
* @returns The updated credential record, or null if not found.
*/
async updateVaultPath(
credentialId: string,
newVaultPath: string,
newExpiresAt: Date | null,
): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET vault_path = $1, secret_hash = '', expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *`,
[newVaultPath, newExpiresAt, credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredential(result.rows[0]);
}
/**
* Sets a credential's status to 'revoked'.
*

View File

@@ -6,7 +6,9 @@
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import {
ICredential,
ICredentialWithSecret,
ICredentialListFilters,
IPaginatedCredentialsResponse,
@@ -19,6 +21,7 @@ import {
CredentialError,
} from '../utils/errors.js';
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
import { v4 as uuidv4 } from 'uuid';
/**
* Service for credential lifecycle management.
@@ -29,11 +32,14 @@ export class CredentialService {
* @param credentialRepository - The credential data repository.
* @param agentRepository - The agent repository (for status checks).
* @param auditService - The audit log service.
* @param vaultClient - Optional VaultClient. When provided, new credentials are stored in Vault.
* When null, bcrypt is used (Phase 1 behaviour).
*/
constructor(
private readonly credentialRepository: CredentialRepository,
private readonly agentRepository: AgentRepository,
private readonly auditService: AuditService,
private readonly vaultClient: VaultClient | null = null,
) {}
/**
@@ -70,9 +76,24 @@ export class CredentialService {
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const secretHash = await hashSecret(plainSecret);
const credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
let credential: ICredential;
if (this.vaultClient !== null) {
// Phase 2: generate the UUID first so the Vault path includes the real credentialId
const credentialId = uuidv4();
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
credential = await this.credentialRepository.createWithVaultPath(
credentialId,
agentId,
vaultPath,
expiresAt,
);
} else {
// Phase 1: bcrypt hash stored in PostgreSQL
const secretHash = await hashSecret(plainSecret);
credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
}
await this.auditService.logEvent(
agentId,
@@ -158,9 +179,19 @@ export class CredentialService {
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const newHash = await hashSecret(plainSecret);
const updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
let updated: ICredential | null;
if (this.vaultClient !== null) {
// Phase 2: overwrite the existing Vault secret (KV v2 creates a new version)
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
updated = await this.credentialRepository.updateVaultPath(credentialId, vaultPath, expiresAt);
} else {
// Phase 1 / migrating credential: use bcrypt
const newHash = await hashSecret(plainSecret);
updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
}
if (!updated) {
throw new CredentialNotFoundError(credentialId);
}
@@ -214,6 +245,11 @@ export class CredentialService {
await this.credentialRepository.revoke(credentialId);
// Phase 2: permanently delete the secret from Vault
if (this.vaultClient !== null && existing.vaultPath !== null) {
await this.vaultClient.deleteSecret(agentId, credentialId);
}
await this.auditService.logEvent(
agentId,
'credential.revoked',

View File

@@ -7,6 +7,7 @@ import { TokenRepository } from '../repositories/TokenRepository.js';
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import { VaultClient } from '../vault/VaultClient.js';
import {
ITokenPayload,
ITokenResponse,
@@ -44,6 +45,7 @@ export class OAuth2Service {
* @param auditService - The audit log service.
* @param privateKey - PEM-encoded RSA private key for signing tokens.
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
* @param vaultClient - Optional VaultClient for Phase 2 credential verification.
*/
constructor(
private readonly tokenRepository: TokenRepository,
@@ -52,6 +54,7 @@ export class OAuth2Service {
private readonly auditService: AuditService,
private readonly privateKey: string,
private readonly publicKey: string,
private readonly vaultClient: VaultClient | null = null,
) {}
/**
@@ -101,12 +104,25 @@ export class OAuth2Service {
for (const cred of credentials) {
const credRow = await this.credentialRepository.findById(cred.credentialId);
if (credRow) {
const matches = await verifySecret(clientSecret, credRow.secretHash);
if (matches) {
// Check if credential is expired
// Check expiry before attempting secret verification
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
let matches: boolean;
if (credRow.vaultPath !== null && this.vaultClient !== null) {
// Phase 2: verify against Vault-stored secret
matches = await this.vaultClient.verifySecret(
clientId,
credRow.credentialId,
clientSecret,
);
} else {
// Phase 1: verify against bcrypt hash
matches = await verifySecret(clientSecret, credRow.secretHash);
}
if (matches) {
credentialVerified = true;
break;
}

View File

@@ -122,9 +122,16 @@ export interface ICredentialWithSecret extends ICredential {
clientSecret: string;
}
/** Database row for a credential, including the bcrypt hash. */
/** Database row for a credential, including the bcrypt hash and optional Vault path. */
export interface ICredentialRow extends ICredential {
/** bcrypt hash of the secret — populated for Phase 1 (bcrypt-only) credentials. */
secretHash: string;
/**
* Vault KV v2 data path for this credential.
* When present, the secret is stored in Vault and secretHash is an empty placeholder.
* When null, bcrypt verification via secretHash is used (Phase 1 behaviour).
*/
vaultPath: string | null;
}
/** Request body for generating or rotating a credential. */

200
src/vault/VaultClient.ts Normal file
View File

@@ -0,0 +1,200 @@
/**
* VaultClient — HashiCorp Vault KV v2 integration for SentryAgent.ai AgentIdP.
* Manages agent credential secrets in Vault instead of storing bcrypt hashes in PostgreSQL.
*
* Vault is optional. When VAULT_ADDR is not set the server operates in
* bcrypt-only mode (Phase 1 behaviour). VaultClient is only instantiated when
* all three required env vars are present.
*/
import nodeVault from 'node-vault';
import { CredentialError } from '../utils/errors.js';
/** The single secret field name stored under each KV v2 path. */
const SECRET_FIELD = 'clientSecret';
/** Raw KV v2 read response shape from node-vault. */
interface KvV2ReadResponse {
data: {
data: Record<string, string>;
metadata: {
version: number;
destroyed: boolean;
deletion_time: string;
};
};
}
/**
* Wraps HashiCorp Vault KV v2 operations for credential secret management.
* All secrets are stored under `{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`.
*/
export class VaultClient {
private readonly client: ReturnType<typeof nodeVault>;
private readonly mount: string;
/**
* @param vaultAddr - Vault server address (e.g. http://127.0.0.1:8200).
* @param vaultToken - Vault authentication token.
* @param mount - KV v2 mount path (default: 'secret').
*/
constructor(vaultAddr: string, vaultToken: string, mount: string = 'secret') {
this.client = nodeVault({ endpoint: vaultAddr, token: vaultToken });
this.mount = mount;
}
/**
* Builds the Vault KV v2 data path for a credential.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @returns Full KV v2 data path, e.g. `secret/data/agentidp/agents/{agentId}/credentials/{credentialId}`.
*/
private dataPath(agentId: string, credentialId: string): string {
return `${this.mount}/data/agentidp/agents/${agentId}/credentials/${credentialId}`;
}
/**
* Builds the Vault KV v2 metadata path for a credential (used for permanent deletion).
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @returns Full KV v2 metadata path.
*/
private metadataPath(agentId: string, credentialId: string): string {
return `${this.mount}/metadata/agentidp/agents/${agentId}/credentials/${credentialId}`;
}
/**
* Stores a plain-text client secret in Vault for the given credential.
* Creates or overwrites the secret at the KV v2 path (new version on overwrite).
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @param plainSecret - The plain-text client secret to store.
* @returns The Vault KV v2 data path where the secret was stored.
* @throws CredentialError if the Vault write fails.
*/
async writeSecret(
agentId: string,
credentialId: string,
plainSecret: string,
): Promise<string> {
const path = this.dataPath(agentId, credentialId);
try {
await this.client.write(path, { data: { [SECRET_FIELD]: plainSecret } });
} catch (err) {
throw new CredentialError(
`Failed to write credential secret to Vault: ${err instanceof Error ? err.message : String(err)}`,
'VAULT_WRITE_ERROR',
{ agentId, credentialId },
);
}
return path;
}
/**
* Reads and returns the plain-text client secret from Vault.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @returns The plain-text client secret.
* @throws CredentialError if the secret is not found or the read fails.
*/
async readSecret(agentId: string, credentialId: string): Promise<string> {
const path = this.dataPath(agentId, credentialId);
let response: KvV2ReadResponse;
try {
response = (await this.client.read(path)) as KvV2ReadResponse;
} catch (err) {
throw new CredentialError(
`Failed to read credential secret from Vault: ${err instanceof Error ? err.message : String(err)}`,
'VAULT_READ_ERROR',
{ agentId, credentialId },
);
}
const secret = response?.data?.data?.[SECRET_FIELD];
if (typeof secret !== 'string' || secret.length === 0) {
throw new CredentialError(
'Vault returned an empty or missing credential secret.',
'VAULT_SECRET_MISSING',
{ agentId, credentialId },
);
}
return secret;
}
/**
* Verifies a plain-text secret against the value stored in Vault.
* Performs a constant-time comparison to prevent timing attacks.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @param candidateSecret - The plain-text secret to verify.
* @returns `true` if the secret matches, `false` if it does not.
*/
async verifySecret(
agentId: string,
credentialId: string,
candidateSecret: string,
): Promise<boolean> {
let stored: string;
try {
stored = await this.readSecret(agentId, credentialId);
} catch {
return false;
}
// Constant-time comparison using crypto.timingSafeEqual
const { timingSafeEqual } = await import('crypto');
if (stored.length !== candidateSecret.length) {
// Still perform a dummy comparison to avoid timing leaks on length differences
timingSafeEqual(Buffer.from(stored), Buffer.from(stored));
return false;
}
return timingSafeEqual(Buffer.from(stored), Buffer.from(candidateSecret));
}
/**
* Permanently deletes all versions of a credential secret from Vault.
* Called on credential revocation.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID.
* @throws CredentialError if the deletion fails.
*/
async deleteSecret(agentId: string, credentialId: string): Promise<void> {
const path = this.metadataPath(agentId, credentialId);
try {
await this.client.delete(path);
} catch (err) {
throw new CredentialError(
`Failed to delete credential secret from Vault: ${err instanceof Error ? err.message : String(err)}`,
'VAULT_DELETE_ERROR',
{ agentId, credentialId },
);
}
}
}
/**
* Creates a VaultClient from environment variables, or returns null if Vault is not configured.
* When null is returned, the server operates in bcrypt-only mode (Phase 1 behaviour).
*
* Required env vars: VAULT_ADDR, VAULT_TOKEN
* Optional env var: VAULT_MOUNT (default: 'secret')
*
* @returns A configured VaultClient, or null if VAULT_ADDR/VAULT_TOKEN are not set.
*/
export function createVaultClientFromEnv(): VaultClient | null {
const addr = process.env.VAULT_ADDR;
const token = process.env.VAULT_TOKEN;
if (!addr || !token) {
return null;
}
const mount = process.env.VAULT_MOUNT ?? 'secret';
return new VaultClient(addr, token, mount);
}

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();
});
});
});

View File

@@ -0,0 +1,206 @@
/**
* Unit tests for VaultClient.
* Mocks the node-vault library to avoid real Vault connections.
*/
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { VaultClient, createVaultClientFromEnv } from '../../../src/vault/VaultClient.js';
import { CredentialError } from '../../../src/utils/errors.js';
// ─── Mock node-vault ────────────────────────────────────────────────────────
const mockWrite = jest.fn<() => Promise<unknown>>();
const mockRead = jest.fn<() => Promise<unknown>>();
const mockDelete = jest.fn<() => Promise<unknown>>();
jest.mock('node-vault', () => {
return jest.fn(() => ({
write: mockWrite,
read: mockRead,
delete: mockDelete,
}));
});
// ─── Helpers ────────────────────────────────────────────────────────────────
const AGENT_ID = 'agent-uuid-1234';
const CRED_ID = 'cred-uuid-5678';
const PLAIN_SECRET = 'super-secret-value';
function makeClient(): VaultClient {
return new VaultClient('http://127.0.0.1:8200', 'test-token', 'secret');
}
// ─── Tests ──────────────────────────────────────────────────────────────────
describe('VaultClient', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// ── writeSecret ────────────────────────────────────────────────────────────
describe('writeSecret', () => {
it('writes the secret to the correct KV v2 path and returns the path', async () => {
mockWrite.mockResolvedValue({});
const client = makeClient();
const path = await client.writeSecret(AGENT_ID, CRED_ID, PLAIN_SECRET);
expect(mockWrite).toHaveBeenCalledWith(
`secret/data/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`,
{ data: { clientSecret: PLAIN_SECRET } },
);
expect(path).toBe(`secret/data/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`);
});
it('throws CredentialError when Vault write fails', async () => {
mockWrite.mockRejectedValue(new Error('connection refused'));
const client = makeClient();
await expect(client.writeSecret(AGENT_ID, CRED_ID, PLAIN_SECRET))
.rejects.toThrow(CredentialError);
});
it('CredentialError on write failure has code VAULT_WRITE_ERROR', async () => {
mockWrite.mockRejectedValue(new Error('forbidden'));
const client = makeClient();
await expect(client.writeSecret(AGENT_ID, CRED_ID, PLAIN_SECRET))
.rejects.toMatchObject({ code: 'VAULT_WRITE_ERROR' });
});
});
// ── readSecret ─────────────────────────────────────────────────────────────
describe('readSecret', () => {
it('reads and returns the stored secret', async () => {
mockRead.mockResolvedValue({
data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} },
});
const client = makeClient();
const secret = await client.readSecret(AGENT_ID, CRED_ID);
expect(mockRead).toHaveBeenCalledWith(
`secret/data/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`,
);
expect(secret).toBe(PLAIN_SECRET);
});
it('throws CredentialError when secret field is missing', async () => {
mockRead.mockResolvedValue({ data: { data: {}, metadata: {} } });
const client = makeClient();
await expect(client.readSecret(AGENT_ID, CRED_ID))
.rejects.toMatchObject({ code: 'VAULT_SECRET_MISSING' });
});
it('throws CredentialError when Vault read fails', async () => {
mockRead.mockRejectedValue(new Error('404 not found'));
const client = makeClient();
await expect(client.readSecret(AGENT_ID, CRED_ID))
.rejects.toMatchObject({ code: 'VAULT_READ_ERROR' });
});
});
// ── verifySecret ───────────────────────────────────────────────────────────
describe('verifySecret', () => {
it('returns true when candidate matches stored secret', async () => {
mockRead.mockResolvedValue({
data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} },
});
const client = makeClient();
const result = await client.verifySecret(AGENT_ID, CRED_ID, PLAIN_SECRET);
expect(result).toBe(true);
});
it('returns false when candidate does not match stored secret', async () => {
mockRead.mockResolvedValue({
data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} },
});
const client = makeClient();
const result = await client.verifySecret(AGENT_ID, CRED_ID, 'wrong-secret');
expect(result).toBe(false);
});
it('returns false when Vault read fails (does not throw)', async () => {
mockRead.mockRejectedValue(new Error('vault sealed'));
const client = makeClient();
const result = await client.verifySecret(AGENT_ID, CRED_ID, PLAIN_SECRET);
expect(result).toBe(false);
});
it('returns false when lengths differ (constant-time)', async () => {
mockRead.mockResolvedValue({
data: { data: { clientSecret: PLAIN_SECRET }, metadata: {} },
});
const client = makeClient();
const result = await client.verifySecret(AGENT_ID, CRED_ID, 'short');
expect(result).toBe(false);
});
});
// ── deleteSecret ───────────────────────────────────────────────────────────
describe('deleteSecret', () => {
it('calls delete on the metadata path', async () => {
mockDelete.mockResolvedValue({});
const client = makeClient();
await client.deleteSecret(AGENT_ID, CRED_ID);
expect(mockDelete).toHaveBeenCalledWith(
`secret/metadata/agentidp/agents/${AGENT_ID}/credentials/${CRED_ID}`,
);
});
it('throws CredentialError when Vault delete fails', async () => {
mockDelete.mockRejectedValue(new Error('permission denied'));
const client = makeClient();
await expect(client.deleteSecret(AGENT_ID, CRED_ID))
.rejects.toMatchObject({ code: 'VAULT_DELETE_ERROR' });
});
});
});
// ─── createVaultClientFromEnv ─────────────────────────────────────────────────
describe('createVaultClientFromEnv', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('returns null when VAULT_ADDR is not set', () => {
delete process.env['VAULT_ADDR'];
delete process.env['VAULT_TOKEN'];
expect(createVaultClientFromEnv()).toBeNull();
});
it('returns null when VAULT_TOKEN is not set', () => {
process.env['VAULT_ADDR'] = 'http://127.0.0.1:8200';
delete process.env['VAULT_TOKEN'];
expect(createVaultClientFromEnv()).toBeNull();
});
it('returns a VaultClient when both VAULT_ADDR and VAULT_TOKEN are set', () => {
process.env['VAULT_ADDR'] = 'http://127.0.0.1:8200';
process.env['VAULT_TOKEN'] = 'test-token';
const client = createVaultClientFromEnv();
expect(client).toBeInstanceOf(VaultClient);
});
it('uses default mount "secret" when VAULT_MOUNT is not set', () => {
process.env['VAULT_ADDR'] = 'http://127.0.0.1:8200';
process.env['VAULT_TOKEN'] = 'test-token';
delete process.env['VAULT_MOUNT'];
// VaultClient instance created — mount is internal, just verify no throw
expect(() => createVaultClientFromEnv()).not.toThrow();
});
});