@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @checkstack/secrets-backend
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 270ef29: Fix suspend/resume durability + complete the run-wide secret-masking guarantee.
|
|
8
|
+
|
|
9
|
+
A panel review confirmed several defects in the automation dispatch engine's suspend/resume durability and in the run-wide masking choke point. These survived because the unit suite stubbed the seam under test; the fixes ship with tests that exercise the real suspend / sweep / resume paths.
|
|
10
|
+
|
|
11
|
+
Suspend/resume durability:
|
|
12
|
+
|
|
13
|
+
- **Stalled sweeper no longer re-runs intentional waits.** `findStalledRunIds` now joins `automation_runs` and returns only `status = 'running'` runs, and suspend-finalisation no longer clobbers the run's `lastActionPath` checkpoint to `null`. Previously any wait longer than the stale window (>60s) was re-walked from the top every sweep cycle, re-firing pre-wait side effects and leaking wait locks. The wait-aware sweeps now also run before the stalled-run sweep.
|
|
14
|
+
- **Stalled recovery refuses a run holding a live wait lock.** `recoverStalledRun` now only recovers a genuinely-`running` run with no wait lock; a crash-mid-wait recovery is left to the wait/resume paths instead of re-walking from the top and creating a duplicate lock + duplicate delay job.
|
|
15
|
+
- **Cancelled runs can no longer resurrect.** `resumeRun` guards on `status === 'waiting'` (mirroring `checkWaitUntil`) and drops any stale lock for a non-waiting run, so `wakeWaitingRuns` / delay-expiry / a racing queue job can't wake a cancelled or terminal run. `cancelActiveRuns` (restart mode) now deletes the cancelled runs' wait locks + run-state in the same operation.
|
|
16
|
+
- **Concurrency check-then-create is serialized.** The `mode` check + `createRun` now run under a transaction-scoped advisory lock keyed on `(automationId, scope)`, so two concurrent fires can't both pass a `single`-mode "no active run" check and double-run.
|
|
17
|
+
|
|
18
|
+
Masking guarantee (now genuinely covers scope + artifacts):
|
|
19
|
+
|
|
20
|
+
- **The run-wide masking choke point now also masks the durable scope snapshot and produced artifacts.** The `RunSecretRegistry` is threaded into `RunStateStore.upsert` (masks `scopeSnapshot`) and `ArtifactStore.record` (masks `data`) so a resolved connection credential threaded into `scope.variables` or surfaced into an artifact is redacted before persist - and therefore cannot reach a read-only user via `getRunScopeForReplay`. **GUARANTEE CHANGE**: run-wide masking now covers step output, run error, scope snapshot, and artifact data for every action.
|
|
21
|
+
- **`testConnection` / `testProviderConnection` mask provider errors.** These RPCs run outside a dispatch run, so they build a per-call mask set from the resolved/submitted connection config and run any provider error through it before returning, so a provider error echoing a token can't cross back to the browser.
|
|
22
|
+
- **Short secrets surface a warning.** `setSecret` now warns when a value is shorter than `MIN_MASKABLE_LENGTH` (4) that it cannot be auto-redacted (the threshold is intentionally not lowered).
|
|
23
|
+
|
|
24
|
+
Internal:
|
|
25
|
+
|
|
26
|
+
- `@checkstack/backend-api`: `withXactLock`'s `fn` now receives the transaction handle `tx` so a critical section can run on the locked connection; the doc clarifies why running on the pool inside the lock window is still safe. The incident dedup caller's comment is corrected accordingly. `RunStore` gains `findWaitLocksByRun`.
|
|
27
|
+
|
|
28
|
+
- 270ef29: Add the Secrets platform (Phase 1): a central, plugin-agnostic secret manager with a pluggable backend extension point, a cross-plugin resolver service, and a universal Jenkins-style masking layer.
|
|
29
|
+
|
|
30
|
+
- New packages: `secrets-common` (schemas, contract, `secrets.read`/`secrets.manage`, masking utils), `secrets-backend` (`SecretBackend` extension point, `secretResolverRef`/`secretAdminRef` services, run-scoped masking context, RPC router), `secrets-backend-local` (default AES-256-GCM backend, owns the `secrets` table promoted from gitops), `secrets-frontend` (admin Settings page).
|
|
31
|
+
- Resolution machinery (`resolveSecretsBySchema`, `SecretStore`, `${{ secrets.NAME }}` / `x-secret`) is promoted out of `gitops-backend` into `secrets-backend`. GitOps now resolves and manages secrets through the platform's service refs (single source of truth); its secret table is migrated without loss.
|
|
32
|
+
- Universal masking seam wired at the central script-output boundaries: automation `run_script` / `run_shell` artifacts and the in-UI test panel redact run-scoped secret values from `result`/`stdout`/`stderr`/`error` before persist/return. Phase 1 resolves no run-scoped secrets yet, so masking is a no-op until Phase 2; the seam guarantees the boundary exists.
|
|
33
|
+
- No endpoint returns a secret value to a browser: DTOs expose only name/metadata/`hasValue`.
|
|
34
|
+
|
|
35
|
+
BREAKING CHANGES: `gitops-backend` now depends on `secrets-backend` and resolves/manages secrets through it. The `secrets` table is owned by `secrets-backend-local`; the gitops `secrets` table is retained as a migration source but is no longer the source of truth.
|
|
36
|
+
|
|
37
|
+
- 270ef29: Secrets platform Phase 4: HashiCorp Vault backend + backend selection.
|
|
38
|
+
|
|
39
|
+
- New `@checkstack/secrets-backend-vault`: a read-through `SecretBackend`
|
|
40
|
+
against Vault. Token, AppRole, and OIDC/JWT auth (session cached to the
|
|
41
|
+
lease TTL, capped); KV v2 reads mapped via the backend's own
|
|
42
|
+
`secret_index` table (name → path/key); read-through value cache with a
|
|
43
|
+
capped TTL (rotated values re-read). `list()` returns metadata only,
|
|
44
|
+
never values. Minimal typed HTTP client (no extra dependency), injectable
|
|
45
|
+
fetch for testing.
|
|
46
|
+
- Backend selection: the active backend is persisted via `ConfigService`
|
|
47
|
+
and switchable in Settings → Secrets; switching re-routes resolution.
|
|
48
|
+
New `setBackendConfig` / `testBackend` RPCs (manage-gated, status-only)
|
|
49
|
+
and `getBackendConfig` now returns Vault connection metadata
|
|
50
|
+
(`hasCredential`, never the credential). `SecretBackend` gains optional
|
|
51
|
+
`test` / `configure` / `getConfigMeta`.
|
|
52
|
+
- The Vault auth credential is stored as an `x-secret` config field
|
|
53
|
+
(encrypted at rest with the AES-GCM master key, redacted on read) —
|
|
54
|
+
bootstrapping it WITHOUT putting it in Vault. It is write-only over the
|
|
55
|
+
API and never logged.
|
|
56
|
+
- Admin UI: backend selector + Vault connection form + "Test connection".
|
|
57
|
+
|
|
58
|
+
Satellite-direct-Vault (a satellite reading Vault itself) is deferred to a
|
|
59
|
+
follow-up; core-mediated delivery already routes through the Vault backend.
|
|
60
|
+
|
|
61
|
+
- 270ef29: Secrets platform Phase 5: internal-secret consolidation (registry token) + connection-credential leak hardening.
|
|
62
|
+
|
|
63
|
+
- New `internalSecretsRef`: platform-internal secrets (not user-managed
|
|
64
|
+
named secrets) stored under a reserved `__internal__:` prefix, ALWAYS on
|
|
65
|
+
the local (always-writable, AES-GCM) backend so internal writes never
|
|
66
|
+
break when Vault is the active backend. Excluded from the user-facing
|
|
67
|
+
Secrets list.
|
|
68
|
+
- The script-package registry auth token is consolidated onto
|
|
69
|
+
`internalSecretsRef`. The `authSecretRef` column now holds a stable
|
|
70
|
+
marker; a one-time, idempotent, parity-verified migration moves legacy
|
|
71
|
+
inline ciphertext into the platform and only rewrites the column once the
|
|
72
|
+
platform copy reads back identically (legacy value never dropped early).
|
|
73
|
+
Resolution stays backward-compatible with legacy ciphertext.
|
|
74
|
+
- Integration: `createConnection` / `updateConnection` now return the
|
|
75
|
+
redacted connection preview instead of echoing the submitted credential
|
|
76
|
+
fields back in the response (leak hardening). Non-breaking — the frontend
|
|
77
|
+
refetches the redacted list and ignores the returned preview.
|
|
78
|
+
|
|
79
|
+
NOTE: integration connection-credential STORAGE is intentionally NOT
|
|
80
|
+
migrated onto the secrets platform. Connection creds are co-mingled
|
|
81
|
+
secret/non-secret config stored per-provider via `ConfigService` (which
|
|
82
|
+
already uses the same AES-GCM crypto + per-field redaction); splitting them
|
|
83
|
+
out would require per-provider schema-walking and a lossy migration across
|
|
84
|
+
live integrations for no real gain. The `ConnectionStore` API + storage are
|
|
85
|
+
unchanged.
|
|
86
|
+
|
|
87
|
+
- 270ef29: Secrets platform Phase 5b: route integration connection credentials through the ONE secrets channel.
|
|
88
|
+
|
|
89
|
+
Connection credentials now resolve through the same secrets channel as
|
|
90
|
+
everything else, so a credential can originate from Vault and there is no
|
|
91
|
+
parallel credential-resolution code to drift. Two entry forms, both walked
|
|
92
|
+
by the shared `walkSecretFields` machinery (acting only on the provider
|
|
93
|
+
`connectionSchema`'s `x-secret` fields):
|
|
94
|
+
|
|
95
|
+
- Reference form: a `${{ secrets.NAME }}` template resolves through the
|
|
96
|
+
ACTIVE backend (local or Vault) via `secretResolverRef`.
|
|
97
|
+
- Inline form: an operator-typed value is extracted into an internal
|
|
98
|
+
secret on the local backend; the stored config keeps only a reference
|
|
99
|
+
marker, resolved via `internalSecretsRef`.
|
|
100
|
+
|
|
101
|
+
The `ConnectionStore` public API is unchanged: `listConnections` /
|
|
102
|
+
`getConnection` stay redacted; `getConnectionWithCredentials` inflates via
|
|
103
|
+
the unified channel. A one-time, idempotent, parity-verified, REVERSIBLE
|
|
104
|
+
migration (backup ConfigService entry per connection; rewrites only after
|
|
105
|
+
the platform copy reads back identically) moves existing inline
|
|
106
|
+
credentials onto the platform without breaking live connections.
|
|
107
|
+
|
|
108
|
+
`secrets-backend` exports `walkSecretFields` (the shared schema-walk behind
|
|
109
|
+
`resolveSecretsBySchema`, reused for the migration extract + inflate).
|
|
110
|
+
|
|
111
|
+
BREAKING CHANGES: a connection's stored credential fields may now hold a
|
|
112
|
+
`${{ secrets.NAME }}` reference or an internal-reference marker instead of
|
|
113
|
+
an inline value. Resolution is transparent (`getConnectionWithCredentials`
|
|
114
|
+
returns the same plaintext); a legacy inline value still resolves until the
|
|
115
|
+
one-time migration converts it.
|
|
116
|
+
|
|
117
|
+
- b995afb: Hide secret write controls when the active backend is read-through.
|
|
118
|
+
|
|
119
|
+
When a read-through backend (e.g. Vault) was active, the Secrets admin page still showed the "Add a secret", Rotate, and Delete controls even though the backend correctly rejects writes (`set` / `delete` are intentionally unimplemented), so every attempt errored.
|
|
120
|
+
|
|
121
|
+
The active backend now reports a capability flag and the UI gates its write affordances on it instead of any hardcoded backend id, so other read-through backends are handled the same way.
|
|
122
|
+
|
|
123
|
+
Changes:
|
|
124
|
+
|
|
125
|
+
- `@checkstack/secrets-common`: add a `writable: boolean` field to `BackendConfigDto` (returned by `getBackendConfig`). It carries no sensitive data - a capability boolean only.
|
|
126
|
+
- `@checkstack/secrets-backend`: populate `writable` in the `getBackendConfig` handler by inspecting the resolved active backend (true only when it implements both `set` and `delete`; `false` for read-through backends or an unresolved active id). Exposes a small `isBackendWritable` helper.
|
|
127
|
+
- `@checkstack/secrets-frontend`: hide the create form, per-row Rotate / Delete buttons, and adjust the empty-state and helper text when the active backend is not writable, plus show a short "read-through" explainer. The local backend stays fully writable.
|
|
128
|
+
|
|
129
|
+
State & scale: `writable` is derived on read from the resolved active backend's capabilities (durable config selects the backend), so every pod computes the same answer; no new state is introduced.
|
|
130
|
+
|
|
131
|
+
### Patch Changes
|
|
132
|
+
|
|
133
|
+
- Updated dependencies [270ef29]
|
|
134
|
+
- Updated dependencies [270ef29]
|
|
135
|
+
- Updated dependencies [270ef29]
|
|
136
|
+
- Updated dependencies [b995afb]
|
|
137
|
+
- Updated dependencies [270ef29]
|
|
138
|
+
- Updated dependencies [270ef29]
|
|
139
|
+
- Updated dependencies [270ef29]
|
|
140
|
+
- Updated dependencies [b995afb]
|
|
141
|
+
- Updated dependencies [270ef29]
|
|
142
|
+
- Updated dependencies [270ef29]
|
|
143
|
+
- Updated dependencies [270ef29]
|
|
144
|
+
- Updated dependencies [270ef29]
|
|
145
|
+
- Updated dependencies [270ef29]
|
|
146
|
+
- Updated dependencies [b995afb]
|
|
147
|
+
- @checkstack/backend-api@0.19.0
|
|
148
|
+
- @checkstack/secrets-common@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/secrets-backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Secrets platform backend: resolver service, masking, backend extension point, RPC router",
|
|
5
|
+
"author": "Checkstack contributors",
|
|
6
|
+
"license": "Elastic-2.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"checkstack": {
|
|
15
|
+
"type": "backend",
|
|
16
|
+
"pluginId": "secrets"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"dev": "checkstack-dev",
|
|
20
|
+
"pack": "bunx @checkstack/scripts plugin-pack",
|
|
21
|
+
"typecheck": "tsgo -b",
|
|
22
|
+
"generate": "drizzle-kit generate",
|
|
23
|
+
"lint": "bun run lint:code",
|
|
24
|
+
"lint:code": "eslint . --max-warnings 0",
|
|
25
|
+
"test": "bun test"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@checkstack/backend-api": "0.18.0",
|
|
29
|
+
"@checkstack/common": "0.12.0",
|
|
30
|
+
"@checkstack/secrets-common": "0.0.1",
|
|
31
|
+
"@orpc/server": "^1.13.2",
|
|
32
|
+
"drizzle-orm": "^0.45.0",
|
|
33
|
+
"uuid": "^14.0.0",
|
|
34
|
+
"zod": "^4.2.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@checkstack/scripts": "0.3.4",
|
|
38
|
+
"@checkstack/dev-server": "2.0.0",
|
|
39
|
+
"@checkstack/backend": "0.11.0",
|
|
40
|
+
"@checkstack/tsconfig": "0.0.7",
|
|
41
|
+
"@checkstack/drizzle-helper": "0.0.5",
|
|
42
|
+
"@checkstack/test-utils-backend": "0.1.31",
|
|
43
|
+
"@types/bun": "^1.3.5",
|
|
44
|
+
"@types/node": "^20.0.0",
|
|
45
|
+
"drizzle-kit": "^0.31.10",
|
|
46
|
+
"typescript": "^5.7.2"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createSecretBackendRegistry } from "./secret-backend-registry";
|
|
3
|
+
import { createActiveBackendStore } from "./active-backend";
|
|
4
|
+
import type { SecretBackend } from "./secret-backend";
|
|
5
|
+
|
|
6
|
+
function fakeBackend(id: string, values: Record<string, string>): SecretBackend {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
get: async ({ name }) => values[name],
|
|
10
|
+
list: async () => [],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("createActiveBackendStore", () => {
|
|
15
|
+
it("resolves through whichever backend is active, and re-routes on switch", async () => {
|
|
16
|
+
const registry = createSecretBackendRegistry();
|
|
17
|
+
registry.register(fakeBackend("local", { db_pass: "local-value" }));
|
|
18
|
+
registry.register(fakeBackend("vault", { db_pass: "vault-value" }));
|
|
19
|
+
|
|
20
|
+
let active = "local";
|
|
21
|
+
const store = createActiveBackendStore({
|
|
22
|
+
backends: registry,
|
|
23
|
+
getActiveBackendId: async () => active,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(await store.resolve("db_pass")).toBe("local-value");
|
|
27
|
+
|
|
28
|
+
// Switch the active backend to vault — resolution re-routes with no
|
|
29
|
+
// other change (the Phase-4 acceptance: active_backend=vault routes
|
|
30
|
+
// resolution through Vault).
|
|
31
|
+
active = "vault";
|
|
32
|
+
expect(await store.resolve("db_pass")).toBe("vault-value");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("throws a clear error when the active backend lacks the secret", async () => {
|
|
36
|
+
const registry = createSecretBackendRegistry();
|
|
37
|
+
registry.register(fakeBackend("vault", {}));
|
|
38
|
+
const store = createActiveBackendStore({
|
|
39
|
+
backends: registry,
|
|
40
|
+
getActiveBackendId: async () => "vault",
|
|
41
|
+
});
|
|
42
|
+
await expect(store.resolve("absent")).rejects.toThrow(
|
|
43
|
+
"Secret not found: absent",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SecretBackendRegistry } from "./secret-backend-registry";
|
|
2
|
+
import type { SecretStore } from "./secret-resolver";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a {@link SecretStore} that resolves each name through whichever
|
|
6
|
+
* backend is currently active. Routing is dynamic: switching the active
|
|
7
|
+
* backend (e.g. local → vault) immediately changes where values resolve
|
|
8
|
+
* from, with no other plumbing change. Throws on a missing secret so a
|
|
9
|
+
* required reference fails clearly.
|
|
10
|
+
*/
|
|
11
|
+
export function createActiveBackendStore({
|
|
12
|
+
backends,
|
|
13
|
+
getActiveBackendId,
|
|
14
|
+
}: {
|
|
15
|
+
backends: SecretBackendRegistry;
|
|
16
|
+
getActiveBackendId: () => Promise<string>;
|
|
17
|
+
}): SecretStore {
|
|
18
|
+
return {
|
|
19
|
+
resolve: async (name: string): Promise<string> => {
|
|
20
|
+
const backend = backends.get(await getActiveBackendId());
|
|
21
|
+
const value = await backend.get({ name });
|
|
22
|
+
if (value === undefined) {
|
|
23
|
+
throw new Error(`Secret not found: ${name}`);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
+
import { MIN_MASKABLE_LENGTH } from "@checkstack/secrets-common";
|
|
3
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
4
|
+
import { createSecretAdminService } from "./admin-service";
|
|
5
|
+
import type { SecretBackend } from "./secret-backend";
|
|
6
|
+
|
|
7
|
+
function fakeBackend(): SecretBackend {
|
|
8
|
+
const store: Record<string, string> = {};
|
|
9
|
+
const backend: SecretBackend = {
|
|
10
|
+
id: "local",
|
|
11
|
+
get: async ({ name }: { name: string }) => store[name],
|
|
12
|
+
set: async ({ name, value }: { name: string; value: string }) => {
|
|
13
|
+
store[name] = value;
|
|
14
|
+
},
|
|
15
|
+
delete: async ({ name }: { name: string }) => {
|
|
16
|
+
delete store[name];
|
|
17
|
+
},
|
|
18
|
+
list: async () =>
|
|
19
|
+
Object.keys(store).map((name) => ({
|
|
20
|
+
id: name,
|
|
21
|
+
name,
|
|
22
|
+
description: null,
|
|
23
|
+
hasValue: true,
|
|
24
|
+
backend: "local",
|
|
25
|
+
createdBy: null,
|
|
26
|
+
createdAt: new Date(),
|
|
27
|
+
updatedAt: new Date(),
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
return backend;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeService(logger?: Logger) {
|
|
34
|
+
const backend = fakeBackend();
|
|
35
|
+
return createSecretAdminService({
|
|
36
|
+
getActiveBackend: async () => backend,
|
|
37
|
+
onChanged: async () => {},
|
|
38
|
+
logger,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("L1 — setSecret warns on values too short to auto-redact", () => {
|
|
43
|
+
it("warns when a value is shorter than MIN_MASKABLE_LENGTH", async () => {
|
|
44
|
+
const warn = mock();
|
|
45
|
+
const logger = { warn, info: mock(), error: mock(), debug: mock() };
|
|
46
|
+
const admin = makeService(logger as unknown as Logger);
|
|
47
|
+
|
|
48
|
+
const short = "a".repeat(MIN_MASKABLE_LENGTH - 1);
|
|
49
|
+
await admin.setSecret({ name: "API_PIN", value: short });
|
|
50
|
+
|
|
51
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
52
|
+
const message = String(warn.mock.calls[0]?.[0] ?? "");
|
|
53
|
+
expect(message).toContain("API_PIN");
|
|
54
|
+
expect(message).toContain("cannot be auto-redacted");
|
|
55
|
+
// Never echo the value itself.
|
|
56
|
+
expect(message).not.toContain(short);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does NOT warn for a value at or above the threshold", async () => {
|
|
60
|
+
const warn = mock();
|
|
61
|
+
const logger = { warn, info: mock(), error: mock(), debug: mock() };
|
|
62
|
+
const admin = makeService(logger as unknown as Logger);
|
|
63
|
+
|
|
64
|
+
await admin.setSecret({
|
|
65
|
+
name: "API_TOKEN",
|
|
66
|
+
value: "x".repeat(MIN_MASKABLE_LENGTH),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(warn).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("still stores the secret despite the warning (threshold is not changed)", async () => {
|
|
73
|
+
const admin = makeService();
|
|
74
|
+
const result = await admin.setSecret({ name: "PIN", value: "ab" });
|
|
75
|
+
expect(result.created).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInternalSecretName,
|
|
3
|
+
MIN_MASKABLE_LENGTH,
|
|
4
|
+
type SecretMetadata,
|
|
5
|
+
} from "@checkstack/secrets-common";
|
|
6
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
7
|
+
import type { SecretBackend } from "./secret-backend";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Cross-plugin secret administration service (exposed via
|
|
11
|
+
* `secretAdminRef`). Lets a consumer plugin manage secrets through the
|
|
12
|
+
* active backend so there is a SINGLE source of truth — e.g. gitops
|
|
13
|
+
* delegates its legacy secret-management RPCs here instead of writing its
|
|
14
|
+
* own table.
|
|
15
|
+
*
|
|
16
|
+
* `list` returns metadata only (never values). `setSecret` is write-only.
|
|
17
|
+
* No method returns a value.
|
|
18
|
+
*/
|
|
19
|
+
export interface SecretAdminService {
|
|
20
|
+
list(): Promise<SecretMetadata[]>;
|
|
21
|
+
setSecret(input: {
|
|
22
|
+
name: string;
|
|
23
|
+
value: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
createdBy?: string;
|
|
26
|
+
}): Promise<{ created: boolean }>;
|
|
27
|
+
deleteSecret(input: { name: string }): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createSecretAdminService({
|
|
31
|
+
getActiveBackend,
|
|
32
|
+
onChanged,
|
|
33
|
+
logger,
|
|
34
|
+
}: {
|
|
35
|
+
getActiveBackend: () => Promise<SecretBackend>;
|
|
36
|
+
onChanged: (input: {
|
|
37
|
+
name: string;
|
|
38
|
+
change: "created" | "rotated" | "deleted";
|
|
39
|
+
}) => Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Optional logger so `setSecret` can warn when a value is too short to be
|
|
42
|
+
* auto-redacted (see L1 below). Optional so existing call sites / tests
|
|
43
|
+
* that don't pass one degrade silently.
|
|
44
|
+
*/
|
|
45
|
+
logger?: Logger;
|
|
46
|
+
}): SecretAdminService {
|
|
47
|
+
return {
|
|
48
|
+
async list() {
|
|
49
|
+
const backend = await getActiveBackend();
|
|
50
|
+
const all = await backend.list();
|
|
51
|
+
// Hide platform-internal secrets (registry token, connection creds)
|
|
52
|
+
// from the user-facing list — they aren't user-managed named secrets.
|
|
53
|
+
return all.filter((m) => !isInternalSecretName(m.name));
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async setSecret({ name, value, description, createdBy }) {
|
|
57
|
+
const backend = await getActiveBackend();
|
|
58
|
+
if (!backend.set) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Backend "${backend.id}" is read-only; manage secrets in the external store.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
// Values below MIN_MASKABLE_LENGTH can't be auto-redacted by the
|
|
64
|
+
// by-value masker (they'd over-mask coincidental substrings of normal
|
|
65
|
+
// output). Don't silently change the threshold — warn so the operator
|
|
66
|
+
// knows this secret won't be scrubbed from run logs / errors if it
|
|
67
|
+
// ever surfaces. (Length only; never echo the value.)
|
|
68
|
+
if (value.length < MIN_MASKABLE_LENGTH) {
|
|
69
|
+
logger?.warn(
|
|
70
|
+
`Secret "${name}" is ${value.length} character(s) long; values shorter than ${MIN_MASKABLE_LENGTH} cannot be auto-redacted from logs or error output. Consider a longer value.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const existing = await backend.list();
|
|
74
|
+
const created = !existing.some((m) => m.name === name);
|
|
75
|
+
await backend.set({ name, value, description, createdBy });
|
|
76
|
+
await onChanged({ name, change: created ? "created" : "rotated" });
|
|
77
|
+
return { created };
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async deleteSecret({ name }) {
|
|
81
|
+
const backend = await getActiveBackend();
|
|
82
|
+
if (!backend.delete) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Backend "${backend.id}" is read-only; manage secrets in the external store.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
await backend.delete({ name });
|
|
88
|
+
await onChanged({ name, change: "deleted" });
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { configString, type ConfigService } from "@checkstack/backend-api";
|
|
3
|
+
import { vaultAuthMethodSchema } from "@checkstack/secrets-common";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Persisted backend configuration, stored via the platform `ConfigService`
|
|
7
|
+
* (a single config row keyed by {@link BACKEND_CONFIG_ID}). The Vault auth
|
|
8
|
+
* `credential` is marked `x-secret`, so ConfigService encrypts it at rest
|
|
9
|
+
* with the AES-GCM master key (env-provided) and `getRedacted` strips it —
|
|
10
|
+
* this is how we bootstrap the Vault auth secret WITHOUT putting it in
|
|
11
|
+
* Vault (the chicken/egg in the plan). The credential is never returned to
|
|
12
|
+
* a browser and never logged.
|
|
13
|
+
*/
|
|
14
|
+
export const BACKEND_CONFIG_ID = "backend-config";
|
|
15
|
+
export const BACKEND_CONFIG_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Schema for ConfigService storage. Uses `configString({ "x-secret": true })`
|
|
19
|
+
* for the credential so it is encrypted at rest + redacted on safe reads.
|
|
20
|
+
*/
|
|
21
|
+
export const storedBackendConfigSchema = z.object({
|
|
22
|
+
activeBackend: z.string().default("local"),
|
|
23
|
+
vault: z
|
|
24
|
+
.object({
|
|
25
|
+
address: z.string(),
|
|
26
|
+
mount: z.string().default("secret"),
|
|
27
|
+
authMethod: vaultAuthMethodSchema,
|
|
28
|
+
role: z.string().optional(),
|
|
29
|
+
authMount: z.string().optional(),
|
|
30
|
+
namespace: z.string().optional(),
|
|
31
|
+
/** Encrypted at rest; stripped by getRedacted. */
|
|
32
|
+
credential: configString({ "x-secret": true }).optional(),
|
|
33
|
+
})
|
|
34
|
+
.optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type StoredBackendConfig = z.infer<typeof storedBackendConfigSchema>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Thin store over ConfigService for the backend config. `load` decrypts the
|
|
41
|
+
* credential (service-only, for resolving against Vault); `loadRedacted`
|
|
42
|
+
* omits it (safe for the frontend DTO).
|
|
43
|
+
*/
|
|
44
|
+
export interface BackendConfigStore {
|
|
45
|
+
load(): Promise<StoredBackendConfig | undefined>;
|
|
46
|
+
loadRedacted(): Promise<Partial<StoredBackendConfig> | undefined>;
|
|
47
|
+
save(config: StoredBackendConfig): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createBackendConfigStore({
|
|
51
|
+
config,
|
|
52
|
+
}: {
|
|
53
|
+
config: ConfigService;
|
|
54
|
+
}): BackendConfigStore {
|
|
55
|
+
return {
|
|
56
|
+
load: () =>
|
|
57
|
+
config.get(
|
|
58
|
+
BACKEND_CONFIG_ID,
|
|
59
|
+
storedBackendConfigSchema,
|
|
60
|
+
BACKEND_CONFIG_VERSION,
|
|
61
|
+
),
|
|
62
|
+
loadRedacted: () =>
|
|
63
|
+
config.getRedacted(
|
|
64
|
+
BACKEND_CONFIG_ID,
|
|
65
|
+
storedBackendConfigSchema,
|
|
66
|
+
BACKEND_CONFIG_VERSION,
|
|
67
|
+
),
|
|
68
|
+
save: (data) =>
|
|
69
|
+
config.set(
|
|
70
|
+
BACKEND_CONFIG_ID,
|
|
71
|
+
storedBackendConfigSchema,
|
|
72
|
+
BACKEND_CONFIG_VERSION,
|
|
73
|
+
data,
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHook } from "@checkstack/backend-api";
|
|
2
|
+
import {
|
|
3
|
+
SECRETS_CHANGED_HOOK_ID,
|
|
4
|
+
type SecretsChangedPayload,
|
|
5
|
+
} from "@checkstack/secrets-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Backend hook fired when a secret is created, rotated, or deleted.
|
|
9
|
+
* Consumers (e.g. gitops) subscribe to re-reconcile entities that
|
|
10
|
+
* reference the changed secret.
|
|
11
|
+
*/
|
|
12
|
+
export const secretsChangedHook = createHook<SecretsChangedPayload>(
|
|
13
|
+
SECRETS_CHANGED_HOOK_ID,
|
|
14
|
+
);
|