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:
@@ -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
197
docs/devops/vault-setup.md
Normal 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).
|
||||
@@ -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
154
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
src/app.ts
14
src/app.ts
@@ -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,
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
19
src/db/migrations/005_add_vault_path.sql
Normal file
19
src/db/migrations/005_add_vault_path.sql
Normal 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).';
|
||||
@@ -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'.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
// 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) {
|
||||
// Check if credential is expired
|
||||
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
|
||||
continue;
|
||||
}
|
||||
credentialVerified = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -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
200
src/vault/VaultClient.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
206
tests/unit/vault/VaultClient.test.ts
Normal file
206
tests/unit/vault/VaultClient.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user