@checkstack/secrets-backend-local 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 ADDED
@@ -0,0 +1,35 @@
1
+ # @checkstack/secrets-backend-local
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ - 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).
10
+ - 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.
11
+ - 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.
12
+ - No endpoint returns a secret value to a browser: DTOs expose only name/metadata/`hasValue`.
13
+
14
+ 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.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [270ef29]
19
+ - Updated dependencies [270ef29]
20
+ - Updated dependencies [270ef29]
21
+ - Updated dependencies [b995afb]
22
+ - Updated dependencies [270ef29]
23
+ - Updated dependencies [270ef29]
24
+ - Updated dependencies [270ef29]
25
+ - Updated dependencies [b995afb]
26
+ - Updated dependencies [270ef29]
27
+ - Updated dependencies [270ef29]
28
+ - Updated dependencies [270ef29]
29
+ - Updated dependencies [270ef29]
30
+ - Updated dependencies [270ef29]
31
+ - Updated dependencies [270ef29]
32
+ - Updated dependencies [b995afb]
33
+ - @checkstack/backend-api@0.19.0
34
+ - @checkstack/secrets-backend@0.1.0
35
+ - @checkstack/secrets-common@0.1.0
@@ -0,0 +1,10 @@
1
+ CREATE TABLE "secrets" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "name" text NOT NULL,
4
+ "encrypted_value" text NOT NULL,
5
+ "description" text,
6
+ "created_by" text,
7
+ "created_at" timestamp DEFAULT now() NOT NULL,
8
+ "updated_at" timestamp DEFAULT now() NOT NULL,
9
+ CONSTRAINT "secrets_name_unique" UNIQUE("name")
10
+ );
@@ -0,0 +1,20 @@
1
+ -- Promote existing GitOps secrets into the central local secret backend.
2
+ --
3
+ -- The legacy GitOps `secrets` table lives in the `plugin_gitops` Postgres
4
+ -- schema (see getPluginSchemaName). This copies its rows into this
5
+ -- backend's `secrets` table WITHOUT loss: it is guarded by to_regclass so a
6
+ -- fresh install (no gitops table) is a no-op, and ON CONFLICT (name) keeps
7
+ -- any already-present row. The source table is intentionally NOT dropped —
8
+ -- gitops switches to reading via the secretResolverRef, and leaving its
9
+ -- table in place means no rows can be lost if plugin migration order ever
10
+ -- runs gitops after this one.
11
+ DO $$
12
+ BEGIN
13
+ IF to_regclass('"plugin_gitops".secrets') IS NOT NULL THEN
14
+ INSERT INTO secrets (id, name, encrypted_value, description, created_by, created_at, updated_at)
15
+ SELECT id, name, encrypted_value, description, created_by, created_at, updated_at
16
+ FROM "plugin_gitops".secrets
17
+ ON CONFLICT (name) DO NOTHING;
18
+ END IF;
19
+ END
20
+ $$;
@@ -0,0 +1,84 @@
1
+ {
2
+ "id": "262e0256-7c2f-4736-a72a-0e7e00d9d057",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.secrets": {
8
+ "name": "secrets",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "encrypted_value": {
24
+ "name": "encrypted_value",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "description": {
30
+ "name": "description",
31
+ "type": "text",
32
+ "primaryKey": false,
33
+ "notNull": false
34
+ },
35
+ "created_by": {
36
+ "name": "created_by",
37
+ "type": "text",
38
+ "primaryKey": false,
39
+ "notNull": false
40
+ },
41
+ "created_at": {
42
+ "name": "created_at",
43
+ "type": "timestamp",
44
+ "primaryKey": false,
45
+ "notNull": true,
46
+ "default": "now()"
47
+ },
48
+ "updated_at": {
49
+ "name": "updated_at",
50
+ "type": "timestamp",
51
+ "primaryKey": false,
52
+ "notNull": true,
53
+ "default": "now()"
54
+ }
55
+ },
56
+ "indexes": {},
57
+ "foreignKeys": {},
58
+ "compositePrimaryKeys": {},
59
+ "uniqueConstraints": {
60
+ "secrets_name_unique": {
61
+ "name": "secrets_name_unique",
62
+ "nullsNotDistinct": false,
63
+ "columns": [
64
+ "name"
65
+ ]
66
+ }
67
+ },
68
+ "policies": {},
69
+ "checkConstraints": {},
70
+ "isRLSEnabled": false
71
+ }
72
+ },
73
+ "enums": {},
74
+ "schemas": {},
75
+ "sequences": {},
76
+ "roles": {},
77
+ "policies": {},
78
+ "views": {},
79
+ "_meta": {
80
+ "columns": {},
81
+ "schemas": {},
82
+ "tables": {}
83
+ }
84
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "id": "f2d2e3ed-c3e7-488b-b34b-1cacfcf43ac0",
3
+ "prevId": "262e0256-7c2f-4736-a72a-0e7e00d9d057",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.secrets": {
8
+ "name": "secrets",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "encrypted_value": {
24
+ "name": "encrypted_value",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": true
28
+ },
29
+ "description": {
30
+ "name": "description",
31
+ "type": "text",
32
+ "primaryKey": false,
33
+ "notNull": false
34
+ },
35
+ "created_by": {
36
+ "name": "created_by",
37
+ "type": "text",
38
+ "primaryKey": false,
39
+ "notNull": false
40
+ },
41
+ "created_at": {
42
+ "name": "created_at",
43
+ "type": "timestamp",
44
+ "primaryKey": false,
45
+ "notNull": true,
46
+ "default": "now()"
47
+ },
48
+ "updated_at": {
49
+ "name": "updated_at",
50
+ "type": "timestamp",
51
+ "primaryKey": false,
52
+ "notNull": true,
53
+ "default": "now()"
54
+ }
55
+ },
56
+ "indexes": {},
57
+ "foreignKeys": {},
58
+ "compositePrimaryKeys": {},
59
+ "uniqueConstraints": {
60
+ "secrets_name_unique": {
61
+ "name": "secrets_name_unique",
62
+ "nullsNotDistinct": false,
63
+ "columns": [
64
+ "name"
65
+ ]
66
+ }
67
+ },
68
+ "policies": {},
69
+ "checkConstraints": {},
70
+ "isRLSEnabled": false
71
+ }
72
+ },
73
+ "enums": {},
74
+ "schemas": {},
75
+ "sequences": {},
76
+ "roles": {},
77
+ "policies": {},
78
+ "views": {},
79
+ "_meta": {
80
+ "columns": {},
81
+ "schemas": {},
82
+ "tables": {}
83
+ }
84
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1780163271755,
9
+ "tag": "0000_dusty_sandman",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1780163349834,
16
+ "tag": "0001_promote_gitops_secrets",
17
+ "breakpoints": true
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@checkstack/secrets-backend-local",
3
+ "version": "0.1.0",
4
+ "description": "Default local secret backend: AES-256-GCM values in Postgres",
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-backend-local"
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
+ "@checkstack/secrets-backend": "0.0.1",
32
+ "drizzle-orm": "^0.45.0",
33
+ "uuid": "^14.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@checkstack/scripts": "0.3.4",
37
+ "@checkstack/dev-server": "2.0.0",
38
+ "@checkstack/backend": "0.11.0",
39
+ "@checkstack/tsconfig": "0.0.7",
40
+ "@checkstack/drizzle-helper": "0.0.5",
41
+ "@checkstack/test-utils-backend": "0.1.31",
42
+ "@types/bun": "^1.3.5",
43
+ "@types/node": "^20.0.0",
44
+ "drizzle-kit": "^0.31.10",
45
+ "typescript": "^5.7.2"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
2
+ import { secretBackendExtensionPoint } from "@checkstack/secrets-backend";
3
+ import { pluginMetadata } from "./plugin-metadata";
4
+ import {
5
+ createLocalSecretBackend,
6
+ LOCAL_SECRET_BACKEND_ID,
7
+ } from "./store";
8
+ import * as schema from "./schema";
9
+
10
+ /**
11
+ * Default secret backend: AES-256-GCM values in Postgres. Always
12
+ * available; the active backend when no external backend (Vault) is
13
+ * configured.
14
+ *
15
+ * Owns the `secrets` table (promoted from gitops — see ./drizzle). The
16
+ * backend is built in `init()` (where `database` is injected) and
17
+ * registered with the host's `secretBackendExtensionPoint`. The host runs
18
+ * its `register()` first (dep ordering), so the extension point exists by
19
+ * the time this init runs.
20
+ */
21
+ export default createBackendPlugin({
22
+ metadata: pluginMetadata,
23
+
24
+ register(env) {
25
+ env.registerInit({
26
+ schema,
27
+ deps: {
28
+ logger: coreServices.logger,
29
+ },
30
+ init: async ({ logger, database }) => {
31
+ const backend = createLocalSecretBackend({ db: database });
32
+ env
33
+ .getExtensionPoint(secretBackendExtensionPoint)
34
+ .registerSecretBackend(backend, pluginMetadata);
35
+ logger.debug(
36
+ `🔐 Registered "${LOCAL_SECRET_BACKEND_ID}" secret backend.`,
37
+ );
38
+ },
39
+ });
40
+ },
41
+ });
42
+
43
+ export {
44
+ createLocalSecretBackend,
45
+ LOCAL_SECRET_BACKEND_ID,
46
+ } from "./store";
47
+ export * as schema from "./schema";
@@ -0,0 +1,5 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ export const pluginMetadata = definePluginMetadata({
4
+ pluginId: "secrets-backend-local",
5
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
2
+
3
+ /**
4
+ * Local secret store: AES-256-GCM encrypted values keyed by unique name.
5
+ * Promoted from the GitOps `secrets` table — the gitops migration copies
6
+ * existing rows into this table (see ./drizzle migrations).
7
+ *
8
+ * NEVER expose `encryptedValue` or a decrypted value through any RPC/DTO.
9
+ */
10
+ export const secrets = pgTable("secrets", {
11
+ id: text("id").primaryKey(),
12
+ /** Unique name referenced via `${{ secrets.NAME }}`. */
13
+ name: text("name").notNull().unique(),
14
+ /** AES-256-GCM encrypted value (iv:authTag:ciphertext). */
15
+ encryptedValue: text("encrypted_value").notNull(),
16
+ description: text("description"),
17
+ createdBy: text("created_by"),
18
+ createdAt: timestamp("created_at").defaultNow().notNull(),
19
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
20
+ });
@@ -0,0 +1,56 @@
1
+ // AES-256 needs a 32-byte (64 hex char) key. Set before importing the
2
+ // encryption module so module-eval-time encrypt() calls succeed.
3
+ process.env.ENCRYPTION_MASTER_KEY = "11".repeat(32);
4
+
5
+ import { describe, it, expect } from "bun:test";
6
+ import { encrypt, decrypt } from "@checkstack/backend-api";
7
+ import { toSecretMetadata, LOCAL_SECRET_BACKEND_ID } from "./store";
8
+ import type * as schema from "./schema";
9
+
10
+ describe("local backend encryption round-trip", () => {
11
+ it("encrypts then decrypts a value back to the original", () => {
12
+ const value = "gh_aBcDeF123456-with-special/chars=&";
13
+ const stored = encrypt(value);
14
+ expect(stored).not.toContain(value);
15
+ expect(decrypt(stored)).toBe(value);
16
+ });
17
+
18
+ it("produces a different ciphertext per encrypt (random IV)", () => {
19
+ const a = encrypt("same-plaintext-value");
20
+ const b = encrypt("same-plaintext-value");
21
+ expect(a).not.toBe(b);
22
+ expect(decrypt(a)).toBe("same-plaintext-value");
23
+ expect(decrypt(b)).toBe("same-plaintext-value");
24
+ });
25
+ });
26
+
27
+ describe("toSecretMetadata (no-value-leak guarantee)", () => {
28
+ const baseRow: typeof schema.secrets.$inferSelect = {
29
+ id: "id-1",
30
+ name: "api_key",
31
+ encryptedValue: encrypt("supersecretvalue"),
32
+ description: "An API key",
33
+ createdBy: "user-1",
34
+ createdAt: new Date("2026-01-01T00:00:00Z"),
35
+ updatedAt: new Date("2026-01-02T00:00:00Z"),
36
+ };
37
+
38
+ it("exposes metadata + hasValue but never the value", () => {
39
+ const meta = toSecretMetadata(baseRow);
40
+ expect(meta.name).toBe("api_key");
41
+ expect(meta.hasValue).toBe(true);
42
+ expect(meta.backend).toBe(LOCAL_SECRET_BACKEND_ID);
43
+ // The serialized DTO must not contain the value or the ciphertext.
44
+ const serialized = JSON.stringify(meta);
45
+ expect(serialized).not.toContain("supersecretvalue");
46
+ expect(serialized).not.toContain(baseRow.encryptedValue);
47
+ // The metadata shape has no `value` / `encryptedValue` keys.
48
+ expect("value" in meta).toBe(false);
49
+ expect("encryptedValue" in meta).toBe(false);
50
+ });
51
+
52
+ it("reports hasValue=false for an empty stored value", () => {
53
+ const meta = toSecretMetadata({ ...baseRow, encryptedValue: "" });
54
+ expect(meta.hasValue).toBe(false);
55
+ });
56
+ });
package/src/store.ts ADDED
@@ -0,0 +1,90 @@
1
+ import { encrypt, decrypt, type SafeDatabase } from "@checkstack/backend-api";
2
+ import type { SecretBackend } from "@checkstack/secrets-backend";
3
+ import type { SecretMetadata } from "@checkstack/secrets-common";
4
+ import { eq } from "drizzle-orm";
5
+ import { v4 as uuidv4 } from "uuid";
6
+ import * as schema from "./schema";
7
+
8
+ /** Stable id for the default local backend. */
9
+ export const LOCAL_SECRET_BACKEND_ID = "local";
10
+
11
+ type Db = SafeDatabase<typeof schema>;
12
+
13
+ type SecretRow = typeof schema.secrets.$inferSelect;
14
+
15
+ /**
16
+ * Map a stored row to its public metadata. Pure + exported so the
17
+ * no-value-leak guarantee is unit-testable: the result carries `hasValue`
18
+ * but never the encrypted/decrypted value.
19
+ */
20
+ export function toSecretMetadata(row: SecretRow): SecretMetadata {
21
+ return {
22
+ id: row.id,
23
+ name: row.name,
24
+ description: row.description,
25
+ hasValue: row.encryptedValue.length > 0,
26
+ backend: LOCAL_SECRET_BACKEND_ID,
27
+ createdBy: row.createdBy,
28
+ createdAt: row.createdAt,
29
+ updatedAt: row.updatedAt,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Build the default local {@link SecretBackend}: AES-256-GCM values in the
35
+ * `secrets` table. `set`/`delete` are supported (read-write); `get`
36
+ * decrypts a single value; `list` returns metadata only (never values).
37
+ */
38
+ export function createLocalSecretBackend({ db }: { db: Db }): SecretBackend {
39
+ return {
40
+ id: LOCAL_SECRET_BACKEND_ID,
41
+
42
+ async get({ name }) {
43
+ const rows = await db
44
+ .select()
45
+ .from(schema.secrets)
46
+ .where(eq(schema.secrets.name, name));
47
+ const row = rows[0];
48
+ if (!row) return;
49
+ return decrypt(row.encryptedValue);
50
+ },
51
+
52
+ async set({ name, value, description, createdBy }) {
53
+ const encryptedValue = encrypt(value);
54
+ const existing = await db
55
+ .select()
56
+ .from(schema.secrets)
57
+ .where(eq(schema.secrets.name, name));
58
+
59
+ if (existing[0]) {
60
+ await db
61
+ .update(schema.secrets)
62
+ .set({
63
+ encryptedValue,
64
+ // Only overwrite description when explicitly provided.
65
+ ...(description === undefined ? {} : { description }),
66
+ updatedAt: new Date(),
67
+ })
68
+ .where(eq(schema.secrets.name, name));
69
+ return;
70
+ }
71
+
72
+ await db.insert(schema.secrets).values({
73
+ id: uuidv4(),
74
+ name,
75
+ encryptedValue,
76
+ description: description ?? null,
77
+ createdBy: createdBy ?? null,
78
+ });
79
+ },
80
+
81
+ async delete({ name }) {
82
+ await db.delete(schema.secrets).where(eq(schema.secrets.name, name));
83
+ },
84
+
85
+ async list(): Promise<SecretMetadata[]> {
86
+ const rows = await db.select().from(schema.secrets);
87
+ return rows.map((row) => toSecretMetadata(row));
88
+ },
89
+ };
90
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ],
6
+ "references": [
7
+ {
8
+ "path": "../backend"
9
+ },
10
+ {
11
+ "path": "../backend-api"
12
+ },
13
+ {
14
+ "path": "../common"
15
+ },
16
+ {
17
+ "path": "../dev-server"
18
+ },
19
+ {
20
+ "path": "../drizzle-helper"
21
+ },
22
+ {
23
+ "path": "../secrets-backend"
24
+ },
25
+ {
26
+ "path": "../secrets-common"
27
+ },
28
+ {
29
+ "path": "../test-utils-backend"
30
+ }
31
+ ]
32
+ }