@sanoma-ai/workflows 0.10.0 → 0.10.2
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 +140 -0
- package/KMS-SECRETS.md +99 -0
- package/README.md +39 -7
- package/SECRET-SUPPORT.md +33 -0
- package/dist/apps.d.ts +6 -0
- package/dist/apps.js +18 -1
- package/dist/apps.js.map +1 -1
- package/dist/authentication.d.ts +35 -0
- package/dist/authentication.js +71 -1
- package/dist/authentication.js.map +1 -1
- package/dist/call.js +7 -5
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +54 -6
- package/dist/client.js +177 -5
- package/dist/client.js.map +1 -1
- package/dist/cloud-identity.d.ts +52 -0
- package/dist/cloud-identity.js +222 -0
- package/dist/cloud-identity.js.map +1 -0
- package/dist/config.d.ts +7 -4
- package/dist/config.js +44 -12
- package/dist/config.js.map +1 -1
- package/dist/connection.d.ts +223 -2
- package/dist/connection.js +340 -4
- package/dist/connection.js.map +1 -1
- package/dist/db.js +15 -2
- package/dist/db.js.map +1 -1
- package/dist/describe.d.ts +8 -0
- package/dist/describe.js +51 -1
- package/dist/describe.js.map +1 -1
- package/dist/direct.js +4 -0
- package/dist/direct.js.map +1 -1
- package/dist/fake.d.ts +13 -1
- package/dist/fake.js +70 -0
- package/dist/fake.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/kms-secrets.d.ts +24 -0
- package/dist/kms-secrets.js +209 -0
- package/dist/kms-secrets.js.map +1 -0
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/load.d.ts +11 -0
- package/dist/load.js +42 -8
- package/dist/load.js.map +1 -1
- package/dist/op.d.ts +14 -6
- package/dist/op.js +20 -2
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +5 -0
- package/dist/outline.js +33 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +2 -4
- package/dist/paths.js +2 -8
- package/dist/paths.js.map +1 -1
- package/dist/run.d.ts +3 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +4 -1
- package/dist/sandbox.js +51 -5
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +5 -0
- package/dist/scenario.js +3 -2
- package/dist/scenario.js.map +1 -1
- package/dist/secret.d.ts +2 -0
- package/dist/secret.js +32 -0
- package/dist/secret.js.map +1 -1
- package/dist/shared.d.ts +4 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/sites.js +18 -3
- package/dist/sites.js.map +1 -1
- package/dist/steps.d.ts +3 -0
- package/dist/steps.js.map +1 -1
- package/oxlint.json +3 -0
- package/package.json +16 -2
- package/src/apps.ts +17 -1
- package/src/authentication.ts +126 -2
- package/src/call.ts +4 -5
- package/src/client.ts +242 -6
- package/src/cloud-identity.ts +266 -0
- package/src/config.ts +45 -11
- package/src/connection.ts +478 -6
- package/src/db.ts +15 -2
- package/src/describe.ts +52 -1
- package/src/direct.ts +4 -0
- package/src/fake.ts +71 -0
- package/src/index.ts +13 -1
- package/src/kms-secrets.ts +271 -0
- package/src/lint.ts +1 -1
- package/src/load.ts +36 -7
- package/src/op.ts +33 -8
- package/src/outline.ts +35 -2
- package/src/paths.ts +2 -9
- package/src/run.ts +3 -1
- package/src/sandbox.ts +55 -5
- package/src/scenario.ts +24 -6
- package/src/secret.ts +29 -0
- package/src/shared.ts +9 -0
- package/src/sites.ts +19 -3
- package/src/steps.ts +3 -2
package/src/client.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
connectionDriver,
|
|
3
|
+
connectionTargetSchema,
|
|
4
|
+
type ConnectionRef,
|
|
5
|
+
type ConnectionSnapshot,
|
|
6
|
+
type ConnectionTarget,
|
|
7
|
+
type ConnectionSetup,
|
|
8
|
+
type ConnectionCard,
|
|
9
|
+
type ConnectionRule,
|
|
10
|
+
} from "./connection.ts";
|
|
11
|
+
import { encodeCredentials, decodeCredentials, verifyAuthentication, AuthenticationError } from "./authentication.ts";
|
|
12
|
+
|
|
13
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
14
|
import { isDeepStrictEqual } from "node:util";
|
|
4
15
|
import { type DBOSClient, Error as DBOSErrors, type WorkflowStatusString } from "@dbos-inc/dbos-sdk";
|
|
5
16
|
import type { Pool } from "pg";
|
|
@@ -53,7 +64,7 @@ import type { CallContext, Driver, Op } from "./op.ts";
|
|
|
53
64
|
import { allowAll, type RecordedDecision } from "./policy.ts";
|
|
54
65
|
import { admit, readPeople } from "./resources.ts";
|
|
55
66
|
import type { RunArgs, StartHold } from "./run.ts";
|
|
56
|
-
import type
|
|
67
|
+
import { SecretStoreError, type SecretAccess, type SecretLocation } from "./secret-store.ts";
|
|
57
68
|
import {
|
|
58
69
|
BUILT_IN_WORKFLOWS,
|
|
59
70
|
CALL_WORKFLOW,
|
|
@@ -220,6 +231,15 @@ export interface CallOptions {
|
|
|
220
231
|
id?: string;
|
|
221
232
|
}
|
|
222
233
|
|
|
234
|
+
/** A credential-free review shown before the user saves a connection. */
|
|
235
|
+
export interface ConnectionReview extends ConnectionSetup {
|
|
236
|
+
authorization: string;
|
|
237
|
+
requestId: string;
|
|
238
|
+
account: string;
|
|
239
|
+
scopes: string[];
|
|
240
|
+
credentialVersion: string | null;
|
|
241
|
+
expiresAt?: number;
|
|
242
|
+
}
|
|
223
243
|
/** What `SanomaClient.connect` is given besides the config. */
|
|
224
244
|
export interface ClientOptions {
|
|
225
245
|
/**
|
|
@@ -1148,10 +1168,226 @@ export class SanomaClient {
|
|
|
1148
1168
|
if (current) await this.storedSecrets.delete(name, { expectedVersion: current.version, by });
|
|
1149
1169
|
}
|
|
1150
1170
|
|
|
1171
|
+
private connectionAuthorization(): string {
|
|
1172
|
+
return createHash("sha256")
|
|
1173
|
+
.update(
|
|
1174
|
+
JSON.stringify({
|
|
1175
|
+
version: this.config.version,
|
|
1176
|
+
policies: this.config.policies,
|
|
1177
|
+
problems: this.config.policyProblems,
|
|
1178
|
+
}),
|
|
1179
|
+
)
|
|
1180
|
+
.digest("hex");
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/** Safe account cards, scoped to the admitted principal. No provider or secret reads. */
|
|
1184
|
+
async connections(options: { actor: Principal | string }): Promise<ConnectionCard[]> {
|
|
1185
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1186
|
+
return this.config.connections.cards(actor.id);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Creation choices are scoped by code to the admitted person's current groups. */
|
|
1190
|
+
async connectionRules(options: { actor: Principal | string }): Promise<{ id: string; rule: ConnectionRule }[]> {
|
|
1191
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1192
|
+
return [...this.config.connections.catalog.rules]
|
|
1193
|
+
.filter(([, rule]) =>
|
|
1194
|
+
rule.createBy.some((by) => (typeof by === "string" ? by === actor.id : actor.groups?.includes(by.group))),
|
|
1195
|
+
)
|
|
1196
|
+
.map(([id, rule]) => ({ id, rule }));
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Readiness only, without reading or listing any credential. The adapter's bootstrap
|
|
1200
|
+
* authority is checked by validate; provider details never escape this boundary. */
|
|
1201
|
+
async secretStoreStatus(options: { actor: Principal | string }) {
|
|
1202
|
+
await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1203
|
+
const { store, namespace } = this.storedSecrets;
|
|
1204
|
+
let problem: string | undefined;
|
|
1205
|
+
try {
|
|
1206
|
+
await this.storedSecrets.validate();
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
problem = (error as SecretStoreError).code;
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
store: store.id,
|
|
1212
|
+
namespace,
|
|
1213
|
+
capabilities: { ...store.capabilities },
|
|
1214
|
+
ready: !problem,
|
|
1215
|
+
...(problem && { problem }),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Secure intake endpoint, never a workflow operation. Only its metadata-only prepare gate
|
|
1220
|
+
* enters policy/approval history; callers keep the value in a password form while reviewing. */
|
|
1221
|
+
async previewConnection(
|
|
1222
|
+
target: ConnectionTarget,
|
|
1223
|
+
credentials: unknown,
|
|
1224
|
+
options: { actor: Principal | string; id: string },
|
|
1225
|
+
): Promise<ConnectionReview> {
|
|
1226
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1227
|
+
const id = parseOrThrow(z.string().min(1).max(200), options.id, "Name the setup request");
|
|
1228
|
+
target = parseOrThrow(connectionTargetSchema, target, "Select a declared connection or creation rule");
|
|
1229
|
+
const setup = await this.config.connections.setup(target, actor, id);
|
|
1230
|
+
const authorized = (await this.call(
|
|
1231
|
+
"sanoma.connection.prepare",
|
|
1232
|
+
{
|
|
1233
|
+
target,
|
|
1234
|
+
requestId: id,
|
|
1235
|
+
definition: setup.definition,
|
|
1236
|
+
authorization: this.connectionAuthorization(),
|
|
1237
|
+
},
|
|
1238
|
+
{ actor, id: `connection-prepare:${id}` },
|
|
1239
|
+
)) as ConnectionSetup;
|
|
1240
|
+
if (!isDeepStrictEqual(authorized, setup))
|
|
1241
|
+
throw new SanomaError("invalid_input", "The connection changed; start a new review");
|
|
1242
|
+
const method = this.config.connections.catalog.methods.get(setup.vendor)![setup.method]!;
|
|
1243
|
+
const external =
|
|
1244
|
+
method.kind !== "workload-identity" &&
|
|
1245
|
+
(setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
|
|
1246
|
+
if (
|
|
1247
|
+
!external &&
|
|
1248
|
+
method.kind !== "workload-identity" &&
|
|
1249
|
+
this.storedSecrets.store.capabilities.conditionalWrites === "none"
|
|
1250
|
+
)
|
|
1251
|
+
throw new SecretStoreError("unsupported");
|
|
1252
|
+
if (credentials !== undefined && (external || method.kind === "workload-identity"))
|
|
1253
|
+
throw new SecretStoreError("read_only");
|
|
1254
|
+
const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
|
|
1255
|
+
if (external && !existing) throw new SecretStoreError("unavailable");
|
|
1256
|
+
const encoded =
|
|
1257
|
+
method.kind === "workload-identity"
|
|
1258
|
+
? undefined
|
|
1259
|
+
: external
|
|
1260
|
+
? existing!.value
|
|
1261
|
+
: encodeCredentials(method, credentials);
|
|
1262
|
+
const identity = await verifyAuthentication(
|
|
1263
|
+
method,
|
|
1264
|
+
encoded === undefined ? undefined : decodeCredentials(method, encoded),
|
|
1265
|
+
{ connectionId: setup.id, vendor: setup.vendor },
|
|
1266
|
+
);
|
|
1267
|
+
if (setup.expectedAccount && setup.expectedAccount !== identity.account)
|
|
1268
|
+
throw new AuthenticationError("wrong_account");
|
|
1269
|
+
if (setup.requiredScopes.some((scope) => !identity.scopes.includes(scope)))
|
|
1270
|
+
throw new AuthenticationError("scope_changed");
|
|
1271
|
+
return {
|
|
1272
|
+
...setup,
|
|
1273
|
+
requestId: id,
|
|
1274
|
+
authorization: this.connectionAuthorization(),
|
|
1275
|
+
account: identity.account,
|
|
1276
|
+
scopes: [...identity.scopes],
|
|
1277
|
+
credentialVersion: existing?.version ?? null,
|
|
1278
|
+
...(identity.expiresAt !== undefined && { expiresAt: identity.expiresAt }),
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Reverify the reviewed authority, reserve its metadata under policy, then write the bundle
|
|
1283
|
+
* outside durable execution. Activation has its own policy gate and verifies what was saved. */
|
|
1284
|
+
async connectConnection(
|
|
1285
|
+
review: ConnectionReview,
|
|
1286
|
+
credentials: unknown,
|
|
1287
|
+
options: { actor: Principal | string },
|
|
1288
|
+
): Promise<ConnectionSnapshot> {
|
|
1289
|
+
const actor = await this.admitted(parseOrThrow(PrincipalOrId, options.actor, "Name the principal"));
|
|
1290
|
+
if (review.authorization !== this.connectionAuthorization())
|
|
1291
|
+
throw new SanomaError("invalid_input", "The policy changed; start a new connection review");
|
|
1292
|
+
const setup = await this.config.connections.setup(review.target, actor, review.requestId);
|
|
1293
|
+
for (const field of ["id", "vendor", "method", "definition", "credential", "location"] as const) {
|
|
1294
|
+
if (!isDeepStrictEqual(setup[field], review[field]))
|
|
1295
|
+
throw new SanomaError("invalid_input", "The destination changed; review it again before saving");
|
|
1296
|
+
}
|
|
1297
|
+
// Reuse only the metadata gate, never a cached credential or verification result.
|
|
1298
|
+
await this.call(
|
|
1299
|
+
"sanoma.connection.prepare",
|
|
1300
|
+
{
|
|
1301
|
+
target: review.target,
|
|
1302
|
+
requestId: review.requestId,
|
|
1303
|
+
definition: setup.definition,
|
|
1304
|
+
authorization: review.authorization,
|
|
1305
|
+
},
|
|
1306
|
+
{ actor, id: `connection-prepare:${review.requestId}` },
|
|
1307
|
+
);
|
|
1308
|
+
const method = this.config.connections.catalog.methods.get(setup.vendor)![setup.method]!;
|
|
1309
|
+
const external =
|
|
1310
|
+
method.kind !== "workload-identity" &&
|
|
1311
|
+
(setup.location?.management === "external" || !this.storedSecrets.store.capabilities.writable);
|
|
1312
|
+
if (
|
|
1313
|
+
!external &&
|
|
1314
|
+
method.kind !== "workload-identity" &&
|
|
1315
|
+
this.storedSecrets.store.capabilities.conditionalWrites === "none"
|
|
1316
|
+
)
|
|
1317
|
+
throw new SecretStoreError("unsupported");
|
|
1318
|
+
if (credentials !== undefined && (external || method.kind === "workload-identity"))
|
|
1319
|
+
throw new SecretStoreError("read_only");
|
|
1320
|
+
const existing = setup.credential ? await this.storedSecrets.read(setup.credential) : undefined;
|
|
1321
|
+
if (external && (!existing || existing.version !== review.credentialVersion))
|
|
1322
|
+
throw new SecretStoreError("conflict");
|
|
1323
|
+
if (setup.expectedRevision === review.expectedRevision && (existing?.version ?? null) !== review.credentialVersion)
|
|
1324
|
+
throw new SecretStoreError("conflict");
|
|
1325
|
+
const encoded =
|
|
1326
|
+
method.kind === "workload-identity"
|
|
1327
|
+
? undefined
|
|
1328
|
+
: external
|
|
1329
|
+
? existing!.value
|
|
1330
|
+
: encodeCredentials(method, credentials);
|
|
1331
|
+
const identity = await verifyAuthentication(
|
|
1332
|
+
method,
|
|
1333
|
+
encoded === undefined ? undefined : decodeCredentials(method, encoded),
|
|
1334
|
+
{ connectionId: setup.id, vendor: setup.vendor },
|
|
1335
|
+
);
|
|
1336
|
+
if (identity.account !== review.account) throw new AuthenticationError("wrong_account");
|
|
1337
|
+
if (JSON.stringify(identity.scopes) !== JSON.stringify([...new Set(review.scopes)].toSorted()))
|
|
1338
|
+
throw new AuthenticationError("scope_changed");
|
|
1339
|
+
const selected = (await this.call(
|
|
1340
|
+
"sanoma.connection.configure",
|
|
1341
|
+
{
|
|
1342
|
+
target: review.target,
|
|
1343
|
+
requestId: review.requestId,
|
|
1344
|
+
expectedRevision: review.expectedRevision,
|
|
1345
|
+
definition: review.definition,
|
|
1346
|
+
account: identity.account,
|
|
1347
|
+
scopes: [...identity.scopes],
|
|
1348
|
+
},
|
|
1349
|
+
{ actor, id: `connection-configure:${review.requestId}` },
|
|
1350
|
+
)) as ConnectionSnapshot;
|
|
1351
|
+
const ref = { vendor: selected.vendor, $connection: selected.id };
|
|
1352
|
+
if (
|
|
1353
|
+
(await this.config.connections.setupState(selected, actor)) === "pending" &&
|
|
1354
|
+
!external &&
|
|
1355
|
+
encoded !== undefined
|
|
1356
|
+
) {
|
|
1357
|
+
let written = false;
|
|
1358
|
+
try {
|
|
1359
|
+
const receipt = await this.config.connections.storedVersion(selected.id, selected.revision);
|
|
1360
|
+
if (receipt) {
|
|
1361
|
+
if (existing?.version !== receipt || existing.value !== encoded) throw new SecretStoreError("conflict");
|
|
1362
|
+
} else {
|
|
1363
|
+
const stored = await this.storedSecrets.write(setup.credential!, encoded, {
|
|
1364
|
+
expectedVersion: review.credentialVersion,
|
|
1365
|
+
by: actor.id,
|
|
1366
|
+
});
|
|
1367
|
+
written = true;
|
|
1368
|
+
await this.config.connections.recordStoredVersion(selected.id, selected.revision, stored.version);
|
|
1369
|
+
}
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
if (written || (error as { code?: string })?.code === "unavailable")
|
|
1372
|
+
await this.config.connections.uncertain(ref, selected.revision, actor.id);
|
|
1373
|
+
throw error;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const connected = (await this.call(
|
|
1377
|
+
"sanoma.connection.activate",
|
|
1378
|
+
{
|
|
1379
|
+
connection: ref,
|
|
1380
|
+
expectedRevision: selected.revision,
|
|
1381
|
+
},
|
|
1382
|
+
{ actor, id: `connection-activate:${review.requestId}` },
|
|
1383
|
+
)) as ConnectionSnapshot;
|
|
1384
|
+
await this.config.connections.assertCurrent(connected, actor.id);
|
|
1385
|
+
return connected;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1151
1388
|
/**
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
* is left out. For server code: a page is told whether one is set, never its value.
|
|
1389
|
+
* Values resolved through the selected store and explicit bindings, with version and change
|
|
1390
|
+
* metadata. Missing names are left out. Server code only; pages receive status, never values.
|
|
1155
1391
|
*/
|
|
1156
1392
|
async secrets(names: readonly string[]): Promise<Map<string, StoredSecret>> {
|
|
1157
1393
|
const found = new Map<string, StoredSecret>();
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { Compute, OAuth2Client } from "google-auth-library";
|
|
3
|
+
import type { Db } from "./db.ts";
|
|
4
|
+
|
|
5
|
+
const LIFETIME = 600_000;
|
|
6
|
+
const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
7
|
+
const CREDENTIAL = /^[A-Za-z0-9_-]{43}$/;
|
|
8
|
+
const ACCOUNT = /^[a-z][a-z0-9-]+@[a-z][a-z0-9-]+\.iam\.gserviceaccount\.com$/;
|
|
9
|
+
|
|
10
|
+
/** A safe error boundary: no HTTP response, credentials, SDK cause or request options. */
|
|
11
|
+
export class CloudIdentityError extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("Cloud identity unavailable");
|
|
14
|
+
this.name = "CloudIdentityError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Provisioning only. Deliver the value once through deployment secrets; persist only its hash. */
|
|
19
|
+
export function createDeploymentCredential(): { credential: string; hash: string } {
|
|
20
|
+
const credential = randomBytes(32).toString("base64url");
|
|
21
|
+
return { credential, hash: credentialHash(credential) };
|
|
22
|
+
}
|
|
23
|
+
const credentialHash = (credential: string) => createHash("sha256").update(credential).digest("hex");
|
|
24
|
+
|
|
25
|
+
export interface IdentityEnrollment {
|
|
26
|
+
deployment: string;
|
|
27
|
+
company: string;
|
|
28
|
+
environment: string;
|
|
29
|
+
serviceAccount: string;
|
|
30
|
+
generation: number;
|
|
31
|
+
}
|
|
32
|
+
export interface IdentityEnrollments {
|
|
33
|
+
/** Return an enrollment only while this hash/generation and deployment are active. Never cache. */
|
|
34
|
+
active(hash: string): Promise<IdentityEnrollment | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Install in the broker's own database using provisioning authority, never a company database. */
|
|
38
|
+
export const CLOUD_IDENTITY_SQL = `
|
|
39
|
+
CREATE SCHEMA IF NOT EXISTS sanoma_identity;
|
|
40
|
+
REVOKE ALL ON SCHEMA sanoma_identity FROM PUBLIC;
|
|
41
|
+
CREATE TABLE IF NOT EXISTS sanoma_identity.deployments (
|
|
42
|
+
id text PRIMARY KEY, company text NOT NULL, environment text NOT NULL,
|
|
43
|
+
service_account text NOT NULL, enabled boolean NOT NULL DEFAULT true
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS sanoma_identity.credentials (
|
|
46
|
+
hash text PRIMARY KEY CHECK (hash ~ '^[0-9a-f]{64}$'),
|
|
47
|
+
deployment text NOT NULL REFERENCES sanoma_identity.deployments(id),
|
|
48
|
+
generation bigint NOT NULL CHECK (generation > 0),
|
|
49
|
+
valid_from timestamptz NOT NULL DEFAULT now(), valid_until timestamptz NOT NULL,
|
|
50
|
+
revoked boolean NOT NULL DEFAULT false,
|
|
51
|
+
UNIQUE (deployment, generation), CHECK (valid_until > valid_from),
|
|
52
|
+
CHECK (isfinite(valid_from) AND isfinite(valid_until))
|
|
53
|
+
);
|
|
54
|
+
REVOKE ALL ON ALL TABLES IN SCHEMA sanoma_identity FROM PUBLIC;
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
/** Give the broker SELECT only; enrollment, rotation and revocation use a separate database role. */
|
|
58
|
+
export function postgresIdentityEnrollments(database: () => Promise<Db>): IdentityEnrollments {
|
|
59
|
+
return {
|
|
60
|
+
async active(hash) {
|
|
61
|
+
const { rows } = await (
|
|
62
|
+
await database()
|
|
63
|
+
).query<{
|
|
64
|
+
deployment: string;
|
|
65
|
+
company: string;
|
|
66
|
+
environment: string;
|
|
67
|
+
service_account: string;
|
|
68
|
+
generation: string;
|
|
69
|
+
}>(
|
|
70
|
+
`SELECT d.id AS deployment, d.company, d.environment, d.service_account, c.generation
|
|
71
|
+
FROM sanoma_identity.credentials c JOIN sanoma_identity.deployments d ON d.id = c.deployment
|
|
72
|
+
WHERE c.hash = $1 AND NOT c.revoked AND d.enabled
|
|
73
|
+
AND c.valid_from <= clock_timestamp() AND c.valid_until > clock_timestamp()`,
|
|
74
|
+
[hash],
|
|
75
|
+
);
|
|
76
|
+
const row = rows[0];
|
|
77
|
+
return (
|
|
78
|
+
row && {
|
|
79
|
+
deployment: row.deployment,
|
|
80
|
+
company: row.company,
|
|
81
|
+
environment: row.environment,
|
|
82
|
+
serviceAccount: row.service_account,
|
|
83
|
+
generation: Number(row.generation),
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface CloudAccessToken {
|
|
91
|
+
accessToken: string;
|
|
92
|
+
expiresAt: number;
|
|
93
|
+
}
|
|
94
|
+
export interface IdentityAudit {
|
|
95
|
+
result: "issued" | "denied" | "unavailable";
|
|
96
|
+
enrollment?: IdentityEnrollment;
|
|
97
|
+
expiresAt?: number;
|
|
98
|
+
}
|
|
99
|
+
export interface IdentityBrokerOptions {
|
|
100
|
+
enrollments: IdentityEnrollments;
|
|
101
|
+
/** Trusted issuer with attached platform authority; target comes exclusively from enrollment. */
|
|
102
|
+
issue(serviceAccount: string): Promise<CloudAccessToken>;
|
|
103
|
+
/** Must durably record the decision without token values. A failure prevents delivery. */
|
|
104
|
+
audit(event: IdentityAudit): Promise<void>;
|
|
105
|
+
}
|
|
106
|
+
const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
|
|
107
|
+
!!e &&
|
|
108
|
+
[e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
|
|
109
|
+
ACCOUNT.test(e.serviceAccount) &&
|
|
110
|
+
Number.isSafeInteger(e.generation) &&
|
|
111
|
+
e.generation > 0;
|
|
112
|
+
const validToken = (token: CloudAccessToken, now: number) =>
|
|
113
|
+
typeof token?.accessToken === "string" &&
|
|
114
|
+
token.accessToken.length > 0 &&
|
|
115
|
+
token.accessToken.length <= 8192 &&
|
|
116
|
+
!/[\s\r\n]/.test(token.accessToken) &&
|
|
117
|
+
Number.isFinite(token.expiresAt) &&
|
|
118
|
+
token.expiresAt > now &&
|
|
119
|
+
token.expiresAt <= now + LIFETIME + 30_000;
|
|
120
|
+
const reply = (status: number, body: unknown) =>
|
|
121
|
+
Response.json(body, {
|
|
122
|
+
status,
|
|
123
|
+
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
|
|
127
|
+
// without buffering attacker-controlled input or waiting indefinitely for its end.
|
|
128
|
+
async function hasBody(request: Request): Promise<boolean> {
|
|
129
|
+
if (!request.body) return false;
|
|
130
|
+
const reader = request.body.getReader();
|
|
131
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
132
|
+
try {
|
|
133
|
+
const first = await Promise.race([
|
|
134
|
+
reader.read(),
|
|
135
|
+
new Promise<never>((_, reject) => {
|
|
136
|
+
timeout = setTimeout(() => reject(new CloudIdentityError()), 5_000);
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
return !first.done;
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timeout);
|
|
142
|
+
void reader.cancel().catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** POST /token with a deployment bearer credential and no query or body. No enrollment endpoint. */
|
|
147
|
+
export function identityBroker(options: IdentityBrokerOptions): (request: Request) => Promise<Response> {
|
|
148
|
+
return async (request) => {
|
|
149
|
+
let enrollment: IdentityEnrollment | undefined;
|
|
150
|
+
try {
|
|
151
|
+
const url = new URL(request.url);
|
|
152
|
+
if (url.pathname !== "/token" || request.method !== "POST") return reply(404, { error: "not_found" });
|
|
153
|
+
if (url.search || (await hasBody(request))) return reply(400, { error: "invalid_request" });
|
|
154
|
+
const authorization = request.headers.get("authorization") ?? "";
|
|
155
|
+
const credential = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
|
|
156
|
+
const hash = CREDENTIAL.test(credential) ? credentialHash(credential) : undefined;
|
|
157
|
+
enrollment = hash ? await options.enrollments.active(hash) : undefined;
|
|
158
|
+
if (!validEnrollment(enrollment)) {
|
|
159
|
+
await options.audit({ result: "denied" });
|
|
160
|
+
return reply(401, { error: "unauthorized" });
|
|
161
|
+
}
|
|
162
|
+
// Copy authoritative values before any awaits; caller fields never select a target or lifetime.
|
|
163
|
+
enrollment = {
|
|
164
|
+
deployment: enrollment.deployment,
|
|
165
|
+
company: enrollment.company,
|
|
166
|
+
environment: enrollment.environment,
|
|
167
|
+
serviceAccount: enrollment.serviceAccount,
|
|
168
|
+
generation: enrollment.generation,
|
|
169
|
+
};
|
|
170
|
+
const token = await options.issue(enrollment.serviceAccount);
|
|
171
|
+
if (!validToken(token, Date.now())) throw new CloudIdentityError();
|
|
172
|
+
const current = await options.enrollments.active(hash!);
|
|
173
|
+
if (
|
|
174
|
+
!current ||
|
|
175
|
+
(Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
|
|
176
|
+
) {
|
|
177
|
+
await options.audit({ result: "denied", enrollment });
|
|
178
|
+
return reply(401, { error: "unauthorized" });
|
|
179
|
+
}
|
|
180
|
+
await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
|
|
181
|
+
return reply(200, { accessToken: token.accessToken, expiresAt: token.expiresAt });
|
|
182
|
+
} catch {
|
|
183
|
+
try {
|
|
184
|
+
await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
|
|
185
|
+
} catch {
|
|
186
|
+
/* fail closed */
|
|
187
|
+
}
|
|
188
|
+
return reply(503, { error: "unavailable" });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Cloud Run attached identity only: no ADC environment file or service-account private key fallback. */
|
|
194
|
+
export function attachedGoogleTokenIssuer(): IdentityBrokerOptions["issue"] {
|
|
195
|
+
const auth = new Compute({ scopes: [SCOPE] });
|
|
196
|
+
return async (serviceAccount) => {
|
|
197
|
+
if (!ACCOUNT.test(serviceAccount)) throw new CloudIdentityError();
|
|
198
|
+
try {
|
|
199
|
+
const response = await auth.request<{ accessToken: string; expireTime: string }>({
|
|
200
|
+
url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${serviceAccount}:generateAccessToken`,
|
|
201
|
+
method: "POST",
|
|
202
|
+
data: { scope: [SCOPE], lifetime: "600s" },
|
|
203
|
+
timeout: 10_000,
|
|
204
|
+
});
|
|
205
|
+
const token = { accessToken: response.data.accessToken, expiresAt: Date.parse(response.data.expireTime) };
|
|
206
|
+
if (!validToken(token, Date.now())) throw new CloudIdentityError();
|
|
207
|
+
return token;
|
|
208
|
+
} catch {
|
|
209
|
+
throw new CloudIdentityError();
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A per-runtime Google SDK auth client. Short-lived access tokens are kept only in this instance's memory. */
|
|
215
|
+
export function cloudIdentityAuth(options: {
|
|
216
|
+
endpoint: string;
|
|
217
|
+
/** Read the current deployment credential from protected bootstrap; called again on renewal. */
|
|
218
|
+
credential(): Promise<string>;
|
|
219
|
+
}): OAuth2Client {
|
|
220
|
+
let endpoint: URL;
|
|
221
|
+
try {
|
|
222
|
+
endpoint = new URL(options.endpoint);
|
|
223
|
+
} catch {
|
|
224
|
+
throw new CloudIdentityError();
|
|
225
|
+
}
|
|
226
|
+
if (
|
|
227
|
+
endpoint.protocol !== "https:" ||
|
|
228
|
+
endpoint.username ||
|
|
229
|
+
endpoint.password ||
|
|
230
|
+
endpoint.search ||
|
|
231
|
+
endpoint.hash ||
|
|
232
|
+
endpoint.pathname !== "/token"
|
|
233
|
+
)
|
|
234
|
+
throw new CloudIdentityError();
|
|
235
|
+
const client = new OAuth2Client({ eagerRefreshThresholdMillis: 60_000 });
|
|
236
|
+
let pending: Promise<{ access_token: string; expiry_date: number }> | undefined;
|
|
237
|
+
client.refreshHandler = () => {
|
|
238
|
+
if (pending) return pending;
|
|
239
|
+
pending = (async () => {
|
|
240
|
+
try {
|
|
241
|
+
const credential = await options.credential();
|
|
242
|
+
if (!CREDENTIAL.test(credential)) throw new CloudIdentityError();
|
|
243
|
+
const response = await fetch(endpoint, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: { Authorization: `Bearer ${credential}` },
|
|
246
|
+
cache: "no-store",
|
|
247
|
+
redirect: "error",
|
|
248
|
+
signal: AbortSignal.timeout(10_000),
|
|
249
|
+
});
|
|
250
|
+
if (!response.ok) throw new CloudIdentityError();
|
|
251
|
+
const raw = await response.text();
|
|
252
|
+
if (raw.length > 16_384) throw new CloudIdentityError();
|
|
253
|
+
const token = JSON.parse(raw) as CloudAccessToken;
|
|
254
|
+
const now = Date.now();
|
|
255
|
+
if (!validToken(token, now) || token.expiresAt <= now + 60_000) throw new CloudIdentityError();
|
|
256
|
+
return { access_token: token.accessToken, expiry_date: token.expiresAt };
|
|
257
|
+
} catch {
|
|
258
|
+
throw new CloudIdentityError();
|
|
259
|
+
} finally {
|
|
260
|
+
pending = undefined;
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
return pending;
|
|
264
|
+
};
|
|
265
|
+
return client;
|
|
266
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { configuredSecrets } from "./credentials.ts";
|
|
2
|
+
import { secretsForInput } from "./secret.ts";
|
|
3
|
+
import { isRunControlError } from "./errors.ts";
|
|
2
4
|
import { currentRun } from "./run.ts";
|
|
3
5
|
import {
|
|
4
6
|
connectionCatalog,
|
|
@@ -331,6 +333,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
331
333
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
332
334
|
],
|
|
333
335
|
ops,
|
|
336
|
+
{ connections },
|
|
334
337
|
);
|
|
335
338
|
const fakes = makersOf(running, "fake");
|
|
336
339
|
if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
|
|
@@ -617,11 +620,15 @@ function indexConnectors(list: Connector<any, any>[]) {
|
|
|
617
620
|
}
|
|
618
621
|
|
|
619
622
|
/**
|
|
620
|
-
* The drivers' functions by operation id, refusing one no connector declares.
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
+
* The drivers' functions by operation id, refusing one no connector declares. Named live
|
|
624
|
+
* calls resolve authentication here, after policy, inside the driver's execution boundary.
|
|
625
|
+
* Drivers without named authentication retain their declared environment guard.
|
|
623
626
|
*/
|
|
624
|
-
export function indexDrivers(
|
|
627
|
+
export function indexDrivers(
|
|
628
|
+
list: Driver[],
|
|
629
|
+
ops: Map<string, Op>,
|
|
630
|
+
options: { connections?: ConnectionRegistry; sandbox?: boolean } = {},
|
|
631
|
+
) {
|
|
625
632
|
const map = new Map<string, DriverFn>();
|
|
626
633
|
for (const d of list) {
|
|
627
634
|
for (const [key, fn] of Object.entries(d.ops)) {
|
|
@@ -629,7 +636,36 @@ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
|
|
|
629
636
|
if (!ops.has(id)) {
|
|
630
637
|
throw new Error(`Driver "${d.vendor}" implements ${id}, which no connector in \`connectors\` declares`);
|
|
631
638
|
}
|
|
632
|
-
|
|
639
|
+
const unselected = d.env ? guarded(d.vendor, d.env, fn) : fn;
|
|
640
|
+
map.set(id, async (input, call) => {
|
|
641
|
+
if (options.sandbox) return fn(input, call);
|
|
642
|
+
if (!call.connection) {
|
|
643
|
+
if (d.authentication?.length)
|
|
644
|
+
throw new DriverError("Select a named connection for this driver", { retryable: false });
|
|
645
|
+
return unselected(input, call);
|
|
646
|
+
}
|
|
647
|
+
if (!options.connections || !call.principal || !d.authentication?.includes(call.connection.method))
|
|
648
|
+
throw new DriverError("This driver does not support the selected connection", { retryable: false });
|
|
649
|
+
const authentication = await options.connections.authenticate(call.connection, call.principal.id);
|
|
650
|
+
try {
|
|
651
|
+
return await fn(input, {
|
|
652
|
+
...call,
|
|
653
|
+
connection: structuredClone(call.connection),
|
|
654
|
+
authentication,
|
|
655
|
+
secrets: secretsForInput(input, call.secrets, ops.get(id)!.createsSecrets?.(input)),
|
|
656
|
+
});
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (isRunControlError(error)) throw error;
|
|
659
|
+
// SDK exceptions can contain the complete request, including authorization headers.
|
|
660
|
+
// Drivers may come from another installed copy of this package.
|
|
661
|
+
const failure = error as { code?: unknown; retryable?: unknown; status?: unknown } | null;
|
|
662
|
+
const status = failure?.code === "driver_failed" ? failure.status : undefined;
|
|
663
|
+
throw new DriverError(`${d.vendor} operation failed`, {
|
|
664
|
+
retryable: failure?.code === "driver_failed" && failure.retryable === true,
|
|
665
|
+
...(typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599 && { status }),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
});
|
|
633
669
|
}
|
|
634
670
|
}
|
|
635
671
|
return map;
|
|
@@ -637,12 +673,10 @@ export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
|
|
|
637
673
|
|
|
638
674
|
/**
|
|
639
675
|
* `fn`, refused while the environment leaves a variable `env` declares missing or invalid. The
|
|
640
|
-
* worker started without it (`startWorker` only warns)
|
|
641
|
-
* environment
|
|
642
|
-
* driver step
|
|
643
|
-
*
|
|
644
|
-
* used when the approver approves. The refusal is the call's own failure, final: recorded as
|
|
645
|
-
* the call's, and never retried.
|
|
676
|
+
* worker started without it (`startWorker` only warns). This checks only explicitly supplied
|
|
677
|
+
* process environment; stored credentials are never copied there. The guard runs inside the
|
|
678
|
+
* driver step after policy and approval. Named connections never use this path. The refusal
|
|
679
|
+
* is the call's own failure, final: recorded as the call's, and never retried.
|
|
646
680
|
*/
|
|
647
681
|
function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
|
|
648
682
|
return async (input, call) => {
|