@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.
- package/CLOUD-IDENTITY.md +25 -5
- package/KMS-SECRETS.md +75 -11
- package/README.md +24 -12
- package/dist/client.js +3 -0
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +2 -0
- package/dist/collection.js +57 -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 +2 -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 +62 -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
package/src/config.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { configuredSecrets } from "./credentials.ts";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
configuredIdentityProvisioning,
|
|
4
|
+
type IdentityProvisioning,
|
|
5
|
+
type IdentityProvisioningDatabase,
|
|
6
|
+
} from "./identity-provisioning.ts";
|
|
3
7
|
import { secretsForInput } from "./secret.ts";
|
|
4
8
|
import { isRunControlError } from "./errors.ts";
|
|
5
9
|
import { currentRun } from "./run.ts";
|
|
@@ -109,7 +113,7 @@ export interface SanomaConfig {
|
|
|
109
113
|
/** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
|
|
110
114
|
secrets?: SecretConfiguration;
|
|
111
115
|
/** Trusted control plane only: separate broker enrollment writer. Absent in company runtimes. */
|
|
112
|
-
identityProvisioning?: IdentityProvisioning;
|
|
116
|
+
identityProvisioning?: IdentityProvisioning | IdentityProvisioningDatabase;
|
|
113
117
|
connections?: readonly ConnectionDefinition[];
|
|
114
118
|
connectionRules?: readonly ConnectionRule[];
|
|
115
119
|
/** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
|
|
@@ -159,6 +163,7 @@ export interface ResolvedConfig {
|
|
|
159
163
|
secrets: SecretConfiguration;
|
|
160
164
|
connections: ConnectionRegistry;
|
|
161
165
|
connectionPool: LazyPool;
|
|
166
|
+
identityProvisioning: ReturnType<typeof configuredIdentityProvisioning>;
|
|
162
167
|
/**
|
|
163
168
|
* Scopes workflows, queues and versions in the system database: the `name` of
|
|
164
169
|
* `<root>/package.json` when it names one, else "sanoma".
|
|
@@ -331,6 +336,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
331
336
|
// Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
|
|
332
337
|
const databaseUrl = resolveDatabaseUrl(config);
|
|
333
338
|
const connectionPool = lazyPool(databaseUrl, "connections");
|
|
339
|
+
const identityProvisioning = configuredIdentityProvisioning(config.identityProvisioning);
|
|
334
340
|
const connections = new ConnectionRegistry(
|
|
335
341
|
connectionCatalog(config.connectors, config.connections, config.connectionRules),
|
|
336
342
|
() => connectionPool.db(),
|
|
@@ -361,7 +367,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
361
367
|
changeDriver(remotes),
|
|
362
368
|
connectionDriver(connections),
|
|
363
369
|
appsListDriver,
|
|
364
|
-
runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities:
|
|
370
|
+
runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: identityProvisioning.store }),
|
|
365
371
|
...(hostTyped ? [sourceDriver] : []),
|
|
366
372
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
367
373
|
],
|
|
@@ -413,6 +419,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
413
419
|
secrets: config.secrets ?? {},
|
|
414
420
|
connections,
|
|
415
421
|
connectionPool,
|
|
422
|
+
identityProvisioning,
|
|
416
423
|
appName,
|
|
417
424
|
databaseUrl,
|
|
418
425
|
// The built-in's code and operations are part of the app's version, like the config's own.
|
package/src/connection.ts
CHANGED
|
@@ -300,6 +300,11 @@ export class ConnectionRegistry {
|
|
|
300
300
|
this.secrets = secrets;
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
/** Release runtime-owned storage clients; the caller owns the database connection. */
|
|
304
|
+
async close(): Promise<void> {
|
|
305
|
+
await this.secrets?.close();
|
|
306
|
+
}
|
|
307
|
+
|
|
303
308
|
/** A direct metadata write uses the same transaction as its default ledger record. */
|
|
304
309
|
onDatabase(db: Db): ConnectionRegistry {
|
|
305
310
|
return new ConnectionRegistry(this.catalog, async () => db, this.secrets);
|
package/src/credentials.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { CREDENTIALS_TABLE as TABLE, type Db } from "./db.ts";
|
|
|
3
3
|
import {
|
|
4
4
|
SecretAccess,
|
|
5
5
|
SecretStoreError,
|
|
6
|
+
checkSecretConfiguration,
|
|
7
|
+
isSecretStoreFactory,
|
|
6
8
|
type SecretAddress,
|
|
7
9
|
type SecretConfiguration,
|
|
8
10
|
type SecretStore,
|
|
@@ -105,5 +107,30 @@ export function postgresSecretStore(db: () => Promise<Db>): SecretStore {
|
|
|
105
107
|
};
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
export
|
|
109
|
-
|
|
110
|
+
export function configuredSecrets(config: SecretConfiguration, db: () => Promise<Db>): SecretAccess {
|
|
111
|
+
checkSecretConfiguration(config);
|
|
112
|
+
const selected = config.store;
|
|
113
|
+
if (!selected || !isSecretStoreFactory(selected)) return new SecretAccess(config, postgresSecretStore(db));
|
|
114
|
+
try {
|
|
115
|
+
const store = selected.create({ database: db });
|
|
116
|
+
checkSecretConfiguration({ store });
|
|
117
|
+
if (
|
|
118
|
+
isSecretStoreFactory(store) ||
|
|
119
|
+
(store.close !== undefined && typeof store.close !== "function") ||
|
|
120
|
+
store.id !== selected.id ||
|
|
121
|
+
(Object.keys(selected.capabilities) as (keyof typeof selected.capabilities)[]).some(
|
|
122
|
+
(key) => store.capabilities[key] !== selected.capabilities[key],
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
throw new SecretStoreError("invalid_configuration");
|
|
126
|
+
return new SecretAccess(
|
|
127
|
+
{ ...config, store },
|
|
128
|
+
postgresSecretStore(db),
|
|
129
|
+
undefined,
|
|
130
|
+
() => store.close?.() ?? Promise.resolve(),
|
|
131
|
+
);
|
|
132
|
+
} catch {
|
|
133
|
+
// Config construction may otherwise leak a provider's bootstrap/database diagnostics.
|
|
134
|
+
throw new SecretStoreError("invalid_configuration");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Pool, type PoolClient } from "pg";
|
|
2
2
|
import type { IdentityEnrollment } from "./cloud-identity.ts";
|
|
3
3
|
|
|
4
4
|
export type DeploymentIdentity = Omit<IdentityEnrollment, "generation">;
|
|
@@ -14,6 +14,69 @@ export interface IdentityProvisioning {
|
|
|
14
14
|
revoke(input: DeploymentIdentity): Promise<void>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Trusted control-plane configuration; the runtime owns this separate writer connection. */
|
|
18
|
+
export interface IdentityProvisioningDatabase {
|
|
19
|
+
databaseUrl: string;
|
|
20
|
+
maxValidityMs: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolve without I/O. Explicit adapters remain caller-owned; database configuration is runtime-owned. */
|
|
24
|
+
export function configuredIdentityProvisioning(
|
|
25
|
+
config: IdentityProvisioning | IdentityProvisioningDatabase | undefined,
|
|
26
|
+
): { store?: IdentityProvisioning; close(): Promise<void> } {
|
|
27
|
+
if (!config) return { close: async () => {} };
|
|
28
|
+
if (!("databaseUrl" in config)) return { store: config, close: async () => {} };
|
|
29
|
+
let url: URL;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(config.databaseUrl);
|
|
32
|
+
if (
|
|
33
|
+
!["postgres:", "postgresql:"].includes(url.protocol) ||
|
|
34
|
+
!url.hostname ||
|
|
35
|
+
!url.username ||
|
|
36
|
+
!url.password ||
|
|
37
|
+
!/^\/[^/]+$/.test(url.pathname) ||
|
|
38
|
+
url.hash ||
|
|
39
|
+
[...url.searchParams.keys()].some((key) => !["sslmode", "channel_binding"].includes(key))
|
|
40
|
+
)
|
|
41
|
+
fail("invalid");
|
|
42
|
+
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) url.searchParams.set("sslmode", "verify-full");
|
|
43
|
+
} catch {
|
|
44
|
+
throw new IdentityProvisioningError("invalid");
|
|
45
|
+
}
|
|
46
|
+
const connectionString = url.href;
|
|
47
|
+
let pool: Pool | undefined;
|
|
48
|
+
let closed = false;
|
|
49
|
+
let closing: Promise<void> | undefined;
|
|
50
|
+
const store = postgresIdentityProvisioning(async () => {
|
|
51
|
+
if (closed) throw new IdentityProvisioningError("unavailable");
|
|
52
|
+
if (!pool) {
|
|
53
|
+
pool = new Pool({ connectionString, max: 5, connectionTimeoutMillis: 10_000, query_timeout: 15_000 });
|
|
54
|
+
pool.on("error", () => {});
|
|
55
|
+
}
|
|
56
|
+
// The control plane needs enrollment writes, never schema or database administration.
|
|
57
|
+
const { rows } = await pool.query<{ allowed: boolean }>(`SELECT
|
|
58
|
+
NOT EXISTS (SELECT FROM pg_roles WHERE rolname=current_user AND (rolsuper OR rolcreatedb OR rolcreaterole OR rolbypassrls))
|
|
59
|
+
AND has_schema_privilege('sanoma_identity','USAGE') AND NOT has_schema_privilege('sanoma_identity','CREATE')
|
|
60
|
+
AND has_table_privilege('sanoma_identity.deployments','SELECT')
|
|
61
|
+
AND has_table_privilege('sanoma_identity.deployments','INSERT')
|
|
62
|
+
AND has_table_privilege('sanoma_identity.deployments','UPDATE')
|
|
63
|
+
AND has_table_privilege('sanoma_identity.credentials','SELECT')
|
|
64
|
+
AND has_table_privilege('sanoma_identity.credentials','INSERT')
|
|
65
|
+
AND has_table_privilege('sanoma_identity.credentials','UPDATE')
|
|
66
|
+
AND NOT has_table_privilege('sanoma_identity.deployments','DELETE,TRUNCATE,REFERENCES,TRIGGER')
|
|
67
|
+
AND NOT has_table_privilege('sanoma_identity.credentials','DELETE,TRUNCATE,REFERENCES,TRIGGER') AS allowed`);
|
|
68
|
+
if (!rows[0]?.allowed) fail("invalid");
|
|
69
|
+
return pool;
|
|
70
|
+
}, config);
|
|
71
|
+
return {
|
|
72
|
+
store,
|
|
73
|
+
close() {
|
|
74
|
+
closed = true;
|
|
75
|
+
return (closing ??= pool?.end() ?? Promise.resolve());
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
17
80
|
export class IdentityProvisioningError extends Error {
|
|
18
81
|
readonly retryable: boolean;
|
|
19
82
|
readonly code: "invalid" | "conflict" | "unavailable";
|
package/src/identity-service.ts
CHANGED
|
@@ -139,7 +139,7 @@ export async function startIdentityService(options: IdentityServiceOptions): Pro
|
|
|
139
139
|
res.end('{"error":"invalid_request"}');
|
|
140
140
|
return;
|
|
141
141
|
}
|
|
142
|
-
if (req.url === "/
|
|
142
|
+
if (req.url === "/health" && (req.method === "GET" || req.method === "HEAD")) {
|
|
143
143
|
res.writeHead(200, { "Content-Type": "text/plain", "Cache-Control": "no-store" });
|
|
144
144
|
res.end("ok\n");
|
|
145
145
|
return;
|
package/src/index.ts
CHANGED
package/src/kms-secrets.ts
CHANGED
|
@@ -2,7 +2,14 @@ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from "node:
|
|
|
2
2
|
import type { protos } from "@google-cloud/kms";
|
|
3
3
|
import crc32c from "crc-32/crc32c.js";
|
|
4
4
|
import type { Db } from "./db.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { cloudIdentityAuth } from "./cloud-identity.ts";
|
|
6
|
+
import {
|
|
7
|
+
SecretStoreError,
|
|
8
|
+
type SecretAddress,
|
|
9
|
+
type SecretStore,
|
|
10
|
+
type SecretStoreFactory,
|
|
11
|
+
type SecretValue,
|
|
12
|
+
} from "./secret-store.ts";
|
|
6
13
|
|
|
7
14
|
/** Install with a privileged connection; runtime needs only SELECT/INSERT/UPDATE/DELETE on its company database. */
|
|
8
15
|
export const KMS_SECRETS_SQL = `
|
|
@@ -47,8 +54,8 @@ export interface KmsPostgresOptions {
|
|
|
47
54
|
decryptKeyNames?: readonly string[];
|
|
48
55
|
/** Explicit client under a company-scoped IAM identity, never fleet credentials. Caller owns its lifecycle. */
|
|
49
56
|
kms: GcpKmsClient;
|
|
50
|
-
/**
|
|
51
|
-
database
|
|
57
|
+
/** Omit in config to use the runtime's database. An explicit connection remains caller-owned. */
|
|
58
|
+
database?: () => Promise<Db>;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
interface Envelope {
|
|
@@ -68,6 +75,13 @@ interface Row {
|
|
|
68
75
|
}
|
|
69
76
|
const FORMAT = "sanoma-gcp-kms-v1";
|
|
70
77
|
const MAX_BYTES = 65_536;
|
|
78
|
+
const capabilities = Object.freeze({
|
|
79
|
+
writable: true,
|
|
80
|
+
maxBytes: MAX_BYTES,
|
|
81
|
+
conditionalWrites: "atomic" as const,
|
|
82
|
+
deletion: "destroy" as const,
|
|
83
|
+
changeDetection: "read" as const,
|
|
84
|
+
});
|
|
71
85
|
const keyName =
|
|
72
86
|
/^projects\/[a-zA-Z0-9-]+\/locations\/[a-z0-9-]+\/keyRings\/[a-zA-Z0-9_-]+\/cryptoKeys\/[a-zA-Z0-9_-]+$/;
|
|
73
87
|
const nonempty = (v: string) =>
|
|
@@ -110,12 +124,81 @@ const versionOf = (name: unknown, key: string): name is string =>
|
|
|
110
124
|
/^[1-9][0-9]{0,19}$/.test(name.slice(`${key}/cryptoKeyVersions/`.length));
|
|
111
125
|
const checksum = (value: Uint8Array) => crc32c.buf(value) >>> 0;
|
|
112
126
|
|
|
127
|
+
function checkedKeys(options: Pick<KmsPostgresOptions, "company" | "keyName" | "decryptKeyNames">): Set<string> {
|
|
128
|
+
const keys = new Set([options.keyName, ...(options.decryptKeyNames ?? [])]);
|
|
129
|
+
if (!nonempty(options.company) || [...keys].some((key) => !nonempty(key) || !keyName.test(key)))
|
|
130
|
+
throw new SecretStoreError("invalid_configuration");
|
|
131
|
+
return keys;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type ManagedKmsOptions = Pick<KmsPostgresOptions, "company" | "keyName" | "decryptKeyNames"> & {
|
|
135
|
+
/** Fixed deployment broker; the callback reads protected bootstrap again on renewal. */
|
|
136
|
+
identity: { endpoint: string; credential(): Promise<string> };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Config composition for managed Cloud. Each runtime owns its lazily opened SDK client and database binding. */
|
|
140
|
+
export function managedKmsStore(options: ManagedKmsOptions): SecretStoreFactory {
|
|
141
|
+
const keys = checkedKeys(options);
|
|
142
|
+
const { company, keyName: writeKey } = options;
|
|
143
|
+
const identity = { ...options.identity };
|
|
144
|
+
if (typeof identity.credential !== "function") throw new SecretStoreError("invalid_configuration");
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
kind: "runtime",
|
|
147
|
+
id: "kms-postgres",
|
|
148
|
+
capabilities,
|
|
149
|
+
create({ database }: { database: () => Promise<Db> }) {
|
|
150
|
+
const authClient = cloudIdentityAuth(identity);
|
|
151
|
+
let closed = false;
|
|
152
|
+
let pending: Promise<GcpKmsClient & { close(): Promise<void> }> | undefined;
|
|
153
|
+
const client = () => {
|
|
154
|
+
if (closed) throw new SecretStoreError("unavailable");
|
|
155
|
+
return (pending ??= import("@google-cloud/kms").then(
|
|
156
|
+
({ KeyManagementServiceClient }) => new KeyManagementServiceClient({ authClient }),
|
|
157
|
+
));
|
|
158
|
+
};
|
|
159
|
+
const kms: GcpKmsClient = {
|
|
160
|
+
encrypt: async (request) => (await client()).encrypt(request),
|
|
161
|
+
decrypt: async (request) => (await client()).decrypt(request),
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
...kmsPostgresStore({ company, keyName: writeKey, decryptKeyNames: [...keys], database, kms }),
|
|
165
|
+
async close() {
|
|
166
|
+
if (closed) return;
|
|
167
|
+
closed = true;
|
|
168
|
+
if (pending) await (await pending).close();
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
113
175
|
/** Versioned envelope encryption; no plaintext/data-key cache, no SANOMA_SECRETS_KEY and no Secrets Manager. */
|
|
114
|
-
export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore
|
|
176
|
+
export function kmsPostgresStore(options: KmsPostgresOptions & { database: () => Promise<Db> }): SecretStore;
|
|
177
|
+
export function kmsPostgresStore(options: KmsPostgresOptions & { database?: undefined }): SecretStoreFactory;
|
|
178
|
+
export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore | SecretStoreFactory;
|
|
179
|
+
export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore | SecretStoreFactory {
|
|
115
180
|
const { company, keyName: writeKey, kms, database } = options;
|
|
116
|
-
const keys =
|
|
117
|
-
if (
|
|
181
|
+
const keys = checkedKeys(options);
|
|
182
|
+
if (
|
|
183
|
+
typeof kms?.encrypt !== "function" ||
|
|
184
|
+
typeof kms?.decrypt !== "function" ||
|
|
185
|
+
(database !== undefined && typeof database !== "function")
|
|
186
|
+
)
|
|
118
187
|
throw new SecretStoreError("invalid_configuration");
|
|
188
|
+
if (!database)
|
|
189
|
+
return Object.freeze({
|
|
190
|
+
kind: "runtime",
|
|
191
|
+
id: "kms-postgres",
|
|
192
|
+
capabilities,
|
|
193
|
+
create: ({ database: runtimeDatabase }: { database: () => Promise<Db> }) =>
|
|
194
|
+
kmsPostgresStore({
|
|
195
|
+
company,
|
|
196
|
+
keyName: writeKey,
|
|
197
|
+
decryptKeyNames: [...keys],
|
|
198
|
+
kms,
|
|
199
|
+
database: runtimeDatabase,
|
|
200
|
+
}),
|
|
201
|
+
});
|
|
119
202
|
const context = (address: SecretAddress, version: string) => {
|
|
120
203
|
if (!nonempty(address.namespace) || !nonempty(address.path)) throw new SecretStoreError("invalid_configuration");
|
|
121
204
|
return { company, namespace: address.namespace, path: address.path, version, format: FORMAT };
|
|
@@ -209,13 +292,7 @@ export function kmsPostgresStore(options: KmsPostgresOptions): SecretStore {
|
|
|
209
292
|
};
|
|
210
293
|
return {
|
|
211
294
|
id: "kms-postgres",
|
|
212
|
-
capabilities
|
|
213
|
-
writable: true,
|
|
214
|
-
maxBytes: MAX_BYTES,
|
|
215
|
-
conditionalWrites: "atomic",
|
|
216
|
-
deletion: "destroy",
|
|
217
|
-
changeDetection: "read",
|
|
218
|
-
}),
|
|
295
|
+
capabilities,
|
|
219
296
|
validate: (namespace) =>
|
|
220
297
|
safe(async () => {
|
|
221
298
|
const address = { namespace, path: "__validation__" },
|
package/src/runtime-ops.ts
CHANGED
|
@@ -12,6 +12,12 @@ import {
|
|
|
12
12
|
memoryIdentityProvisioning,
|
|
13
13
|
type IdentityProvisioning,
|
|
14
14
|
} from "./identity-provisioning.ts";
|
|
15
|
+
import {
|
|
16
|
+
prepareSecretStore,
|
|
17
|
+
memoryStorageProvisioning,
|
|
18
|
+
storageProvisioningCode,
|
|
19
|
+
type PrepareStorage,
|
|
20
|
+
} from "./storage-provisioning.ts";
|
|
15
21
|
|
|
16
22
|
// What a workflow cannot do itself and the runtime does for it, as operations of the vendor
|
|
17
23
|
// `sanoma` (`sanoma.secret.*`, `sanoma.invite.mint`, `sanoma.token.*`): named in `uses`, decided by
|
|
@@ -53,6 +59,24 @@ async function identityWrite(write: () => Promise<void>): Promise<void> {
|
|
|
53
59
|
* `uses: [sanoma.secret.generate]` and `ctx.sanoma.secret.generate({ name, kind })`.
|
|
54
60
|
*/
|
|
55
61
|
export const sanoma = defineConnector("sanoma", {
|
|
62
|
+
secretStore: {
|
|
63
|
+
prepare: {
|
|
64
|
+
effect: "access",
|
|
65
|
+
idempotent: true,
|
|
66
|
+
description:
|
|
67
|
+
"Prepare a company/environment KMS Postgres schema and restricted runtime login; store its database URL without returning credentials",
|
|
68
|
+
input: z.object({
|
|
69
|
+
company: z.string().min(1).max(256),
|
|
70
|
+
environment: z.string().min(1).max(256),
|
|
71
|
+
database: z.strictObject({ $secret: z.string().regex(SECRET_NAME) }),
|
|
72
|
+
store: SecretName,
|
|
73
|
+
}),
|
|
74
|
+
output: z.object({ company: z.string(), environment: z.string(), role: z.string(), store: z.string() }),
|
|
75
|
+
target: ({ company, environment }: { company: string; environment: string }) =>
|
|
76
|
+
JSON.stringify([company, environment]),
|
|
77
|
+
createsSecrets: ({ store }: { store: string }) => [store],
|
|
78
|
+
},
|
|
79
|
+
},
|
|
56
80
|
identity: {
|
|
57
81
|
enroll: {
|
|
58
82
|
effect: "access",
|
|
@@ -211,13 +235,25 @@ export async function postInvite(host: string, request: string): Promise<{ url:
|
|
|
211
235
|
export function runtimeDriver({
|
|
212
236
|
tokens,
|
|
213
237
|
identities,
|
|
238
|
+
storage = prepareSecretStore,
|
|
214
239
|
invite = postInvite,
|
|
215
240
|
}: {
|
|
216
241
|
tokens: Tokens;
|
|
217
242
|
identities?: IdentityProvisioning;
|
|
243
|
+
storage?: PrepareStorage;
|
|
218
244
|
invite?: (host: string, request: string, call: CallContext) => Promise<{ url: string }>;
|
|
219
245
|
}) {
|
|
220
246
|
return defineDriver(sanoma, {
|
|
247
|
+
secretStore: {
|
|
248
|
+
async prepare(input, call) {
|
|
249
|
+
try {
|
|
250
|
+
return await storage(input, call.secrets);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const code = storageProvisioningCode(error) ?? "unavailable";
|
|
253
|
+
throw new DriverError(`Storage provisioning: ${code}`, { retryable: code === "unavailable" });
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
},
|
|
221
257
|
identity: {
|
|
222
258
|
async enroll({ store, ...input }, call) {
|
|
223
259
|
if (!identities) throw refuse("identity provisioning is not configured");
|
|
@@ -309,5 +345,6 @@ export const sandboxRuntimeDriver = () =>
|
|
|
309
345
|
runtimeDriver({
|
|
310
346
|
tokens: memoryTokens(),
|
|
311
347
|
identities: memoryIdentityProvisioning(),
|
|
348
|
+
storage: memoryStorageProvisioning(),
|
|
312
349
|
invite: async (host, _request, call) => ({ url: new URL(`/auth/invite/sandbox-${tokenIdOf(call)}`, host).href }),
|
|
313
350
|
});
|
package/src/secret-store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEPLOYMENT_KEYS, type DeploymentKeyName, deploymentKey } from "./deployment-key.ts";
|
|
2
|
+
import type { Db } from "./db.ts";
|
|
2
3
|
/** Opaque values belong only in trusted runtime code, never workflow history or descriptions. */
|
|
3
4
|
export interface SecretValue {
|
|
4
5
|
value: string;
|
|
@@ -62,6 +63,21 @@ export interface SecretStore {
|
|
|
62
63
|
delete(address: SecretAddress, options: { expectedVersion: string; by: string }): Promise<void>;
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/** Bind a fresh adapter to each runtime's existing database; create must perform no I/O or allocate owned clients. */
|
|
67
|
+
export interface SecretStoreFactory {
|
|
68
|
+
readonly kind: "runtime";
|
|
69
|
+
readonly id: string;
|
|
70
|
+
readonly capabilities: Readonly<SecretStoreCapabilities>;
|
|
71
|
+
/** The runtime owns this connection. Never close it or retain it outside the returned adapter. */
|
|
72
|
+
create(context: { database: () => Promise<Db> }): SecretStore & {
|
|
73
|
+
/** Close only clients this adapter opens lazily; never the supplied database. */
|
|
74
|
+
close?(): Promise<void>;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const isSecretStoreFactory = (store: SecretStore | SecretStoreFactory): store is SecretStoreFactory =>
|
|
79
|
+
"kind" in store && store.kind === "runtime";
|
|
80
|
+
|
|
65
81
|
export type SecretBinding =
|
|
66
82
|
| { path: string; management: "sanoma" | "external"; env?: never; bootstrap?: never }
|
|
67
83
|
| { env: string; management?: "external"; path?: never; bootstrap?: never }
|
|
@@ -69,7 +85,7 @@ export type SecretBinding =
|
|
|
69
85
|
|
|
70
86
|
export interface SecretConfiguration {
|
|
71
87
|
/** Omitted: built-in encrypted Postgres, supplied by the runtime. */
|
|
72
|
-
store?: SecretStore;
|
|
88
|
+
store?: SecretStore | SecretStoreFactory;
|
|
73
89
|
/** Defaults to `default`. Choose a distinct namespace and identity for each deployment. */
|
|
74
90
|
namespace?: string;
|
|
75
91
|
/** Names without a binding resolve to the same name in this namespace. */
|
|
@@ -134,7 +150,9 @@ export function checkSecretConfiguration(config: SecretConfiguration): void {
|
|
|
134
150
|
!["atomic", "coordinated", "none"].includes(c.conditionalWrites) ||
|
|
135
151
|
!["destroy", "soft-delete", "unsupported"].includes(c.deletion) ||
|
|
136
152
|
!["read", "watch"].includes(c.changeDetection) ||
|
|
137
|
-
|
|
153
|
+
(isSecretStoreFactory(s)
|
|
154
|
+
? typeof s.create !== "function"
|
|
155
|
+
: [s.validate, s.read, s.write, s.delete].some((fn) => typeof fn !== "function"))
|
|
138
156
|
)
|
|
139
157
|
invalid();
|
|
140
158
|
}
|
|
@@ -173,15 +191,21 @@ export class SecretAccess {
|
|
|
173
191
|
private readonly bindings: Readonly<Record<string, SecretBinding>>;
|
|
174
192
|
|
|
175
193
|
private readonly env: (name: string) => string | undefined;
|
|
194
|
+
private closing?: Promise<void>;
|
|
195
|
+
private readonly onClose?: () => Promise<void>;
|
|
176
196
|
|
|
177
197
|
constructor(
|
|
178
198
|
config: SecretConfiguration,
|
|
179
199
|
defaultStore: SecretStore,
|
|
180
200
|
env: (name: string) => string | undefined = (name) => process.env[name],
|
|
201
|
+
onClose?: () => Promise<void>,
|
|
181
202
|
) {
|
|
182
203
|
this.env = env;
|
|
204
|
+
this.onClose = onClose;
|
|
183
205
|
checkSecretConfiguration(config);
|
|
184
|
-
|
|
206
|
+
const store = config.store ?? defaultStore;
|
|
207
|
+
if (isSecretStoreFactory(store)) throw new SecretStoreError("invalid_configuration");
|
|
208
|
+
this.store = store;
|
|
185
209
|
checkSecretConfiguration({ store: this.store });
|
|
186
210
|
this.namespace = config.namespace ?? "default";
|
|
187
211
|
this.bindings = Object.fromEntries(
|
|
@@ -189,6 +213,13 @@ export class SecretAccess {
|
|
|
189
213
|
);
|
|
190
214
|
}
|
|
191
215
|
|
|
216
|
+
/** Only runtime-factory adapters are owned here; configured standalone stores remain caller-owned. */
|
|
217
|
+
close(): Promise<void> {
|
|
218
|
+
return (this.closing ??= secretStoreCall(async () => {
|
|
219
|
+
await this.onClose?.();
|
|
220
|
+
}));
|
|
221
|
+
}
|
|
222
|
+
|
|
192
223
|
location(name: string): SecretLocation {
|
|
193
224
|
if (!namePattern.test(name)) throw new SecretStoreError("invalid_configuration");
|
|
194
225
|
const binding = Object.hasOwn(this.bindings, name) ? this.bindings[name] : undefined;
|
package/src/shared.ts
CHANGED
|
@@ -613,6 +613,6 @@ export type OpFn<I, O> = (input: I) => Promise<O>;
|
|
|
613
613
|
export interface CollectionApi<R> {
|
|
614
614
|
get: OpFn<{ key: string }, Saved<R> | null>;
|
|
615
615
|
list(query?: ListQuery<R>): Promise<Saved<R>[]>;
|
|
616
|
-
save: OpFn<{ record: R }, Saved<R>>;
|
|
616
|
+
save: OpFn<{ record: R; ifAbsent?: boolean; ifRecord?: Record<string, unknown> }, Saved<R>>;
|
|
617
617
|
delete: OpFn<{ key: string }, { deleted: boolean }>;
|
|
618
618
|
}
|