@sanoma-ai/workflows 0.10.7 → 0.10.12

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.
Files changed (54) hide show
  1. package/CLOUD-IDENTITY.md +25 -5
  2. package/KMS-SECRETS.md +75 -11
  3. package/README.md +24 -12
  4. package/dist/client.js +3 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/collection.d.ts +2 -0
  7. package/dist/collection.js +57 -8
  8. package/dist/collection.js.map +1 -1
  9. package/dist/config.d.ts +3 -2
  10. package/dist/config.js +4 -1
  11. package/dist/config.js.map +1 -1
  12. package/dist/connection.d.ts +2 -0
  13. package/dist/connection.js +4 -0
  14. package/dist/connection.js.map +1 -1
  15. package/dist/credentials.d.ts +1 -1
  16. package/dist/credentials.js +21 -2
  17. package/dist/credentials.js.map +1 -1
  18. package/dist/identity-provisioning.d.ts +11 -1
  19. package/dist/identity-provisioning.js +59 -0
  20. package/dist/identity-provisioning.js.map +1 -1
  21. package/dist/identity-service.js +1 -1
  22. package/dist/identity-service.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/kms-secrets.d.ts +19 -4
  26. package/dist/kms-secrets.js +70 -11
  27. package/dist/kms-secrets.js.map +1 -1
  28. package/dist/runtime-ops.d.ts +31 -1
  29. package/dist/runtime-ops.js +30 -1
  30. package/dist/runtime-ops.js.map +1 -1
  31. package/dist/secret-store.d.ts +21 -2
  32. package/dist/secret-store.js +18 -3
  33. package/dist/secret-store.js.map +1 -1
  34. package/dist/shared.d.ts +2 -0
  35. package/dist/storage-provisioning.d.ts +25 -0
  36. package/dist/storage-provisioning.js +221 -0
  37. package/dist/storage-provisioning.js.map +1 -0
  38. package/dist/worker.js +9 -2
  39. package/dist/worker.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/client.ts +3 -0
  42. package/src/collection.ts +62 -8
  43. package/src/config.ts +10 -3
  44. package/src/connection.ts +5 -0
  45. package/src/credentials.ts +29 -2
  46. package/src/identity-provisioning.ts +64 -1
  47. package/src/identity-service.ts +1 -1
  48. package/src/index.ts +1 -0
  49. package/src/kms-secrets.ts +90 -13
  50. package/src/runtime-ops.ts +37 -0
  51. package/src/secret-store.ts +34 -3
  52. package/src/shared.ts +1 -1
  53. package/src/storage-provisioning.ts +257 -0
  54. package/src/worker.ts +9 -2
@@ -0,0 +1,257 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Client, escapeIdentifier } from "pg";
3
+ import { KMS_SECRETS_SQL } from "./kms-secrets.ts";
4
+ import { generateSecret, type Secrets } from "./secret.ts";
5
+
6
+ export interface PrepareSecretStore {
7
+ company: string;
8
+ environment: string;
9
+ database: { $secret: string };
10
+ store: string;
11
+ }
12
+ export interface PreparedSecretStore {
13
+ company: string;
14
+ environment: string;
15
+ role: string;
16
+ store: string;
17
+ }
18
+ export type PrepareStorage = (input: PrepareSecretStore, secrets: Secrets) => Promise<PreparedSecretStore>;
19
+
20
+ export class StorageProvisioningError extends Error {
21
+ readonly code: "invalid" | "conflict" | "denied" | "unavailable";
22
+ constructor(code: StorageProvisioningError["code"]) {
23
+ super(`Storage provisioning: ${code}`);
24
+ this.name = "StorageProvisioningError";
25
+ this.code = code;
26
+ }
27
+ }
28
+ export function storageProvisioningCode(error: unknown): StorageProvisioningError["code"] | undefined {
29
+ if (
30
+ !error ||
31
+ typeof error !== "object" ||
32
+ !("name" in error) ||
33
+ error.name !== "StorageProvisioningError" ||
34
+ !("code" in error)
35
+ )
36
+ return;
37
+ switch (error.code) {
38
+ case "invalid":
39
+ case "conflict":
40
+ case "denied":
41
+ case "unavailable":
42
+ return error.code;
43
+ }
44
+ }
45
+ const fail = (code: StorageProvisioningError["code"]): never => {
46
+ throw new StorageProvisioningError(code);
47
+ };
48
+ const digest = (value: string) => createHash("sha256").update(value).digest("hex");
49
+ const ROLE_PREFIX = "sanoma_runtime_";
50
+ const BINDING_SQL = `CREATE TABLE IF NOT EXISTS sanoma_kms.deployment (
51
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
52
+ company text NOT NULL, environment text NOT NULL, runtime_role text NOT NULL,
53
+ credential_hash text NOT NULL
54
+ );
55
+ REVOKE ALL ON sanoma_kms.deployment FROM PUBLIC;`;
56
+
57
+ // Neon requires the original password, not a client-computed SCRAM verifier. Bind it
58
+ // as a parameter to a session-private invoker function: it stays out of submitted
59
+ // SQL statement text and Postgres performs the password hashing. The trusted database
60
+ // receives the value over verified TLS; this is not protection against its administrator.
61
+ const CREATE_RUNTIME_SQL = `CREATE FUNCTION pg_temp.sanoma_create_runtime(role_name text, role_password text)
62
+ RETURNS void LANGUAGE plpgsql AS $body$
63
+ BEGIN
64
+ EXECUTE pg_catalog.format(
65
+ 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS PASSWORD %L',
66
+ role_name, role_password);
67
+ EXCEPTION WHEN OTHERS THEN
68
+ RAISE EXCEPTION 'Runtime role creation failed' USING ERRCODE = SQLSTATE;
69
+ END
70
+ $body$;`;
71
+
72
+ async function credentials(input: PrepareSecretStore, secrets: Secrets) {
73
+ if (
74
+ ![input.company, input.environment].every(
75
+ (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && !v.includes("\0"),
76
+ ) ||
77
+ !/^[A-Z][A-Z0-9_]*$/.test(input.database.$secret) ||
78
+ !/^[A-Z][A-Z0-9_]*$/.test(input.store) ||
79
+ input.store.startsWith("SANOMA_") ||
80
+ input.store === input.database.$secret
81
+ )
82
+ fail("invalid");
83
+ const source = await secrets.get(input.database.$secret);
84
+ if (!source) return fail("invalid");
85
+ let admin: URL;
86
+ try {
87
+ admin = new URL(source);
88
+ } catch {
89
+ return fail("invalid");
90
+ }
91
+ if (
92
+ !["postgres:", "postgresql:"].includes(admin.protocol) ||
93
+ !admin.hostname ||
94
+ !admin.username ||
95
+ !admin.password ||
96
+ !/^\/[^/]+$/.test(admin.pathname) ||
97
+ ["postgres", "template0", "template1"].includes(decodeURIComponent(admin.pathname.slice(1))) ||
98
+ admin.hash ||
99
+ [...admin.searchParams.keys()].some((key) => !["sslmode", "channel_binding"].includes(key))
100
+ )
101
+ fail("invalid");
102
+ if (!["localhost", "127.0.0.1", "[::1]"].includes(admin.hostname)) admin.searchParams.set("sslmode", "verify-full");
103
+ const role = `${ROLE_PREFIX}${digest(JSON.stringify([input.company, input.environment])).slice(0, 24)}`;
104
+ const expected = new URL(admin);
105
+ expected.username = role;
106
+ expected.password = "";
107
+ // Persist before any database mutation: retries after a lost response retain the same password.
108
+ const value = await secrets.ensure(input.store, () => {
109
+ const generated = new URL(expected);
110
+ generated.password = generateSecret();
111
+ return generated.href;
112
+ });
113
+ let runtime: URL;
114
+ try {
115
+ runtime = new URL(value);
116
+ } catch {
117
+ return fail("conflict");
118
+ }
119
+ const password = runtime.password;
120
+ runtime.password = "";
121
+ if (runtime.href !== expected.href || !/^[A-Za-z0-9_-]{43}$/.test(password)) fail("conflict");
122
+ return {
123
+ admin: admin.href,
124
+ runtime: value,
125
+ role,
126
+ password,
127
+ receipt: { company: input.company, environment: input.environment, role, store: input.store },
128
+ };
129
+ }
130
+
131
+ async function connected<T>(url: string, run: (db: Client) => Promise<T>): Promise<T> {
132
+ const db = new Client({
133
+ connectionString: url,
134
+ connectionTimeoutMillis: 10_000,
135
+ query_timeout: 15_000,
136
+ statement_timeout: 15_000,
137
+ });
138
+ db.on("error", () => {
139
+ /* Never log database credentials or SQL diagnostics. */
140
+ });
141
+ try {
142
+ await db.connect();
143
+ return await run(db);
144
+ } finally {
145
+ await db.end().catch(() => {});
146
+ }
147
+ }
148
+
149
+ /** Privileged, company-scoped preparation only; no credential values escape this trusted boundary. */
150
+ export const prepareSecretStore: PrepareStorage = async (input, secrets) => {
151
+ try {
152
+ const prepared = await credentials(input, secrets);
153
+ const { company, environment } = input;
154
+ const hash = digest(prepared.password),
155
+ role = escapeIdentifier(prepared.role);
156
+ await connected(prepared.admin, async (db) => {
157
+ await db.query("BEGIN");
158
+ try {
159
+ await db.query("SELECT pg_advisory_xact_lock(hashtext('sanoma.prepareSecretStore'))");
160
+ await db.query(KMS_SECRETS_SQL);
161
+ await db.query(BINDING_SQL);
162
+ const existing = (
163
+ await db.query<{ company: string; environment: string; runtime_role: string; credential_hash: string }>(
164
+ "SELECT company,environment,runtime_role,credential_hash FROM sanoma_kms.deployment WHERE singleton=true FOR UPDATE",
165
+ )
166
+ ).rows[0];
167
+ if (existing) {
168
+ if (
169
+ existing.company !== company ||
170
+ existing.environment !== environment ||
171
+ existing.runtime_role !== prepared.role ||
172
+ existing.credential_hash !== hash
173
+ )
174
+ fail("conflict");
175
+ } else {
176
+ if (
177
+ (await db.query("SELECT 1 FROM pg_roles WHERE rolname=$1", [prepared.role])).rowCount ||
178
+ (await db.query("SELECT 1 FROM sanoma_kms.secrets LIMIT 1")).rowCount
179
+ )
180
+ fail("conflict");
181
+ await db.query("SET LOCAL password_encryption = 'scram-sha-256'");
182
+ await db.query(CREATE_RUNTIME_SQL);
183
+ await db.query("SELECT pg_temp.sanoma_create_runtime($1, $2)", [prepared.role, prepared.password]);
184
+ await db.query(
185
+ "INSERT INTO sanoma_kms.deployment(company,environment,runtime_role,credential_hash) VALUES($1,$2,$3,$4)",
186
+ [company, environment, prepared.role, hash],
187
+ );
188
+ }
189
+ // Never silently repair/adopt an unexpectedly privileged role on a retry.
190
+ const authority = (
191
+ await db.query<{ safe: boolean }>(
192
+ `SELECT (
193
+ NOT r.rolsuper AND NOT r.rolcreatedb AND NOT r.rolcreaterole AND NOT r.rolreplication AND NOT r.rolbypassrls
194
+ AND r.rolcanlogin AND NOT r.rolinherit
195
+ AND NOT EXISTS(SELECT 1 FROM pg_auth_members WHERE member=r.oid)
196
+ AND NOT EXISTS(SELECT 1 FROM pg_database WHERE datdba=r.oid)
197
+ AND NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname='sanoma_kms' AND nspowner=r.oid)
198
+ ) AS safe FROM pg_roles r WHERE rolname=$1`,
199
+ [prepared.role],
200
+ )
201
+ ).rows[0];
202
+ if (!authority?.safe) fail("denied");
203
+ const database = (await db.query<{ name: string }>("SELECT current_database() AS name")).rows[0]!.name;
204
+ await db.query(`REVOKE ALL ON DATABASE ${escapeIdentifier(database)} FROM PUBLIC`);
205
+ await db.query(`GRANT CONNECT, CREATE, TEMPORARY ON DATABASE ${escapeIdentifier(database)} TO ${role}`);
206
+ await db.query("REVOKE ALL ON SCHEMA public FROM PUBLIC");
207
+ await db.query(`GRANT USAGE, CREATE ON SCHEMA public TO ${role}`);
208
+ await db.query(`GRANT USAGE ON SCHEMA sanoma_kms TO ${role}`);
209
+ await db.query(`GRANT SELECT, INSERT, UPDATE, DELETE ON sanoma_kms.secrets TO ${role}`);
210
+ await db.query("COMMIT");
211
+ } catch (error) {
212
+ await db.query("ROLLBACK").catch(() => {});
213
+ throw error;
214
+ }
215
+ });
216
+ // A committed schema is not enough: the stored login must work with the promised authority.
217
+ await connected(prepared.runtime, async (db) => {
218
+ const { rows } = await db.query<{ safe: boolean }>(
219
+ `SELECT (
220
+ current_user=$1 AND has_schema_privilege(current_user,'sanoma_kms','USAGE')
221
+ AND NOT has_schema_privilege(current_user,'sanoma_kms','CREATE')
222
+ AND has_table_privilege(current_user,'sanoma_kms.secrets','SELECT')
223
+ AND has_table_privilege(current_user,'sanoma_kms.secrets','INSERT')
224
+ AND has_table_privilege(current_user,'sanoma_kms.secrets','UPDATE')
225
+ AND has_table_privilege(current_user,'sanoma_kms.secrets','DELETE')
226
+ AND NOT has_table_privilege(current_user,'sanoma_kms.secrets','TRUNCATE,TRIGGER,REFERENCES')
227
+ AND NOT has_table_privilege(current_user,'sanoma_kms.deployment','SELECT,INSERT,UPDATE,DELETE,TRUNCATE,TRIGGER,REFERENCES')
228
+ ) AS safe`,
229
+ [prepared.role],
230
+ );
231
+ if (!rows[0]?.safe) fail("denied");
232
+ });
233
+ return prepared.receipt;
234
+ } catch (error) {
235
+ const known = storageProvisioningCode(error);
236
+ if (known) throw new StorageProvisioningError(known);
237
+ const code = (error as { code?: string } | null)?.code;
238
+ throw new StorageProvisioningError(
239
+ code === "42501" ? "denied" : code === "23505" || code === "42710" ? "conflict" : "unavailable",
240
+ );
241
+ }
242
+ };
243
+
244
+ /** Sandbox keeps generated credentials in memory and performs no database calls. */
245
+ export function memoryStorageProvisioning(): PrepareStorage {
246
+ const bindings = new Map<string, string>();
247
+ return async (input, secrets) => {
248
+ const prepared = await credentials(input, secrets);
249
+ const url = new URL(prepared.admin);
250
+ url.username = "";
251
+ url.password = "";
252
+ const binding = JSON.stringify([input.company, input.environment, digest(prepared.password)]);
253
+ if (bindings.has(url.href) && bindings.get(url.href) !== binding) fail("conflict");
254
+ bindings.set(url.href, binding);
255
+ return prepared.receipt;
256
+ };
257
+ }
package/src/worker.ts CHANGED
@@ -130,6 +130,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
130
130
  // The config's own, or a Postgres store made for this worker, which `stop` then closes.
131
131
  const ledger = ownedLedger(resolved);
132
132
  const secrets = lazyPool(resolved.databaseUrl, "secrets");
133
+ const storedSecrets = configuredSecrets(resolved.secrets, () => secrets.db());
133
134
  let stopRule: { close(): void } | undefined;
134
135
  let stopping: Promise<void> | undefined;
135
136
  const horizonMs = options.idleHorizonMs ?? 60_000;
@@ -148,7 +149,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
148
149
  policy: resolved.policy,
149
150
  ...(resolved.root !== undefined && { root: resolved.root }),
150
151
  ledger: ledger.store,
151
- secrets: configuredSecrets(resolved.secrets, () => secrets.db()),
152
+ secrets: storedSecrets,
152
153
  workflows: resolved.workflows,
153
154
  outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
154
155
  // `registered` is filled below, before any run starts: every name of this config's is registered then.
@@ -177,6 +178,9 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
177
178
  let failed: { error: unknown } | undefined;
178
179
  for (const close of [
179
180
  () => DBOS.shutdown(),
181
+ () => storedSecrets.close(),
182
+ () => resolved.connections.close(),
183
+ () => resolved.identityProvisioning.close(),
180
184
  () => secrets.close(),
181
185
  () => resolved.connectionPool.close(),
182
186
  () => ledger.close(),
@@ -197,7 +201,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
197
201
  const previous = current;
198
202
  let launched = false;
199
203
  try {
200
- await configuredSecrets(resolved.secrets, () => secrets.db()).validate();
204
+ await storedSecrets.validate();
201
205
  for (const name of resolved.workflows.keys()) {
202
206
  if (!registered.has(name)) registered.set(name, register(name));
203
207
  }
@@ -272,6 +276,9 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
272
276
  if (launched) await worker.stop().catch((error: unknown) => (stopFailed = { error }));
273
277
  else {
274
278
  await Promise.all([
279
+ storedSecrets.close(),
280
+ resolved.connections.close(),
281
+ resolved.identityProvisioning.close(),
275
282
  secrets.close(),
276
283
  ledger.close(),
277
284
  resolved.dataStore?.close(),