@shipfox/api-secrets 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +116 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +38 -0
- package/dist/config.js.map +1 -0
- package/dist/core/crypto.d.ts +25 -0
- package/dist/core/crypto.d.ts.map +1 -0
- package/dist/core/crypto.js +85 -0
- package/dist/core/crypto.js.map +1 -0
- package/dist/core/dek-manager.d.ts +16 -0
- package/dist/core/dek-manager.d.ts.map +1 -0
- package/dist/core/dek-manager.js +85 -0
- package/dist/core/dek-manager.js.map +1 -0
- package/dist/core/errors.d.ts +53 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +86 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/fingerprint.d.ts +2 -0
- package/dist/core/fingerprint.d.ts.map +1 -0
- package/dist/core/fingerprint.js +9 -0
- package/dist/core/fingerprint.js.map +1 -0
- package/dist/core/index.d.ts +30 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +97 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/key-provider.d.ts +17 -0
- package/dist/core/key-provider.d.ts.map +1 -0
- package/dist/core/key-provider.js +45 -0
- package/dist/core/key-provider.js.map +1 -0
- package/dist/core/local-secret-store.d.ts +19 -0
- package/dist/core/local-secret-store.d.ts.map +1 -0
- package/dist/core/local-secret-store.js +40 -0
- package/dist/core/local-secret-store.js.map +1 -0
- package/dist/core/management.d.ts +37 -0
- package/dist/core/management.d.ts.map +1 -0
- package/dist/core/management.js +403 -0
- package/dist/core/management.js.map +1 -0
- package/dist/core/rotate-kek.d.ts +10 -0
- package/dist/core/rotate-kek.d.ts.map +1 -0
- package/dist/core/rotate-kek.js +94 -0
- package/dist/core/rotate-kek.js.map +1 -0
- package/dist/core/secret-store.d.ts +33 -0
- package/dist/core/secret-store.d.ts.map +1 -0
- package/dist/core/secret-store.js +209 -0
- package/dist/core/secret-store.js.map +1 -0
- package/dist/core/store-resolver.d.ts +18 -0
- package/dist/core/store-resolver.d.ts.map +1 -0
- package/dist/core/store-resolver.js +10 -0
- package/dist/core/store-resolver.js.map +1 -0
- package/dist/core/store-validation.d.ts +11 -0
- package/dist/core/store-validation.d.ts.map +1 -0
- package/dist/core/store-validation.js +30 -0
- package/dist/core/store-validation.js.map +1 -0
- package/dist/core/value-cipher.d.ts +10 -0
- package/dist/core/value-cipher.d.ts.map +1 -0
- package/dist/core/value-cipher.js +22 -0
- package/dist/core/value-cipher.js.map +1 -0
- package/dist/core/variable-store.d.ts +24 -0
- package/dist/core/variable-store.d.ts.map +1 -0
- package/dist/core/variable-store.js +194 -0
- package/dist/core/variable-store.js.map +1 -0
- package/dist/db/cap.d.ts +8 -0
- package/dist/db/cap.d.ts.map +1 -0
- package/dist/db/cap.js +39 -0
- package/dist/db/cap.js.map +1 -0
- package/dist/db/data-keys.d.ts +24 -0
- package/dist/db/data-keys.d.ts.map +1 -0
- package/dist/db/data-keys.js +61 -0
- package/dist/db/data-keys.js.map +1 -0
- package/dist/db/db.d.ts +1254 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +24 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/index.d.ts +13 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +15 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/management.d.ts +50 -0
- package/dist/db/management.d.ts.map +1 -0
- package/dist/db/management.js +127 -0
- package/dist/db/management.js.map +1 -0
- package/dist/db/schema/common.d.ts +3 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +8 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/data-keys.d.ts +103 -0
- package/dist/db/schema/data-keys.d.ts.map +1 -0
- package/dist/db/schema/data-keys.js +24 -0
- package/dist/db/schema/data-keys.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/values.d.ts +193 -0
- package/dist/db/schema/values.d.ts.map +1 -0
- package/dist/db/schema/values.js +43 -0
- package/dist/db/schema/values.js.map +1 -0
- package/dist/db/schema/variables.d.ts +175 -0
- package/dist/db/schema/variables.d.ts.map +1 -0
- package/dist/db/schema/variables.js +41 -0
- package/dist/db/schema/variables.js.map +1 -0
- package/dist/db/scope.d.ts +20 -0
- package/dist/db/scope.d.ts.map +1 -0
- package/dist/db/scope.js +19 -0
- package/dist/db/scope.js.map +1 -0
- package/dist/db/values.d.ts +33 -0
- package/dist/db/values.d.ts.map +1 -0
- package/dist/db/values.js +119 -0
- package/dist/db/values.js.map +1 -0
- package/dist/db/variables.d.ts +32 -0
- package/dist/db/variables.d.ts.map +1 -0
- package/dist/db/variables.js +117 -0
- package/dist/db/variables.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +3 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +4 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +39 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +151 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/metrics/service.d.ts +2 -0
- package/dist/metrics/service.d.ts.map +1 -0
- package/dist/metrics/service.js +21 -0
- package/dist/metrics/service.js.map +1 -0
- package/dist/presentation/dto/index.d.ts +2 -0
- package/dist/presentation/dto/index.d.ts.map +1 -0
- package/dist/presentation/dto/index.js +3 -0
- package/dist/presentation/dto/index.js.map +1 -0
- package/dist/presentation/dto/management.d.ts +7 -0
- package/dist/presentation/dto/management.d.ts.map +1 -0
- package/dist/presentation/dto/management.js +32 -0
- package/dist/presentation/dto/management.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-secret.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-secret.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-secret.js +34 -0
- package/dist/presentation/e2eRoutes/create-secret.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-variable.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-variable.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-variable.js +34 -0
- package/dist/presentation/e2eRoutes/create-variable.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +3 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +11 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/auth.d.ts +16 -0
- package/dist/presentation/routes/auth.d.ts.map +1 -0
- package/dist/presentation/routes/auth.js +42 -0
- package/dist/presentation/routes/auth.js.map +1 -0
- package/dist/presentation/routes/batch-secrets.d.ts +2 -0
- package/dist/presentation/routes/batch-secrets.d.ts.map +1 -0
- package/dist/presentation/routes/batch-secrets.js +44 -0
- package/dist/presentation/routes/batch-secrets.js.map +1 -0
- package/dist/presentation/routes/batch-variables.d.ts +2 -0
- package/dist/presentation/routes/batch-variables.d.ts.map +1 -0
- package/dist/presentation/routes/batch-variables.js +44 -0
- package/dist/presentation/routes/batch-variables.js.map +1 -0
- package/dist/presentation/routes/cursor.d.ts +3 -0
- package/dist/presentation/routes/cursor.d.ts.map +1 -0
- package/dist/presentation/routes/cursor.js +19 -0
- package/dist/presentation/routes/cursor.js.map +1 -0
- package/dist/presentation/routes/delete-secret.d.ts +2 -0
- package/dist/presentation/routes/delete-secret.d.ts.map +1 -0
- package/dist/presentation/routes/delete-secret.js +40 -0
- package/dist/presentation/routes/delete-secret.js.map +1 -0
- package/dist/presentation/routes/delete-variable.d.ts +2 -0
- package/dist/presentation/routes/delete-variable.d.ts.map +1 -0
- package/dist/presentation/routes/delete-variable.js +40 -0
- package/dist/presentation/routes/delete-variable.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +73 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/get-variable.d.ts +2 -0
- package/dist/presentation/routes/get-variable.d.ts.map +1 -0
- package/dist/presentation/routes/get-variable.js +42 -0
- package/dist/presentation/routes/get-variable.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +29 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/list-secrets.d.ts +2 -0
- package/dist/presentation/routes/list-secrets.d.ts.map +1 -0
- package/dist/presentation/routes/list-secrets.js +50 -0
- package/dist/presentation/routes/list-secrets.js.map +1 -0
- package/dist/presentation/routes/list-variables.d.ts +2 -0
- package/dist/presentation/routes/list-variables.d.ts.map +1 -0
- package/dist/presentation/routes/list-variables.js +50 -0
- package/dist/presentation/routes/list-variables.js.map +1 -0
- package/dist/presentation/routes/put-secret.d.ts +2 -0
- package/dist/presentation/routes/put-secret.d.ts.map +1 -0
- package/dist/presentation/routes/put-secret.js +52 -0
- package/dist/presentation/routes/put-secret.js.map +1 -0
- package/dist/presentation/routes/put-variable.d.ts +2 -0
- package/dist/presentation/routes/put-variable.d.ts.map +1 -0
- package/dist/presentation/routes/put-variable.js +52 -0
- package/dist/presentation/routes/put-variable.js.map +1 -0
- package/dist/presentation/routes/warnings.d.ts +5 -0
- package/dist/presentation/routes/warnings.d.ts.map +1 -0
- package/dist/presentation/routes/warnings.js +16 -0
- package/dist/presentation/routes/warnings.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_lowly_ogun.sql +59 -0
- package/drizzle/meta/0000_snapshot.json +547 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +77 -0
- package/src/config.test.ts +41 -0
- package/src/config.ts +41 -0
- package/src/core/crypto.test.ts +115 -0
- package/src/core/crypto.ts +105 -0
- package/src/core/dek-manager.test.ts +120 -0
- package/src/core/dek-manager.ts +98 -0
- package/src/core/errors.ts +100 -0
- package/src/core/fingerprint.test.ts +27 -0
- package/src/core/fingerprint.ts +14 -0
- package/src/core/index.ts +153 -0
- package/src/core/key-provider.test.ts +44 -0
- package/src/core/key-provider.ts +70 -0
- package/src/core/local-secret-store.ts +49 -0
- package/src/core/management.test.ts +37 -0
- package/src/core/management.ts +503 -0
- package/src/core/rotate-kek.test.ts +183 -0
- package/src/core/rotate-kek.ts +100 -0
- package/src/core/secret-store.test.ts +301 -0
- package/src/core/secret-store.ts +251 -0
- package/src/core/store-resolver.test.ts +20 -0
- package/src/core/store-resolver.ts +28 -0
- package/src/core/store-validation.ts +43 -0
- package/src/core/value-cipher.ts +26 -0
- package/src/core/variable-store.test.ts +106 -0
- package/src/core/variable-store.ts +232 -0
- package/src/db/cap.ts +58 -0
- package/src/db/data-keys.ts +99 -0
- package/src/db/db.ts +26 -0
- package/src/db/index.ts +45 -0
- package/src/db/management.ts +252 -0
- package/src/db/schema/common.ts +8 -0
- package/src/db/schema/data-keys.ts +31 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/values.ts +74 -0
- package/src/db/schema/variables.ts +71 -0
- package/src/db/scope.ts +42 -0
- package/src/db/values.ts +191 -0
- package/src/db/variables.ts +187 -0
- package/src/index.test.ts +11 -0
- package/src/index.ts +50 -0
- package/src/metrics/index.ts +19 -0
- package/src/metrics/instance.ts +219 -0
- package/src/metrics/service.ts +19 -0
- package/src/presentation/dto/index.ts +1 -0
- package/src/presentation/dto/management.ts +36 -0
- package/src/presentation/e2eRoutes/create-secret.ts +31 -0
- package/src/presentation/e2eRoutes/create-variable.ts +34 -0
- package/src/presentation/e2eRoutes/index.test.ts +94 -0
- package/src/presentation/e2eRoutes/index.ts +8 -0
- package/src/presentation/routes/auth.ts +50 -0
- package/src/presentation/routes/batch-secrets.ts +33 -0
- package/src/presentation/routes/batch-variables.ts +33 -0
- package/src/presentation/routes/cursor.ts +23 -0
- package/src/presentation/routes/delete-secret.ts +26 -0
- package/src/presentation/routes/delete-variable.ts +26 -0
- package/src/presentation/routes/errors.ts +63 -0
- package/src/presentation/routes/get-variable.ts +32 -0
- package/src/presentation/routes/index.ts +29 -0
- package/src/presentation/routes/list-secrets.ts +41 -0
- package/src/presentation/routes/list-variables.ts +41 -0
- package/src/presentation/routes/management.test.ts +452 -0
- package/src/presentation/routes/put-secret.ts +39 -0
- package/src/presentation/routes/put-variable.ts +39 -0
- package/src/presentation/routes/warnings.ts +23 -0
- package/test/env.ts +8 -0
- package/test/globalSetup.ts +18 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +7 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import {listDataKeysPage, listDataKeyVersions, updateDataKeyWrapCas} from '#db/index.js';
|
|
2
|
+
import {classifyKekRotationError, recordSecretsKekRotation} from '#metrics/instance.js';
|
|
3
|
+
import {KekVersionStrandedError} from './errors.js';
|
|
4
|
+
import type {KeyProvider} from './key-provider.js';
|
|
5
|
+
|
|
6
|
+
const PAGE_SIZE = 100;
|
|
7
|
+
|
|
8
|
+
export interface RotateWorkspaceDataKeysResult {
|
|
9
|
+
rotated: number;
|
|
10
|
+
skipped: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RotateWorkspaceDataKeysOptions {
|
|
14
|
+
workspaceIds?: string[] | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function rotateWorkspaceDataKeysWithProvider(
|
|
18
|
+
keyProvider: KeyProvider,
|
|
19
|
+
options: RotateWorkspaceDataKeysOptions = {},
|
|
20
|
+
): Promise<RotateWorkspaceDataKeysResult> {
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
try {
|
|
23
|
+
const knownVersions = [keyProvider.currentKeyVersion, keyProvider.previousKeyVersion].filter(
|
|
24
|
+
(version): version is string => Boolean(version),
|
|
25
|
+
);
|
|
26
|
+
const unknownVersions = await listDataKeyVersions(knownVersions, {
|
|
27
|
+
workspaceIds: options.workspaceIds,
|
|
28
|
+
});
|
|
29
|
+
if (unknownVersions.length > 0) throw new KekVersionStrandedError(unknownVersions[0] as string);
|
|
30
|
+
|
|
31
|
+
let rotated = 0;
|
|
32
|
+
let skipped = 0;
|
|
33
|
+
let skippedCurrent = 0;
|
|
34
|
+
let skippedRace = 0;
|
|
35
|
+
let afterWorkspaceId: string | undefined;
|
|
36
|
+
|
|
37
|
+
while (true) {
|
|
38
|
+
const page = await listDataKeysPage({
|
|
39
|
+
afterWorkspaceId,
|
|
40
|
+
limit: PAGE_SIZE,
|
|
41
|
+
workspaceIds: options.workspaceIds,
|
|
42
|
+
});
|
|
43
|
+
if (page.length === 0) break;
|
|
44
|
+
|
|
45
|
+
for (const row of page) {
|
|
46
|
+
afterWorkspaceId = row.workspaceId;
|
|
47
|
+
if (row.kekVersion === keyProvider.currentKeyVersion) {
|
|
48
|
+
skipped += 1;
|
|
49
|
+
skippedCurrent += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plaintextDek = keyProvider.unwrapDek(row.workspaceId, row.wrappedDek, row.kekVersion);
|
|
54
|
+
try {
|
|
55
|
+
const wrapped = keyProvider.wrapDek(row.workspaceId, plaintextDek);
|
|
56
|
+
const updated = await updateDataKeyWrapCas({
|
|
57
|
+
workspaceId: row.workspaceId,
|
|
58
|
+
oldKekVersion: row.kekVersion,
|
|
59
|
+
wrappedDek: wrapped.wrappedDek,
|
|
60
|
+
kekVersion: wrapped.kekVersion,
|
|
61
|
+
});
|
|
62
|
+
if (updated) rotated += 1;
|
|
63
|
+
else {
|
|
64
|
+
skipped += 1;
|
|
65
|
+
skippedRace += 1;
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
plaintextDek.fill(0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
recordSecretsKekRotation({outcome: 'rotated', count: rotated});
|
|
74
|
+
recordSecretsKekRotation({outcome: 'skipped_current', count: skippedCurrent});
|
|
75
|
+
recordSecretsKekRotation({outcome: 'skipped_race', count: skippedRace});
|
|
76
|
+
recordSecretsKekRotation({
|
|
77
|
+
outcome: rotationDurationOutcome({rotated, skippedCurrent, skippedRace}),
|
|
78
|
+
count: 0,
|
|
79
|
+
durationMs: Date.now() - startedAt,
|
|
80
|
+
});
|
|
81
|
+
return {rotated, skipped};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
recordSecretsKekRotation({
|
|
84
|
+
outcome: classifyKekRotationError(error),
|
|
85
|
+
durationMs: Date.now() - startedAt,
|
|
86
|
+
});
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function rotationDurationOutcome(params: {
|
|
92
|
+
rotated: number;
|
|
93
|
+
skippedCurrent: number;
|
|
94
|
+
skippedRace: number;
|
|
95
|
+
}) {
|
|
96
|
+
if (params.rotated > 0) return 'rotated';
|
|
97
|
+
if (params.skippedRace > 0) return 'skipped_race';
|
|
98
|
+
if (params.skippedCurrent === 0) return 'none';
|
|
99
|
+
return 'skipped_current';
|
|
100
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import {describe, expect, it} from '@shipfox/vitest/vi';
|
|
3
|
+
import {eq, sql} from 'drizzle-orm';
|
|
4
|
+
import {db, secretDataKeys, secretValues, upsertSecretValueRows} from '#db/index.js';
|
|
5
|
+
import {
|
|
6
|
+
NamespaceValidationError,
|
|
7
|
+
SecretBatchScopeMismatchError,
|
|
8
|
+
SecretDecryptionError,
|
|
9
|
+
SecretKeyValidationError,
|
|
10
|
+
SecretValueTooLargeError,
|
|
11
|
+
WorkspaceSecretCapExceededError,
|
|
12
|
+
} from './errors.js';
|
|
13
|
+
import {dekManager, deleteSecrets, getSecret, getSecretsByNamespace, setSecrets} from './index.js';
|
|
14
|
+
|
|
15
|
+
const V1_PREFIX_PATTERN = /^v1:/;
|
|
16
|
+
const HMAC_FINGERPRINT_PATTERN = /^hmac-sha256:[A-Za-z0-9_-]{43}$/;
|
|
17
|
+
|
|
18
|
+
describe('secret store', () => {
|
|
19
|
+
it('sets, encrypts, and reads secrets by namespace', async () => {
|
|
20
|
+
const workspaceId = crypto.randomUUID();
|
|
21
|
+
|
|
22
|
+
await setSecrets({
|
|
23
|
+
workspaceId,
|
|
24
|
+
namespace: 'system/agent/model-provider/openai',
|
|
25
|
+
values: {API_KEY: 'sk-live-value'},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const value = await getSecret({
|
|
29
|
+
workspaceId,
|
|
30
|
+
namespace: 'system/agent/model-provider/openai',
|
|
31
|
+
key: 'API_KEY',
|
|
32
|
+
});
|
|
33
|
+
const values = await getSecretsByNamespace({
|
|
34
|
+
workspaceId,
|
|
35
|
+
namespace: 'system/agent/model-provider/openai',
|
|
36
|
+
});
|
|
37
|
+
const rows = await db()
|
|
38
|
+
.select()
|
|
39
|
+
.from(secretValues)
|
|
40
|
+
.where(eq(secretValues.workspaceId, workspaceId));
|
|
41
|
+
|
|
42
|
+
expect(value).toBe('sk-live-value');
|
|
43
|
+
expect(values).toEqual({API_KEY: 'sk-live-value'});
|
|
44
|
+
expect(rows[0]?.ciphertext).toMatch(V1_PREFIX_PATTERN);
|
|
45
|
+
expect(rows[0]?.ciphertext).not.toBe('sk-live-value');
|
|
46
|
+
expect(rows[0]?.fingerprint).toMatch(HMAC_FINGERPRINT_PATTERN);
|
|
47
|
+
expect(rows[0]?.fingerprint).not.toContain('alue');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('prefers project scope over workspace scope and deletes only exact project scope', async () => {
|
|
51
|
+
const workspaceId = crypto.randomUUID();
|
|
52
|
+
const projectId = crypto.randomUUID();
|
|
53
|
+
|
|
54
|
+
await setSecrets({workspaceId, values: {TOKEN: 'workspace-token'}});
|
|
55
|
+
await setSecrets({workspaceId, projectId, values: {TOKEN: 'project-token'}});
|
|
56
|
+
const projectValue = await getSecret({workspaceId, projectId, key: 'TOKEN'});
|
|
57
|
+
const workspaceValue = await getSecret({workspaceId, key: 'TOKEN'});
|
|
58
|
+
await deleteSecrets({workspaceId, projectId});
|
|
59
|
+
const inheritedValue = await getSecret({workspaceId, projectId, key: 'TOKEN'});
|
|
60
|
+
|
|
61
|
+
expect(projectValue).toBe('project-token');
|
|
62
|
+
expect(workspaceValue).toBe('workspace-token');
|
|
63
|
+
expect(inheritedValue).toBe('workspace-token');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('normalizes an empty project id to workspace scope', async () => {
|
|
67
|
+
const workspaceId = crypto.randomUUID();
|
|
68
|
+
|
|
69
|
+
await setSecrets({workspaceId, projectId: '', values: {TOKEN: 'workspace-token'}});
|
|
70
|
+
const value = await getSecret({workspaceId, key: 'TOKEN'});
|
|
71
|
+
const rows = await db()
|
|
72
|
+
.select()
|
|
73
|
+
.from(secretValues)
|
|
74
|
+
.where(eq(secretValues.workspaceId, workspaceId));
|
|
75
|
+
|
|
76
|
+
expect(value).toBe('workspace-token');
|
|
77
|
+
expect(rows[0]?.projectId).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('treats an empty delete key list as a no-op', async () => {
|
|
81
|
+
const workspaceId = crypto.randomUUID();
|
|
82
|
+
await setSecrets({workspaceId, values: {TOKEN: 'keep-me'}});
|
|
83
|
+
|
|
84
|
+
const deleted = await deleteSecrets({workspaceId, keys: []});
|
|
85
|
+
const value = await getSecret({workspaceId, key: 'TOKEN'});
|
|
86
|
+
|
|
87
|
+
expect(deleted).toBe(0);
|
|
88
|
+
expect(value).toBe('keep-me');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('rejects invalid keys and namespaces before writing', async () => {
|
|
92
|
+
const workspaceId = crypto.randomUUID();
|
|
93
|
+
|
|
94
|
+
await expect(setSecrets({workspaceId, values: {'bad-key': 'value'}})).rejects.toThrow(
|
|
95
|
+
SecretKeyValidationError,
|
|
96
|
+
);
|
|
97
|
+
await expect(
|
|
98
|
+
setSecrets({workspaceId, namespace: 'Bad/Namespace', values: {TOKEN: 'value'}}),
|
|
99
|
+
).rejects.toThrow(NamespaceValidationError);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects oversized values with a typed domain error', async () => {
|
|
103
|
+
const workspaceId = crypto.randomUUID();
|
|
104
|
+
|
|
105
|
+
await expect(
|
|
106
|
+
setSecrets({workspaceId, values: {TOKEN: 'a'.repeat(64 * 1024 + 1)}}),
|
|
107
|
+
).rejects.toThrow(SecretValueTooLargeError);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('enforces the workspace cap', async () => {
|
|
111
|
+
const workspaceId = crypto.randomUUID();
|
|
112
|
+
const values = Object.fromEntries(
|
|
113
|
+
Array.from({length: 10_001}, (_, index) => [`KEY_${index}`, 'value']),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await expect(setSecrets({workspaceId, values})).rejects.toThrow(
|
|
117
|
+
WorkspaceSecretCapExceededError,
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('does not count system namespace entries against the workspace cap', async () => {
|
|
122
|
+
const workspaceId = crypto.randomUUID();
|
|
123
|
+
await db()
|
|
124
|
+
.insert(secretValues)
|
|
125
|
+
.values(
|
|
126
|
+
Array.from({length: 10_000}, (_, index) => ({
|
|
127
|
+
workspaceId,
|
|
128
|
+
projectId: null,
|
|
129
|
+
namespace: 'system/github/installation-token/1',
|
|
130
|
+
key: `KEY_${index}`,
|
|
131
|
+
ciphertext: 'v1:test',
|
|
132
|
+
fingerprint: null,
|
|
133
|
+
})),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await setSecrets({workspaceId, values: {TOKEN: 'user-value'}});
|
|
137
|
+
const value = await getSecret({workspaceId, key: 'TOKEN'});
|
|
138
|
+
|
|
139
|
+
expect(value).toBe('user-value');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('allows system namespace writes when user entries are at the workspace cap', async () => {
|
|
143
|
+
const workspaceId = crypto.randomUUID();
|
|
144
|
+
await db()
|
|
145
|
+
.insert(secretValues)
|
|
146
|
+
.values(
|
|
147
|
+
Array.from({length: 10_000}, (_, index) => ({
|
|
148
|
+
workspaceId,
|
|
149
|
+
projectId: null,
|
|
150
|
+
namespace: '',
|
|
151
|
+
key: `KEY_${index}`,
|
|
152
|
+
ciphertext: 'v1:test',
|
|
153
|
+
fingerprint: null,
|
|
154
|
+
})),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await setSecrets({
|
|
158
|
+
workspaceId,
|
|
159
|
+
namespace: 'system/github/installation-token/1',
|
|
160
|
+
values: {TOKEN: 'system-value'},
|
|
161
|
+
});
|
|
162
|
+
const value = await getSecret({
|
|
163
|
+
workspaceId,
|
|
164
|
+
namespace: 'system/github/installation-token/1',
|
|
165
|
+
key: 'TOKEN',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(value).toBe('system-value');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('allows updating an existing secret at the workspace cap', async () => {
|
|
172
|
+
const workspaceId = crypto.randomUUID();
|
|
173
|
+
await setSecrets({workspaceId, values: {TOKEN: 'old-value'}});
|
|
174
|
+
await db()
|
|
175
|
+
.insert(secretValues)
|
|
176
|
+
.values(
|
|
177
|
+
Array.from({length: 9_999}, (_, index) => ({
|
|
178
|
+
workspaceId,
|
|
179
|
+
projectId: null,
|
|
180
|
+
namespace: '',
|
|
181
|
+
key: `KEY_${index}`,
|
|
182
|
+
ciphertext: 'v1:test',
|
|
183
|
+
fingerprint: null,
|
|
184
|
+
})),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await setSecrets({workspaceId, values: {TOKEN: 'new-value'}});
|
|
188
|
+
const value = await getSecret({workspaceId, key: 'TOKEN'});
|
|
189
|
+
|
|
190
|
+
expect(value).toBe('new-value');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('does not mint a data key for an empty batch', async () => {
|
|
194
|
+
const workspaceId = crypto.randomUUID();
|
|
195
|
+
|
|
196
|
+
await setSecrets({workspaceId, values: {}});
|
|
197
|
+
const rows = await db()
|
|
198
|
+
.select()
|
|
199
|
+
.from(secretDataKeys)
|
|
200
|
+
.where(eq(secretDataKeys.workspaceId, workspaceId));
|
|
201
|
+
|
|
202
|
+
expect(rows).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('rolls back value rows when the write transaction fails', async () => {
|
|
206
|
+
const workspaceId = crypto.randomUUID();
|
|
207
|
+
|
|
208
|
+
await expect(
|
|
209
|
+
db().transaction(async (tx) => {
|
|
210
|
+
await upsertSecretValueRows(
|
|
211
|
+
[
|
|
212
|
+
{
|
|
213
|
+
workspaceId,
|
|
214
|
+
projectId: null,
|
|
215
|
+
namespace: '',
|
|
216
|
+
key: 'TOKEN',
|
|
217
|
+
ciphertext: 'v1:test',
|
|
218
|
+
fingerprint: null,
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
tx,
|
|
222
|
+
);
|
|
223
|
+
throw new Error('rollback');
|
|
224
|
+
}),
|
|
225
|
+
).rejects.toThrow('rollback');
|
|
226
|
+
const rows = await db()
|
|
227
|
+
.select()
|
|
228
|
+
.from(secretValues)
|
|
229
|
+
.where(eq(secretValues.workspaceId, workspaceId));
|
|
230
|
+
|
|
231
|
+
expect(rows).toHaveLength(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('rejects mixed project scopes in one value upsert batch', async () => {
|
|
235
|
+
const workspaceId = crypto.randomUUID();
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
db().transaction((tx) =>
|
|
239
|
+
upsertSecretValueRows(
|
|
240
|
+
[
|
|
241
|
+
{
|
|
242
|
+
workspaceId,
|
|
243
|
+
projectId: null,
|
|
244
|
+
namespace: '',
|
|
245
|
+
key: 'TOKEN',
|
|
246
|
+
ciphertext: 'v1:test',
|
|
247
|
+
fingerprint: null,
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
workspaceId,
|
|
251
|
+
projectId: crypto.randomUUID(),
|
|
252
|
+
namespace: '',
|
|
253
|
+
key: 'OTHER_TOKEN',
|
|
254
|
+
ciphertext: 'v1:test',
|
|
255
|
+
fingerprint: null,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
tx,
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
).rejects.toThrow(SecretBatchScopeMismatchError);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('keeps the database namespace check in parity with the DTO pattern', async () => {
|
|
265
|
+
const workspaceId = crypto.randomUUID();
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
db().insert(secretValues).values({
|
|
269
|
+
workspaceId,
|
|
270
|
+
projectId: null,
|
|
271
|
+
namespace: 'Bad/Namespace',
|
|
272
|
+
key: 'TOKEN',
|
|
273
|
+
ciphertext: 'v1:test',
|
|
274
|
+
fingerprint: null,
|
|
275
|
+
}),
|
|
276
|
+
).rejects.toThrow();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('fails closed when the workspace data key is removed', async () => {
|
|
280
|
+
const workspaceId = crypto.randomUUID();
|
|
281
|
+
await setSecrets({workspaceId, values: {TOKEN: 'recover-me'}});
|
|
282
|
+
dekManager().invalidate(workspaceId);
|
|
283
|
+
await db().delete(secretDataKeys).where(eq(secretDataKeys.workspaceId, workspaceId));
|
|
284
|
+
|
|
285
|
+
await expect(getSecret({workspaceId, key: 'TOKEN'})).rejects.toThrow(SecretDecryptionError);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('keeps the database key-pattern check in parity with the DTO pattern', async () => {
|
|
289
|
+
const accepted = await db().execute(sql`
|
|
290
|
+
SELECT bool_and(value ~ '^[A-Z_][A-Z0-9_]*$') AS ok
|
|
291
|
+
FROM (VALUES ('A_B1'), ('_A')) accepted(value)
|
|
292
|
+
`);
|
|
293
|
+
const rejected = await db().execute(sql`
|
|
294
|
+
SELECT bool_or(value ~ '^[A-Z_][A-Z0-9_]*$') AS ok
|
|
295
|
+
FROM (VALUES ('A-B'), ('1A'), ('a')) rejected(value)
|
|
296
|
+
`);
|
|
297
|
+
|
|
298
|
+
expect(accepted.rows[0]?.ok).toBe(true);
|
|
299
|
+
expect(rejected.rows[0]?.ok).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countSecretValueRows,
|
|
3
|
+
db,
|
|
4
|
+
deleteSecretValueRows,
|
|
5
|
+
lockWorkspaceEntries,
|
|
6
|
+
type StoreScope,
|
|
7
|
+
upsertSecretValueRows,
|
|
8
|
+
} from '#db/index.js';
|
|
9
|
+
import {normalizedProjectId} from '#db/scope.js';
|
|
10
|
+
import {
|
|
11
|
+
classifySecretsOperationError,
|
|
12
|
+
operationScope,
|
|
13
|
+
recordSecretsEntriesMutated,
|
|
14
|
+
recordSecretsOperation,
|
|
15
|
+
} from '#metrics/instance.js';
|
|
16
|
+
import type {DekManager} from './dek-manager.js';
|
|
17
|
+
import {fingerprintSecretValue} from './fingerprint.js';
|
|
18
|
+
import type {SecretStoreProvider} from './store-resolver.js';
|
|
19
|
+
import {
|
|
20
|
+
assertWorkspaceCap,
|
|
21
|
+
validateNamespace,
|
|
22
|
+
validateSecretKeys,
|
|
23
|
+
validateValueBytes,
|
|
24
|
+
} from './store-validation.js';
|
|
25
|
+
import {encryptSecretValue} from './value-cipher.js';
|
|
26
|
+
|
|
27
|
+
export interface SetSecretsParams extends StoreScope {
|
|
28
|
+
workspaceId: string;
|
|
29
|
+
namespace?: string | undefined;
|
|
30
|
+
values: Record<string, string>;
|
|
31
|
+
editedBy?: string | null | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DeleteSecretsParams extends StoreScope {
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
namespace?: string | undefined;
|
|
37
|
+
keys?: string[] | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSecretStoreApi(params: {
|
|
41
|
+
dekManager: DekManager;
|
|
42
|
+
resolveSecretStore: (name?: string | undefined) => SecretStoreProvider;
|
|
43
|
+
}) {
|
|
44
|
+
return {
|
|
45
|
+
async getSecret(
|
|
46
|
+
input: StoreScope & {
|
|
47
|
+
workspaceId: string;
|
|
48
|
+
namespace?: string | undefined;
|
|
49
|
+
key: string;
|
|
50
|
+
store?: string;
|
|
51
|
+
},
|
|
52
|
+
) {
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
const scope = operationScope(input);
|
|
55
|
+
try {
|
|
56
|
+
const namespace = input.namespace ?? '';
|
|
57
|
+
validateNamespace(namespace);
|
|
58
|
+
validateSecretKeys([input.key]);
|
|
59
|
+
const value = await params.resolveSecretStore(input.store).getSecret({...input, namespace});
|
|
60
|
+
recordSecretsOperation({
|
|
61
|
+
resource: 'secret',
|
|
62
|
+
operation: 'get',
|
|
63
|
+
surface: 'internal',
|
|
64
|
+
scope,
|
|
65
|
+
outcome: value === null ? 'not_found' : 'success',
|
|
66
|
+
durationMs: Date.now() - startedAt,
|
|
67
|
+
});
|
|
68
|
+
return value;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
recordSecretsOperation({
|
|
71
|
+
resource: 'secret',
|
|
72
|
+
operation: 'get',
|
|
73
|
+
surface: 'internal',
|
|
74
|
+
scope,
|
|
75
|
+
outcome: classifySecretsOperationError(error),
|
|
76
|
+
durationMs: Date.now() - startedAt,
|
|
77
|
+
});
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async getSecretsByNamespace(
|
|
82
|
+
input: StoreScope & {workspaceId: string; namespace?: string | undefined; store?: string},
|
|
83
|
+
) {
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
const scope = operationScope(input);
|
|
86
|
+
try {
|
|
87
|
+
const namespace = input.namespace ?? '';
|
|
88
|
+
validateNamespace(namespace);
|
|
89
|
+
const values = await params
|
|
90
|
+
.resolveSecretStore(input.store)
|
|
91
|
+
.getSecretsByNamespace({...input, namespace});
|
|
92
|
+
recordSecretsOperation({
|
|
93
|
+
resource: 'secret',
|
|
94
|
+
operation: 'get_namespace',
|
|
95
|
+
surface: 'internal',
|
|
96
|
+
scope,
|
|
97
|
+
outcome: 'success',
|
|
98
|
+
durationMs: Date.now() - startedAt,
|
|
99
|
+
});
|
|
100
|
+
return values;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
recordSecretsOperation({
|
|
103
|
+
resource: 'secret',
|
|
104
|
+
operation: 'get_namespace',
|
|
105
|
+
surface: 'internal',
|
|
106
|
+
scope,
|
|
107
|
+
outcome: classifySecretsOperationError(error),
|
|
108
|
+
durationMs: Date.now() - startedAt,
|
|
109
|
+
});
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
async setSecrets(input: SetSecretsParams): Promise<void> {
|
|
114
|
+
const startedAt = Date.now();
|
|
115
|
+
const scope = operationScope(input);
|
|
116
|
+
try {
|
|
117
|
+
const namespace = input.namespace ?? '';
|
|
118
|
+
const entries = Object.entries(input.values);
|
|
119
|
+
validateNamespace(namespace);
|
|
120
|
+
validateSecretKeys(entries.map(([key]) => key));
|
|
121
|
+
validateValueBytes(entries.map(([, value]) => value));
|
|
122
|
+
if (entries.length === 0) {
|
|
123
|
+
recordSecretsOperation({
|
|
124
|
+
resource: 'secret',
|
|
125
|
+
operation: 'set',
|
|
126
|
+
surface: 'internal',
|
|
127
|
+
scope,
|
|
128
|
+
outcome: 'success',
|
|
129
|
+
durationMs: Date.now() - startedAt,
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const dek = await params.dekManager.getPlaintextDek(input.workspaceId);
|
|
135
|
+
const projectId = normalizedProjectId(input);
|
|
136
|
+
const rows = entries.map(([key, value]) => ({
|
|
137
|
+
workspaceId: input.workspaceId,
|
|
138
|
+
projectId,
|
|
139
|
+
namespace,
|
|
140
|
+
key,
|
|
141
|
+
ciphertext: encryptSecretValue({
|
|
142
|
+
dek,
|
|
143
|
+
workspaceId: input.workspaceId,
|
|
144
|
+
scope: {projectId},
|
|
145
|
+
namespace,
|
|
146
|
+
key,
|
|
147
|
+
value,
|
|
148
|
+
}),
|
|
149
|
+
fingerprint: fingerprintSecretValue(value, dek),
|
|
150
|
+
lastEditedBy: input.editedBy ?? null,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
let existingEntries = 0;
|
|
154
|
+
await db().transaction(async (tx) => {
|
|
155
|
+
await lockWorkspaceEntries(input.workspaceId, tx);
|
|
156
|
+
existingEntries = await countSecretValueRows(
|
|
157
|
+
{
|
|
158
|
+
workspaceId: input.workspaceId,
|
|
159
|
+
projectId,
|
|
160
|
+
namespace,
|
|
161
|
+
keys: entries.map(([key]) => key),
|
|
162
|
+
},
|
|
163
|
+
tx,
|
|
164
|
+
);
|
|
165
|
+
await assertWorkspaceCap({
|
|
166
|
+
workspaceId: input.workspaceId,
|
|
167
|
+
namespace,
|
|
168
|
+
incomingEntries: entries.length - existingEntries,
|
|
169
|
+
tx,
|
|
170
|
+
});
|
|
171
|
+
await upsertSecretValueRows(rows, tx);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
recordSecretsEntriesMutated({
|
|
175
|
+
resource: 'secret',
|
|
176
|
+
operation: 'set',
|
|
177
|
+
effect: 'created',
|
|
178
|
+
surface: 'internal',
|
|
179
|
+
count: entries.length - existingEntries,
|
|
180
|
+
});
|
|
181
|
+
recordSecretsEntriesMutated({
|
|
182
|
+
resource: 'secret',
|
|
183
|
+
operation: 'set',
|
|
184
|
+
effect: 'updated',
|
|
185
|
+
surface: 'internal',
|
|
186
|
+
count: existingEntries,
|
|
187
|
+
});
|
|
188
|
+
recordSecretsOperation({
|
|
189
|
+
resource: 'secret',
|
|
190
|
+
operation: 'set',
|
|
191
|
+
surface: 'internal',
|
|
192
|
+
scope,
|
|
193
|
+
outcome: 'success',
|
|
194
|
+
durationMs: Date.now() - startedAt,
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
recordSecretsOperation({
|
|
198
|
+
resource: 'secret',
|
|
199
|
+
operation: 'set',
|
|
200
|
+
surface: 'internal',
|
|
201
|
+
scope,
|
|
202
|
+
outcome: classifySecretsOperationError(error),
|
|
203
|
+
durationMs: Date.now() - startedAt,
|
|
204
|
+
});
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async deleteSecrets(input: DeleteSecretsParams): Promise<number> {
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
const scope = operationScope(input);
|
|
211
|
+
try {
|
|
212
|
+
const namespace = input.namespace ?? '';
|
|
213
|
+
validateNamespace(namespace);
|
|
214
|
+
if (input.keys) validateSecretKeys(input.keys);
|
|
215
|
+
|
|
216
|
+
const deleted = await deleteSecretValueRows({
|
|
217
|
+
workspaceId: input.workspaceId,
|
|
218
|
+
projectId: normalizedProjectId(input),
|
|
219
|
+
namespace,
|
|
220
|
+
keys: input.keys,
|
|
221
|
+
});
|
|
222
|
+
recordSecretsEntriesMutated({
|
|
223
|
+
resource: 'secret',
|
|
224
|
+
operation: 'delete',
|
|
225
|
+
effect: 'deleted',
|
|
226
|
+
surface: 'internal',
|
|
227
|
+
count: deleted,
|
|
228
|
+
});
|
|
229
|
+
recordSecretsOperation({
|
|
230
|
+
resource: 'secret',
|
|
231
|
+
operation: 'delete',
|
|
232
|
+
surface: 'internal',
|
|
233
|
+
scope,
|
|
234
|
+
outcome: 'success',
|
|
235
|
+
durationMs: Date.now() - startedAt,
|
|
236
|
+
});
|
|
237
|
+
return deleted;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
recordSecretsOperation({
|
|
240
|
+
resource: 'secret',
|
|
241
|
+
operation: 'delete',
|
|
242
|
+
surface: 'internal',
|
|
243
|
+
scope,
|
|
244
|
+
outcome: classifySecretsOperationError(error),
|
|
245
|
+
durationMs: Date.now() - startedAt,
|
|
246
|
+
});
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {describe, expect, it} from '@shipfox/vitest/vi';
|
|
2
|
+
import {UnknownSecretStoreError} from './errors.js';
|
|
3
|
+
import {
|
|
4
|
+
BUILTIN_LOCAL_STORE,
|
|
5
|
+
createSecretStoreResolver,
|
|
6
|
+
type SecretStoreProvider,
|
|
7
|
+
} from './store-resolver.js';
|
|
8
|
+
|
|
9
|
+
describe('secret store resolver', () => {
|
|
10
|
+
it('resolves the local provider and rejects unknown stores', () => {
|
|
11
|
+
const provider: SecretStoreProvider = {
|
|
12
|
+
getSecret: async () => null,
|
|
13
|
+
getSecretsByNamespace: async () => ({}),
|
|
14
|
+
};
|
|
15
|
+
const resolveSecretStore = createSecretStoreResolver(provider);
|
|
16
|
+
|
|
17
|
+
expect(resolveSecretStore(BUILTIN_LOCAL_STORE)).toBe(provider);
|
|
18
|
+
expect(() => resolveSecretStore('remote')).toThrow(UnknownSecretStoreError);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {UnknownSecretStoreError} from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const BUILTIN_LOCAL_STORE = 'local';
|
|
4
|
+
|
|
5
|
+
export interface GetSecretParams {
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
projectId?: string | null | undefined;
|
|
8
|
+
namespace: string;
|
|
9
|
+
key: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GetSecretsByNamespaceParams {
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
projectId?: string | null | undefined;
|
|
15
|
+
namespace: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SecretStoreProvider {
|
|
19
|
+
getSecret(params: GetSecretParams): Promise<string | null>;
|
|
20
|
+
getSecretsByNamespace(params: GetSecretsByNamespaceParams): Promise<Record<string, string>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createSecretStoreResolver(localProvider: SecretStoreProvider) {
|
|
24
|
+
return function resolveSecretStore(name = BUILTIN_LOCAL_STORE): SecretStoreProvider {
|
|
25
|
+
if (name === BUILTIN_LOCAL_STORE) return localProvider;
|
|
26
|
+
throw new UnknownSecretStoreError(name);
|
|
27
|
+
};
|
|
28
|
+
}
|