@checkstack/secrets-backend 0.1.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/CHANGELOG.md +148 -0
- package/package.json +48 -0
- package/src/active-backend.test.ts +46 -0
- package/src/active-backend.ts +28 -0
- package/src/admin-service.test.ts +77 -0
- package/src/admin-service.ts +91 -0
- package/src/backend-config-store.ts +76 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +236 -0
- package/src/internal-secrets-service.test.ts +85 -0
- package/src/internal-secrets-service.ts +54 -0
- package/src/leak-guard.test.ts +194 -0
- package/src/masking-context.test.ts +31 -0
- package/src/masking-context.ts +50 -0
- package/src/resolver-service.test.ts +87 -0
- package/src/resolver-service.ts +122 -0
- package/src/router.test.ts +76 -0
- package/src/router.ts +167 -0
- package/src/secret-backend-registry.ts +45 -0
- package/src/secret-backend.test.ts +21 -0
- package/src/secret-backend.ts +94 -0
- package/src/secret-resolver.test.ts +112 -0
- package/src/secret-resolver.ts +250 -0
- package/src/walk-secret-fields.ts +140 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configString } from "@checkstack/backend-api";
|
|
4
|
+
import { createSecretResolverService } from "./resolver-service";
|
|
5
|
+
import type { SecretStore } from "./secret-resolver";
|
|
6
|
+
|
|
7
|
+
const store: SecretStore = {
|
|
8
|
+
resolve: async (name) => {
|
|
9
|
+
const values: Record<string, string> = {
|
|
10
|
+
jira_token: "jira-secret-value",
|
|
11
|
+
DB_PASS: "db-secret-value",
|
|
12
|
+
missing: "", // present-but-empty, distinct from absent
|
|
13
|
+
};
|
|
14
|
+
if (!(name in values)) throw new Error(`Secret not found: ${name}`);
|
|
15
|
+
return values[name];
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("SecretResolverService.resolveBySchema", () => {
|
|
20
|
+
it("resolves ${{ secrets.NAME }} only in x-secret fields", async () => {
|
|
21
|
+
const service = createSecretResolverService({ secretStore: store });
|
|
22
|
+
const schema = z.object({
|
|
23
|
+
host: z.string(),
|
|
24
|
+
password: configString({ "x-secret": true }),
|
|
25
|
+
});
|
|
26
|
+
const { resolved, warnings } = await service.resolveBySchema({
|
|
27
|
+
value: { host: "h", password: "${{ secrets.DB_PASS }}" },
|
|
28
|
+
schema,
|
|
29
|
+
});
|
|
30
|
+
expect(resolved).toEqual({ host: "h", password: "db-secret-value" });
|
|
31
|
+
expect(warnings).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("SecretResolverService.resolveForRun", () => {
|
|
36
|
+
it("resolves a least-privilege env allowlist + masking context", async () => {
|
|
37
|
+
const service = createSecretResolverService({ secretStore: store });
|
|
38
|
+
const { env, masking } = await service.resolveForRun({
|
|
39
|
+
secretEnv: {
|
|
40
|
+
API_TOKEN: "${{ secrets.jira_token }}",
|
|
41
|
+
DATABASE_PASSWORD: "${{ secrets.DB_PASS }}",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
expect(env).toEqual({
|
|
45
|
+
API_TOKEN: "jira-secret-value",
|
|
46
|
+
DATABASE_PASSWORD: "db-secret-value",
|
|
47
|
+
});
|
|
48
|
+
expect(masking.size).toBe(2);
|
|
49
|
+
// The masking context redacts the resolved values out of output.
|
|
50
|
+
expect(masking.maskText("token=jira-secret-value")).toBe("token=****");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("supports inline interpolation in a mapping value", async () => {
|
|
54
|
+
const service = createSecretResolverService({ secretStore: store });
|
|
55
|
+
const { env } = await service.resolveForRun({
|
|
56
|
+
secretEnv: { CONN: "user:${{ secrets.DB_PASS }}@host" },
|
|
57
|
+
});
|
|
58
|
+
expect(env.CONN).toBe("user:db-secret-value@host");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("throws when a referenced secret cannot be resolved", async () => {
|
|
62
|
+
const service = createSecretResolverService({ secretStore: store });
|
|
63
|
+
await expect(
|
|
64
|
+
service.resolveForRun({ secretEnv: { X: "${{ secrets.absent }}" } }),
|
|
65
|
+
).rejects.toThrow("Secret not found: absent");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("resolves each distinct secret once even when reused", async () => {
|
|
69
|
+
let calls = 0;
|
|
70
|
+
const counting: SecretStore = {
|
|
71
|
+
resolve: async (name) => {
|
|
72
|
+
calls++;
|
|
73
|
+
return `val-${name}`;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
const service = createSecretResolverService({ secretStore: counting });
|
|
77
|
+
await service.resolveForRun({
|
|
78
|
+
secretEnv: {
|
|
79
|
+
A: "${{ secrets.same }}",
|
|
80
|
+
B: "${{ secrets.same }}",
|
|
81
|
+
C: "${{ secrets.other }}",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
// "same" + "other" → 2 distinct resolutions despite 3 references.
|
|
85
|
+
expect(calls).toBe(2);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
collectSecretNames,
|
|
4
|
+
normalizeSecretEnvValue,
|
|
5
|
+
type SecretEnvMapping,
|
|
6
|
+
} from "@checkstack/secrets-common";
|
|
7
|
+
import {
|
|
8
|
+
resolveSecretsBySchema,
|
|
9
|
+
type SecretResolutionResult,
|
|
10
|
+
type SecretStore,
|
|
11
|
+
} from "./secret-resolver";
|
|
12
|
+
import {
|
|
13
|
+
createMaskingContext,
|
|
14
|
+
type SecretMaskingContext,
|
|
15
|
+
} from "./masking-context";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cross-plugin secret resolution service, exposed via `secretResolverRef`.
|
|
19
|
+
*
|
|
20
|
+
* Wraps the promoted `resolveSecretsBySchema` so any consumer plugin
|
|
21
|
+
* (gitops, automation, healthcheck) resolves `${{ secrets.NAME }}` in
|
|
22
|
+
* `x-secret` fields on demand against the active backend, and can resolve
|
|
23
|
+
* a run's least-privilege env-mapping allowlist into values + a masking
|
|
24
|
+
* context.
|
|
25
|
+
*
|
|
26
|
+
* NOTE: every method here returns secret VALUES. It is a service-typed,
|
|
27
|
+
* backend-to-backend interface only — it MUST NOT be exposed to a browser
|
|
28
|
+
* client. Output produced from these values must pass through the
|
|
29
|
+
* returned `SecretMaskingContext` before it is persisted or returned.
|
|
30
|
+
*/
|
|
31
|
+
export interface SecretResolverService {
|
|
32
|
+
/** Resolve a single secret value by name. Throws if not found. */
|
|
33
|
+
resolveSecret(input: { name: string }): Promise<string>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve `${{ secrets.NAME }}` templates in `x-secret`-annotated fields
|
|
37
|
+
* of `value`, per `schema`. Promoted from gitops-backend.
|
|
38
|
+
*/
|
|
39
|
+
resolveBySchema<T>(input: {
|
|
40
|
+
value: T;
|
|
41
|
+
schema: z.ZodTypeAny;
|
|
42
|
+
}): Promise<SecretResolutionResult<T>>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a consumer's least-privilege secret→env allowlist into the
|
|
46
|
+
* concrete env vars for a run, plus a run-scoped masking context holding
|
|
47
|
+
* exactly those values. If any referenced secret is missing, this throws
|
|
48
|
+
* (the run must fail clearly rather than run without a required secret).
|
|
49
|
+
*/
|
|
50
|
+
resolveForRun(input: {
|
|
51
|
+
secretEnv: SecretEnvMapping;
|
|
52
|
+
}): Promise<{ env: Record<string, string>; masking: SecretMaskingContext }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the resolver service over a {@link SecretStore} (typically backed
|
|
57
|
+
* by the active backend's `get`).
|
|
58
|
+
*/
|
|
59
|
+
export function createSecretResolverService({
|
|
60
|
+
secretStore,
|
|
61
|
+
}: {
|
|
62
|
+
secretStore: SecretStore;
|
|
63
|
+
}): SecretResolverService {
|
|
64
|
+
return {
|
|
65
|
+
resolveSecret({ name }) {
|
|
66
|
+
return secretStore.resolve(name);
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
resolveBySchema({ value, schema }) {
|
|
70
|
+
return resolveSecretsBySchema({ value, schema, secretStore });
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async resolveForRun({ secretEnv }) {
|
|
74
|
+
// Normalize tolerated bare secret names to the canonical template
|
|
75
|
+
// (the value schema no longer transforms — see `secretEnvValueSchema`),
|
|
76
|
+
// so collection + substitution below see only `${{ secrets.NAME }}`.
|
|
77
|
+
const normalized: Record<string, string> = {};
|
|
78
|
+
for (const [envName, value] of Object.entries(secretEnv)) {
|
|
79
|
+
normalized[envName] = normalizeSecretEnvValue(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Collect every distinct secret name referenced by the mapping, so
|
|
83
|
+
// we resolve each once even if reused across multiple env vars.
|
|
84
|
+
const names = new Set(
|
|
85
|
+
collectSecretNames({ value: Object.values(normalized) }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const resolved = new Map<string, string>();
|
|
89
|
+
for (const name of names) {
|
|
90
|
+
resolved.set(name, await secretStore.resolve(name));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build the env by substituting templates in each mapping value.
|
|
94
|
+
const env: Record<string, string> = {};
|
|
95
|
+
for (const [envName, template] of Object.entries(normalized)) {
|
|
96
|
+
env[envName] = substituteTemplate({ template, resolved });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const masking = createMaskingContext({ values: resolved.values() });
|
|
100
|
+
return { env, masking };
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const TEMPLATE_RE = /\$\{\{\s*secrets\.([a-zA-Z0-9_-]+)\s*\}\}/g;
|
|
106
|
+
|
|
107
|
+
function substituteTemplate({
|
|
108
|
+
template,
|
|
109
|
+
resolved,
|
|
110
|
+
}: {
|
|
111
|
+
template: string;
|
|
112
|
+
resolved: Map<string, string>;
|
|
113
|
+
}): string {
|
|
114
|
+
TEMPLATE_RE.lastIndex = 0;
|
|
115
|
+
return template.replaceAll(TEMPLATE_RE, (_full, name: string) => {
|
|
116
|
+
const value = resolved.get(name);
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
throw new Error(`Required secret not available: ${name}`);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { call } from "@orpc/server";
|
|
3
|
+
import { createMockRpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import { createSecretsRouter } from "./router";
|
|
5
|
+
import { createSecretBackendRegistry } from "./secret-backend-registry";
|
|
6
|
+
import type { SecretBackend } from "./secret-backend";
|
|
7
|
+
|
|
8
|
+
const mockUser = {
|
|
9
|
+
type: "user" as const,
|
|
10
|
+
id: "test-user",
|
|
11
|
+
accessRules: ["*"],
|
|
12
|
+
roles: ["admin"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Fully writable backend (local): implements both `set` and `delete`. */
|
|
16
|
+
function localBackend(): SecretBackend {
|
|
17
|
+
return {
|
|
18
|
+
id: "local",
|
|
19
|
+
get: async () => undefined,
|
|
20
|
+
set: async () => {},
|
|
21
|
+
delete: async () => {},
|
|
22
|
+
list: async () => [],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Read-through backend (vault): implements neither `set` nor `delete`. */
|
|
27
|
+
function vaultBackend(): SecretBackend {
|
|
28
|
+
return {
|
|
29
|
+
id: "vault",
|
|
30
|
+
get: async () => undefined,
|
|
31
|
+
list: async () => [],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeRouter(active: string, backends: SecretBackend[]) {
|
|
36
|
+
const registry = createSecretBackendRegistry();
|
|
37
|
+
for (const b of backends) registry.register(b);
|
|
38
|
+
return createSecretsRouter({
|
|
39
|
+
backends: registry,
|
|
40
|
+
getActiveBackendId: async () => active,
|
|
41
|
+
setActiveBackendId: async () => {},
|
|
42
|
+
emitChanged: async () => {},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("getBackendConfig — writable capability flag", () => {
|
|
47
|
+
it("reports writable: true when the local backend is active", async () => {
|
|
48
|
+
const router = makeRouter("local", [localBackend(), vaultBackend()]);
|
|
49
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
50
|
+
|
|
51
|
+
const dto = await call(router.getBackendConfig, undefined, { context });
|
|
52
|
+
|
|
53
|
+
expect(dto.activeBackend).toBe("local");
|
|
54
|
+
expect(dto.writable).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("reports writable: false when a read-through (Vault) backend is active", async () => {
|
|
58
|
+
const router = makeRouter("vault", [localBackend(), vaultBackend()]);
|
|
59
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
60
|
+
|
|
61
|
+
const dto = await call(router.getBackendConfig, undefined, { context });
|
|
62
|
+
|
|
63
|
+
expect(dto.activeBackend).toBe("vault");
|
|
64
|
+
expect(dto.writable).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reports writable: false when the active backend is unresolved", async () => {
|
|
68
|
+
// Active id points at a backend that is not registered.
|
|
69
|
+
const router = makeRouter("missing", [localBackend()]);
|
|
70
|
+
const context = createMockRpcContext({ user: mockUser });
|
|
71
|
+
|
|
72
|
+
const dto = await call(router.getBackendConfig, undefined, { context });
|
|
73
|
+
|
|
74
|
+
expect(dto.writable).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
2
|
+
import {
|
|
3
|
+
autoAuthMiddleware,
|
|
4
|
+
correlationMiddleware,
|
|
5
|
+
type Logger,
|
|
6
|
+
type RpcContext,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import {
|
|
9
|
+
secretsContract,
|
|
10
|
+
type BackendConfigDto,
|
|
11
|
+
type SetBackendConfigInput,
|
|
12
|
+
} from "@checkstack/secrets-common";
|
|
13
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
14
|
+
import type { SecretBackendRegistry } from "./secret-backend-registry";
|
|
15
|
+
import { createSecretAdminService } from "./admin-service";
|
|
16
|
+
import { isBackendWritable } from "./secret-backend";
|
|
17
|
+
|
|
18
|
+
const os = implement(secretsContract)
|
|
19
|
+
.$context<RpcContext>()
|
|
20
|
+
.use(correlationMiddleware)
|
|
21
|
+
.use(autoAuthMiddleware);
|
|
22
|
+
|
|
23
|
+
export interface SecretsRouterDeps {
|
|
24
|
+
backends: SecretBackendRegistry;
|
|
25
|
+
/** Resolve the currently active backend id. */
|
|
26
|
+
getActiveBackendId: () => Promise<string>;
|
|
27
|
+
/** Persist the active backend id. */
|
|
28
|
+
setActiveBackendId: (id: string) => Promise<void>;
|
|
29
|
+
/** Notify consumers (e.g. gitops) that a secret changed. */
|
|
30
|
+
emitChanged: (input: {
|
|
31
|
+
name: string;
|
|
32
|
+
change: "created" | "rotated" | "deleted";
|
|
33
|
+
}) => Promise<void>;
|
|
34
|
+
/** Logger for the admin service's short-secret warning. */
|
|
35
|
+
logger?: Logger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createSecretsRouter({
|
|
39
|
+
backends,
|
|
40
|
+
getActiveBackendId,
|
|
41
|
+
setActiveBackendId,
|
|
42
|
+
emitChanged,
|
|
43
|
+
logger,
|
|
44
|
+
}: SecretsRouterDeps) {
|
|
45
|
+
// The router shares the admin service so write semantics + change events
|
|
46
|
+
// stay identical whether a secret is managed via the central UI or via a
|
|
47
|
+
// consumer plugin (e.g. gitops) delegating to secretAdminRef.
|
|
48
|
+
const admin = createSecretAdminService({
|
|
49
|
+
getActiveBackend: async () => backends.get(await getActiveBackendId()),
|
|
50
|
+
onChanged: emitChanged,
|
|
51
|
+
logger,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const listSecrets = os.listSecrets.handler(async () => {
|
|
55
|
+
// Metadata only — never values.
|
|
56
|
+
return admin.list();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const listSecretNames = os.listSecretNames.handler(async () => {
|
|
60
|
+
const metadata = await admin.list();
|
|
61
|
+
return metadata.map((m) => m.name);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const setSecret = os.setSecret.handler(async ({ input, context }) => {
|
|
65
|
+
const actor = context.user;
|
|
66
|
+
const createdBy =
|
|
67
|
+
actor && actor.type !== "service" ? actor.id : undefined;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await admin.setSecret({
|
|
71
|
+
name: input.name,
|
|
72
|
+
value: input.value,
|
|
73
|
+
description: input.description,
|
|
74
|
+
createdBy,
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new ORPCError("NOT_IMPLEMENTED", {
|
|
78
|
+
message: extractErrorMessage(error),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const after = await admin.list();
|
|
83
|
+
const meta = after.find((m) => m.name === input.name);
|
|
84
|
+
return { id: meta?.id ?? input.name, name: input.name };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const deleteSecret = os.deleteSecret.handler(async ({ input }) => {
|
|
88
|
+
try {
|
|
89
|
+
await admin.deleteSecret({ name: input.name });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new ORPCError("NOT_IMPLEMENTED", {
|
|
92
|
+
message: extractErrorMessage(error),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return { success: true };
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const getBackendConfig = os.getBackendConfig.handler(async () => {
|
|
99
|
+
const activeBackend = await getActiveBackendId();
|
|
100
|
+
// Surface the active backend's connection metadata (never credentials).
|
|
101
|
+
const active = backends.has(activeBackend)
|
|
102
|
+
? backends.get(activeBackend)
|
|
103
|
+
: undefined;
|
|
104
|
+
const dto: BackendConfigDto = {
|
|
105
|
+
activeBackend,
|
|
106
|
+
availableBackends: backends.ids(),
|
|
107
|
+
// Capability flag the UI uses to hide write controls for read-through
|
|
108
|
+
// backends. False when the active backend is unresolved/read-only.
|
|
109
|
+
writable: active ? isBackendWritable({ backend: active }) : false,
|
|
110
|
+
};
|
|
111
|
+
if (active?.getConfigMeta) {
|
|
112
|
+
dto.vault = await active.getConfigMeta();
|
|
113
|
+
}
|
|
114
|
+
return dto;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const setBackendConfig = os.setBackendConfig.handler(async ({ input }) => {
|
|
118
|
+
// Configure the target backend (e.g. Vault address/auth/credential)
|
|
119
|
+
// BEFORE switching to it, so we never activate an unconfigured backend.
|
|
120
|
+
if (input.vault) {
|
|
121
|
+
const target = backends.has(input.activeBackend)
|
|
122
|
+
? backends.get(input.activeBackend)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (!target?.configure) {
|
|
125
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
126
|
+
message: `Backend "${input.activeBackend}" is not configurable.`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const vault: NonNullable<SetBackendConfigInput["vault"]> = input.vault;
|
|
130
|
+
await target.configure(vault);
|
|
131
|
+
}
|
|
132
|
+
if (!backends.has(input.activeBackend)) {
|
|
133
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
134
|
+
message: `Backend "${input.activeBackend}" is not registered.`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
await setActiveBackendId(input.activeBackend);
|
|
138
|
+
return { success: true };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const testBackend = os.testBackend.handler(async ({ input }) => {
|
|
142
|
+
const id = input.backend ?? (await getActiveBackendId());
|
|
143
|
+
if (!backends.has(id)) {
|
|
144
|
+
return { ok: false, message: `Backend "${id}" is not registered.` };
|
|
145
|
+
}
|
|
146
|
+
const backend = backends.get(id);
|
|
147
|
+
// A backend with no external connectivity (e.g. local) is always ok.
|
|
148
|
+
if (!backend.test) {
|
|
149
|
+
return { ok: true, message: `Backend "${id}" requires no connectivity.` };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return await backend.test();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { ok: false, message: extractErrorMessage(error) };
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return os.router({
|
|
159
|
+
listSecrets,
|
|
160
|
+
listSecretNames,
|
|
161
|
+
setSecret,
|
|
162
|
+
deleteSecret,
|
|
163
|
+
getBackendConfig,
|
|
164
|
+
setBackendConfig,
|
|
165
|
+
testBackend,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SecretBackend } from "./secret-backend";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collects every registered {@link SecretBackend} (one per backend
|
|
5
|
+
* plugin) and resolves the active one by id. Mirrors the script-packages
|
|
6
|
+
* blob-store registry.
|
|
7
|
+
*/
|
|
8
|
+
export interface SecretBackendRegistry {
|
|
9
|
+
register(backend: SecretBackend): void;
|
|
10
|
+
/** All registered backend ids (for the admin backend selector). */
|
|
11
|
+
ids(): string[];
|
|
12
|
+
has(id: string): boolean;
|
|
13
|
+
/** Resolve a specific backend by id. Throws if not registered. */
|
|
14
|
+
get(id: string): SecretBackend;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createSecretBackendRegistry(): SecretBackendRegistry {
|
|
18
|
+
const backends = new Map<string, SecretBackend>();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
register(backend) {
|
|
22
|
+
backends.set(backend.id, backend);
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
ids() {
|
|
26
|
+
return [...backends.keys()];
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
has(id) {
|
|
30
|
+
return backends.has(id);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
get(id) {
|
|
34
|
+
const backend = backends.get(id);
|
|
35
|
+
if (!backend) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Secret backend "${id}" is not registered. Available: ${
|
|
38
|
+
[...backends.keys()].join(", ") || "(none)"
|
|
39
|
+
}`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return backend;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { isBackendWritable } from "./secret-backend";
|
|
3
|
+
|
|
4
|
+
describe("isBackendWritable", () => {
|
|
5
|
+
it("is true only when BOTH set and delete are implemented", () => {
|
|
6
|
+
expect(
|
|
7
|
+
isBackendWritable({ backend: { set: async () => {}, delete: async () => {} } }),
|
|
8
|
+
).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("is false for a read-through backend (neither set nor delete)", () => {
|
|
12
|
+
expect(isBackendWritable({ backend: {} })).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is false when only one of set / delete is implemented", () => {
|
|
16
|
+
expect(isBackendWritable({ backend: { set: async () => {} } })).toBe(false);
|
|
17
|
+
expect(isBackendWritable({ backend: { delete: async () => {} } })).toBe(
|
|
18
|
+
false,
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createExtensionPoint } from "@checkstack/backend-api";
|
|
2
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
3
|
+
import type {
|
|
4
|
+
SecretMetadata,
|
|
5
|
+
SetBackendConfigInput,
|
|
6
|
+
BackendConfigDto,
|
|
7
|
+
} from "@checkstack/secrets-common";
|
|
8
|
+
|
|
9
|
+
/** Public Vault connection metadata (never the credential). */
|
|
10
|
+
type VaultConfigMeta = NonNullable<BackendConfigDto["vault"]>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A pluggable secret store. Built-ins: `secrets-backend-local` (default,
|
|
14
|
+
* AES-256-GCM in the `secrets` table) and `secrets-backend-vault` (Phase
|
|
15
|
+
* 4). The active backend is config-selected; local is the default when no
|
|
16
|
+
* external backend is configured.
|
|
17
|
+
*
|
|
18
|
+
* `list` returns metadata only and NEVER values. `get` resolves a single
|
|
19
|
+
* value and is service-internal — its result must not cross to a browser.
|
|
20
|
+
* Read-through backends (Vault) implement only `get`/`list`; the local
|
|
21
|
+
* backend additionally implements `set`/`delete`.
|
|
22
|
+
*/
|
|
23
|
+
export interface SecretBackend {
|
|
24
|
+
/** Stable backend id recorded in `secrets.backend`. */
|
|
25
|
+
readonly id: string;
|
|
26
|
+
|
|
27
|
+
/** Resolve a single secret value by name, or undefined if absent. */
|
|
28
|
+
get(input: { name: string }): Promise<string | undefined>;
|
|
29
|
+
|
|
30
|
+
/** Create or rotate a secret value. Local backend only. */
|
|
31
|
+
set?(input: {
|
|
32
|
+
name: string;
|
|
33
|
+
value: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
createdBy?: string;
|
|
36
|
+
}): Promise<void>;
|
|
37
|
+
|
|
38
|
+
/** Delete a secret by name. Local backend only. Idempotent. */
|
|
39
|
+
delete?(input: { name: string }): Promise<void>;
|
|
40
|
+
|
|
41
|
+
/** Metadata for every secret this backend holds. NEVER returns values. */
|
|
42
|
+
list(): Promise<SecretMetadata[]>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate connectivity / auth for this backend. Returns status only and
|
|
46
|
+
* NEVER a secret value. Optional — a backend that needs no external
|
|
47
|
+
* connectivity (e.g. local) may omit it (treated as always-ok).
|
|
48
|
+
*/
|
|
49
|
+
test?(): Promise<{ ok: boolean; message: string }>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Persist this backend's connection config (e.g. Vault address / auth /
|
|
53
|
+
* credential). Implemented by externally-configured backends; the backend
|
|
54
|
+
* owns its own config storage (its plugin-scoped `ConfigService`). The
|
|
55
|
+
* write-only `credential` is encrypted at rest and never returned.
|
|
56
|
+
* Local-style backends omit this.
|
|
57
|
+
*/
|
|
58
|
+
configure?(input: NonNullable<SetBackendConfigInput["vault"]>): Promise<void>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Public connection metadata for the admin UI (NEVER the credential).
|
|
62
|
+
* Omitted by backends with no external connection.
|
|
63
|
+
*/
|
|
64
|
+
getConfigMeta?(): Promise<VaultConfigMeta | undefined>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a backend accepts secret-value writes (create / rotate / delete)
|
|
69
|
+
* from the admin UI. True only when it implements BOTH `set` and `delete`;
|
|
70
|
+
* read-through backends (e.g. Vault) implement neither, so this is `false`
|
|
71
|
+
* and the UI hides its write controls. Capability boolean only — leaks
|
|
72
|
+
* nothing sensitive.
|
|
73
|
+
*/
|
|
74
|
+
export function isBackendWritable({
|
|
75
|
+
backend,
|
|
76
|
+
}: {
|
|
77
|
+
backend: Pick<SecretBackend, "set" | "delete">;
|
|
78
|
+
}): boolean {
|
|
79
|
+
return typeof backend.set === "function" && typeof backend.delete === "function";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extension point a secret-backend plugin registers its implementation
|
|
84
|
+
* with. The active backend is selected via config; the resolver resolves
|
|
85
|
+
* the registered backend by id.
|
|
86
|
+
*/
|
|
87
|
+
export interface SecretBackendExtensionPoint {
|
|
88
|
+
registerSecretBackend(backend: SecretBackend, metadata: PluginMetadata): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const secretBackendExtensionPoint =
|
|
92
|
+
createExtensionPoint<SecretBackendExtensionPoint>(
|
|
93
|
+
"secrets.secretBackendExtensionPoint",
|
|
94
|
+
);
|