@sanoma-ai/workflows 0.10.7 → 0.10.11
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/CLOUD-IDENTITY.md +25 -5
- package/KMS-SECRETS.md +75 -11
- package/README.md +18 -11
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +1 -0
- package/dist/collection.js +25 -8
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +3 -2
- package/dist/config.js +4 -1
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +2 -0
- package/dist/connection.js +4 -0
- package/dist/connection.js.map +1 -1
- package/dist/credentials.d.ts +1 -1
- package/dist/credentials.js +21 -2
- package/dist/credentials.js.map +1 -1
- package/dist/identity-provisioning.d.ts +11 -1
- package/dist/identity-provisioning.js +59 -0
- package/dist/identity-provisioning.js.map +1 -1
- package/dist/identity-service.js +1 -1
- package/dist/identity-service.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +19 -4
- package/dist/kms-secrets.js +70 -11
- package/dist/kms-secrets.js.map +1 -1
- package/dist/runtime-ops.d.ts +31 -1
- package/dist/runtime-ops.js +30 -1
- package/dist/runtime-ops.js.map +1 -1
- package/dist/secret-store.d.ts +21 -2
- package/dist/secret-store.js +18 -3
- package/dist/secret-store.js.map +1 -1
- package/dist/shared.d.ts +1 -0
- package/dist/storage-provisioning.d.ts +25 -0
- package/dist/storage-provisioning.js +221 -0
- package/dist/storage-provisioning.js.map +1 -0
- package/dist/worker.js +9 -2
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +3 -0
- package/src/collection.ts +28 -8
- package/src/config.ts +10 -3
- package/src/connection.ts +5 -0
- package/src/credentials.ts +29 -2
- package/src/identity-provisioning.ts +64 -1
- package/src/identity-service.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kms-secrets.ts +90 -13
- package/src/runtime-ops.ts +37 -0
- package/src/secret-store.ts +34 -3
- package/src/shared.ts +1 -1
- package/src/storage-provisioning.ts +257 -0
- 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:
|
|
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
|
|
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(),
|