@stigmer/server 3.30.0 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +5 -0
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +10 -0
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js +2 -0
- package/dist/boot/config.js.map +1 -1
- package/dist/domain/oauthapp/controller.d.ts +16 -8
- package/dist/domain/oauthapp/controller.d.ts.map +1 -1
- package/dist/domain/oauthapp/controller.js +28 -11
- package/dist/domain/oauthapp/controller.js.map +1 -1
- package/dist/domain/oauthapp/refresolution.d.ts.map +1 -1
- package/dist/domain/oauthapp/refresolution.js +3 -2
- package/dist/domain/oauthapp/refresolution.js.map +1 -1
- package/dist/domain/oauthapp/steps.d.ts +5 -5
- package/dist/domain/oauthapp/steps.js +6 -6
- package/dist/domain/oauthapp/steps.js.map +1 -1
- package/dist/sandbox/docker.d.ts.map +1 -1
- package/dist/sandbox/docker.js +13 -5
- package/dist/sandbox/docker.js.map +1 -1
- package/dist/sandbox/kubernetes.d.ts +1 -1
- package/dist/sandbox/kubernetes.d.ts.map +1 -1
- package/dist/sandbox/kubernetes.js +18 -4
- package/dist/sandbox/kubernetes.js.map +1 -1
- package/dist/sandbox/local-process.d.ts.map +1 -1
- package/dist/sandbox/local-process.js +8 -1
- package/dist/sandbox/local-process.js.map +1 -1
- package/dist/sandbox/provisioner.d.ts +12 -0
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js.map +1 -1
- package/dist/temporal/manager.d.ts +10 -0
- package/dist/temporal/manager.d.ts.map +1 -1
- package/dist/temporal/manager.js +10 -0
- package/dist/temporal/manager.js.map +1 -1
- package/package.json +6 -6
- package/src/boot/__tests__/config.test.ts +23 -1
- package/src/boot/compose.ts +5 -0
- package/src/boot/config.ts +13 -0
- package/src/domain/oauthapp/__tests__/oauthapp.test.ts +17 -10
- package/src/domain/oauthapp/controller.ts +51 -18
- package/src/domain/oauthapp/refresolution.ts +3 -2
- package/src/domain/oauthapp/steps.ts +6 -6
- package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
- package/src/sandbox/__tests__/kubernetes.test.ts +27 -0
- package/src/sandbox/__tests__/local-process.integration.test.ts +10 -0
- package/src/sandbox/__tests__/provisioner.test.ts +2 -0
- package/src/sandbox/docker.ts +11 -5
- package/src/sandbox/kubernetes.ts +18 -3
- package/src/sandbox/local-process.ts +11 -1
- package/src/sandbox/provisioner.ts +12 -0
- package/src/temporal/__tests__/manager.test.ts +59 -1
- package/src/temporal/manager.ts +13 -0
- package/dist/domain/oauthapp/refresolution/refresolution.d.ts +0 -12
- package/dist/domain/oauthapp/refresolution/refresolution.d.ts.map +0 -1
- package/dist/domain/oauthapp/refresolution/refresolution.js +0 -79
- package/dist/domain/oauthapp/refresolution/refresolution.js.map +0 -1
- package/src/domain/oauthapp/refresolution/refresolution.ts +0 -94
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
* ciphertext (no double encryption);
|
|
12
12
|
* - the keyless WARN-degrade path stores plaintext yet still redacts on
|
|
13
13
|
* read, and the enc:v<N>: shape rejection stays UNCONDITIONAL (oss#395);
|
|
14
|
-
* - the delete RPC
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* - the delete RPC answers the removed app REDACTED, never the stored
|
|
15
|
+
* secret: not the ciphertext on a keyed server, not the plaintext on
|
|
16
|
+
* a keyless one (stigmer/stigmer#1257; the Go port returned it);
|
|
17
17
|
* - the referential delete guard's resolution semantics (stigmer#584),
|
|
18
18
|
* proven by seeding McpServer rows directly through the store — the
|
|
19
19
|
* McpServer RPC surface belongs to #9 and is not required here.
|
|
20
20
|
*
|
|
21
21
|
* Keys are injected via env (vi.stubEnv) so the ladder short-circuits
|
|
22
|
-
* before its file steps — the real ~/.stigmer is never touched.
|
|
22
|
+
* before its file steps — the real ~/.stigmer is never touched. Who may
|
|
23
|
+
* read or list an app is the enforcing lane's conformance suite's
|
|
24
|
+
* (role-enforcement.conformance.test.ts).
|
|
23
25
|
*/
|
|
24
26
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
25
27
|
import { tmpdir } from "node:os";
|
|
@@ -50,7 +52,7 @@ import {
|
|
|
50
52
|
REDACTED_MARKER,
|
|
51
53
|
deleteBlockedByMcpServerMessage,
|
|
52
54
|
} from "../constants.js";
|
|
53
|
-
import { resolveOAuthAppRef } from "../refresolution
|
|
55
|
+
import { resolveOAuthAppRef } from "../refresolution.js";
|
|
54
56
|
|
|
55
57
|
const silentLogger = createLogger({ level: "error", pretty: false, write: () => {} });
|
|
56
58
|
|
|
@@ -285,14 +287,12 @@ describe("oauthapp domain (encryption enabled)", () => {
|
|
|
285
287
|
expect(listed.entries[1]?.metadata?.id).toBe(first.metadata?.id);
|
|
286
288
|
});
|
|
287
289
|
|
|
288
|
-
it("delete
|
|
290
|
+
it("delete answers the removed app redacted — never the stored ciphertext", async () => {
|
|
289
291
|
const created = await ts.command.create(appInput());
|
|
290
292
|
const deleted = await ts.command.delete({ resourceId: created.metadata!.id });
|
|
291
293
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
// faithful port cannot silently "fix" the divergence candidate.
|
|
295
|
-
expect(deleted.spec?.clientSecret.startsWith(ENCRYPTED_PREFIX)).toBe(true);
|
|
294
|
+
expect(deleted.metadata?.id).toBe(created.metadata?.id);
|
|
295
|
+
expect(deleted.spec?.clientSecret).toBe(REDACTED_MARKER);
|
|
296
296
|
|
|
297
297
|
const err = await grpcError(() => ts.query.get({ value: created.metadata!.id }));
|
|
298
298
|
expect(err.code).toBe(Code.NotFound);
|
|
@@ -405,6 +405,13 @@ describe("oauthapp domain (encryption disabled)", () => {
|
|
|
405
405
|
expect(stored.spec?.clientSecret).toBe("test-client-secret");
|
|
406
406
|
});
|
|
407
407
|
|
|
408
|
+
it("delete answers the removed app redacted — never the stored plaintext", async () => {
|
|
409
|
+
const created = await ts.command.create(appInput());
|
|
410
|
+
const deleted = await ts.command.delete({ resourceId: created.metadata!.id });
|
|
411
|
+
|
|
412
|
+
expect(deleted.spec?.clientSecret).toBe(REDACTED_MARKER);
|
|
413
|
+
});
|
|
414
|
+
|
|
408
415
|
it("still rejects ciphertext-shaped secrets — the oss#395 boundary is not gated on key state", async () => {
|
|
409
416
|
const err = await grpcError(() =>
|
|
410
417
|
ts.command.create(appInput({ clientSecret: "enc:v1:c211Z2dsZWQ=" })),
|
|
@@ -12,15 +12,20 @@
|
|
|
12
12
|
* The secret contract: client_secret is AES-256-GCM encrypted at rest via
|
|
13
13
|
* the SAME SecretService instance the Environment controller uses (Go
|
|
14
14
|
* wires one service for both), and redacted to ***REDACTED*** on every
|
|
15
|
-
*
|
|
16
|
-
* secret"; client-supplied enc:v<N>:-shaped values are
|
|
17
|
-
* write door (oss#395). Deletion is blocked while an
|
|
18
|
-
* oauth_app_ref resolves to the app (stigmer/stigmer#584).
|
|
15
|
+
* response, the delete's included; the marker round-trips on update/apply
|
|
16
|
+
* as "keep the stored secret"; client-supplied enc:v<N>:-shaped values are
|
|
17
|
+
* refused on every write door (oss#395). Deletion is blocked while an
|
|
18
|
+
* McpServer's oauth_app_ref resolves to the app (stigmer/stigmer#584).
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Every chain opens with Authorize; create and delete run the shared
|
|
21
|
+
* tuple-lifecycle steps against the composed lifecycle; getByReference
|
|
22
|
+
* loads, then authorizes the loaded app exactly as `get` would
|
|
23
|
+
* (AuthorizeResolvedTarget); listByOrg narrows through the list read
|
|
24
|
+
* scope, so a caller who may not `get` an app does not see it listed
|
|
25
|
+
* either: the model shows an app to its creator and the organization's
|
|
26
|
+
* admins, never to members or viewers (fga/model/iam/oauth_app.fga,
|
|
27
|
+
* stigmer/stigmer#1257). OAuthApp is deliberately not search-indexed (a
|
|
28
|
+
* configuration resource; `not_search_indexed` in the kind registry).
|
|
24
29
|
*/
|
|
25
30
|
import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
|
|
26
31
|
import { create, fromBinary } from "@bufbuild/protobuf";
|
|
@@ -43,6 +48,8 @@ import { OAuthAppQueryController } from "@stigmer/protos/ai/stigmer/iam/oauthapp
|
|
|
43
48
|
|
|
44
49
|
import type { Logger } from "../../boot/logger.js";
|
|
45
50
|
import type { Authorizer } from "../../extensions/authorizer.js";
|
|
51
|
+
import type { ListReadScope } from "../../extensions/list-read-scope.js";
|
|
52
|
+
import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
|
|
46
53
|
import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
|
|
47
54
|
import type { SecretService } from "../../encryption/encryption.js";
|
|
48
55
|
import { internalError } from "../../pipeline/errors.js";
|
|
@@ -105,6 +112,8 @@ export interface OAuthAppControllerDeps {
|
|
|
105
112
|
readonly authorizer: Authorizer;
|
|
106
113
|
/** The composed tuple-lifecycle driver — undefined = the shared steps no-op (C2). */
|
|
107
114
|
readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
|
|
115
|
+
/** The composed list read scope — listByOrg narrows through it; undefined = the OSS full scan (stigmer/stigmer#1257). */
|
|
116
|
+
readonly listReadScope: ListReadScope | undefined;
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
/** Registers both OAuthApp services on the router (routes stage). */
|
|
@@ -261,8 +270,10 @@ async function apply(
|
|
|
261
270
|
* between load and delete. The RESOURCE_ID_KEY is set manually because
|
|
262
271
|
* ApiResourceDeleteInput carries resourceId, not the value field
|
|
263
272
|
* ExtractResourceId expects (the environment-domain pattern). Returns the
|
|
264
|
-
* deleted
|
|
265
|
-
*
|
|
273
|
+
* deleted app for the audit trail, redacted like every other response (the
|
|
274
|
+
* ChannelApp and Environment delete shape): the chain has already
|
|
275
|
+
* destroyed the sealed secret, so the stored value could only leak
|
|
276
|
+
* (stigmer/stigmer#1257).
|
|
266
277
|
*/
|
|
267
278
|
async function deleteOAuthApp(
|
|
268
279
|
deps: OAuthAppControllerDeps,
|
|
@@ -311,7 +322,9 @@ async function deleteOAuthApp(
|
|
|
311
322
|
"deleted OAuthApp not found in context",
|
|
312
323
|
);
|
|
313
324
|
}
|
|
314
|
-
|
|
325
|
+
const app = deleted as OAuthApp;
|
|
326
|
+
redactOAuthApp(app);
|
|
327
|
+
return app;
|
|
315
328
|
}
|
|
316
329
|
|
|
317
330
|
/** Get — LoadTarget by id, then redact (Go buildGetPipeline). */
|
|
@@ -382,9 +395,11 @@ async function getByReference(
|
|
|
382
395
|
const LIST_RESULT_KEY = "listResult";
|
|
383
396
|
|
|
384
397
|
/**
|
|
385
|
-
* ListByOrg —
|
|
386
|
-
* sorted created_at descending (Go listByOrgStep). No
|
|
387
|
-
* typically 1–5 per org.
|
|
398
|
+
* ListByOrg — the organization's OAuthApps the caller may view, each
|
|
399
|
+
* redacted, sorted created_at descending (Go listByOrgStep). No
|
|
400
|
+
* pagination: typically 1–5 per org. The annotation admits anyone who can
|
|
401
|
+
* view the organization; the list read scope then keeps only the apps a
|
|
402
|
+
* `get` would return (stigmer/stigmer#1257).
|
|
388
403
|
*/
|
|
389
404
|
async function listByOrg(
|
|
390
405
|
deps: OAuthAppControllerDeps,
|
|
@@ -408,7 +423,7 @@ async function listByOrg(
|
|
|
408
423
|
),
|
|
409
424
|
)
|
|
410
425
|
.addStep(newValidateProtoStep())
|
|
411
|
-
.addStep(newListByOrgStep(deps.store, deps.logger))
|
|
426
|
+
.addStep(newListByOrgStep(deps.store, deps.listReadScope, deps.logger))
|
|
412
427
|
.build()
|
|
413
428
|
.execute(reqCtx);
|
|
414
429
|
|
|
@@ -422,9 +437,17 @@ async function listByOrg(
|
|
|
422
437
|
return list as OAuthApps;
|
|
423
438
|
}
|
|
424
439
|
|
|
425
|
-
/**
|
|
440
|
+
/**
|
|
441
|
+
* The domain-local list step: load all, keep the request's org, narrow to
|
|
442
|
+
* the caller's apps, redact, sort. The org predicate stays the step's own
|
|
443
|
+
* because with no scope composed the helper returns its input unchanged
|
|
444
|
+
* and never reads the org (the trusted-local full scan); with one, the
|
|
445
|
+
* scope is the last per-row predicate, so a composed driver is asked about
|
|
446
|
+
* the org's apps, never every tenant's (the environment list's order).
|
|
447
|
+
*/
|
|
426
448
|
function newListByOrgStep(
|
|
427
449
|
store: Store,
|
|
450
|
+
listReadScope: ListReadScope | undefined,
|
|
428
451
|
logger: Logger,
|
|
429
452
|
): PipelineStep<typeof OAuthAppQueryController.method.listByOrg.input> {
|
|
430
453
|
return {
|
|
@@ -443,7 +466,7 @@ function newListByOrgStep(
|
|
|
443
466
|
throw internalError(error, "failed to list OAuthApps");
|
|
444
467
|
}
|
|
445
468
|
|
|
446
|
-
const
|
|
469
|
+
const requested: OAuthApp[] = [];
|
|
447
470
|
for (const data of resources) {
|
|
448
471
|
let app: OAuthApp;
|
|
449
472
|
try {
|
|
@@ -457,8 +480,18 @@ function newListByOrgStep(
|
|
|
457
480
|
if ((app.metadata?.org ?? "") !== org) {
|
|
458
481
|
continue;
|
|
459
482
|
}
|
|
483
|
+
requested.push(app);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const apps = await restrictListByReadScope(
|
|
487
|
+
listReadScope,
|
|
488
|
+
ctx.callerIdentity,
|
|
489
|
+
ApiResourceKind.oauth_app,
|
|
490
|
+
requested,
|
|
491
|
+
"",
|
|
492
|
+
);
|
|
493
|
+
for (const app of apps) {
|
|
460
494
|
redactOAuthApp(app);
|
|
461
|
-
apps.push(app);
|
|
462
495
|
}
|
|
463
496
|
|
|
464
497
|
apps.sort((a, b) =>
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* answers): the read-path oauth_status enricher (mcpserver, #9), the
|
|
8
8
|
* OAuth initiate and token-refresh paths (#19 — refresh MUST use the app
|
|
9
9
|
* initiate selected or it runs against the wrong vendor credentials), and
|
|
10
|
-
* the OAuthApp delete guard (#13).
|
|
11
|
-
*
|
|
10
|
+
* the OAuthApp delete guard (#13). Every consumer imports it from here,
|
|
11
|
+
* so there is one ladder to change (stigmer/stigmer#1173 removed a
|
|
12
|
+
* second copy the delete guard had read).
|
|
12
13
|
*
|
|
13
14
|
* Resolution semantics: OSS has a flat OAuthApp store — no org-override
|
|
14
15
|
* chain like the cloud's OAuthAppResolutionService, so the ref is the
|
|
@@ -33,18 +33,18 @@ import {
|
|
|
33
33
|
REDACTED_MARKER,
|
|
34
34
|
deleteBlockedByMcpServerMessage,
|
|
35
35
|
} from "./constants.js";
|
|
36
|
-
import { resolveOAuthAppRef } from "./refresolution
|
|
36
|
+
import { resolveOAuthAppRef } from "./refresolution.js";
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Replaces client_secret with the redaction marker (Go RedactOAuthApp).
|
|
40
40
|
*
|
|
41
41
|
* A function rather than a pipeline step because redaction applies at
|
|
42
42
|
* different points per operation: create/update after persist on newState;
|
|
43
|
-
* get/getByReference after load on the target; listByOrg per entry
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* get/getByReference after load on the target; listByOrg per entry; delete
|
|
44
|
+
* on the removed row after its chain. Every response is redacted, the
|
|
45
|
+
* delete's included: the Go port returned the stored secret from delete —
|
|
46
|
+
* ciphertext, or plaintext on a keyless server — which no reader needs
|
|
47
|
+
* once the chain has destroyed the sealed value (stigmer/stigmer#1257).
|
|
48
48
|
*/
|
|
49
49
|
export function redactOAuthApp(app: OAuthApp): void {
|
|
50
50
|
if (app.spec !== undefined && app.spec.clientSecret !== "") {
|
|
@@ -47,6 +47,8 @@ describe.skipIf(!optedIn || !dockerAnswers())(
|
|
|
47
47
|
// them (the runner tolerates an unreachable backend at boot).
|
|
48
48
|
backendEndpoint: "http://host.docker.internal:7234",
|
|
49
49
|
temporalAddress: "host.docker.internal:7233",
|
|
50
|
+
temporalNamespace: "default",
|
|
51
|
+
temporalConnectionEnv: {},
|
|
50
52
|
runnerImage:
|
|
51
53
|
process.env["STIGMER_SANDBOX_RUNNER_IMAGE"] ??
|
|
52
54
|
"ghcr.io/stigmer/runner:latest",
|
|
@@ -34,6 +34,8 @@ const silentLogger = { info: () => {} };
|
|
|
34
34
|
const config: SandboxDriverConfig = {
|
|
35
35
|
backendEndpoint: "http://stigmer-server.stigmer.svc:7234",
|
|
36
36
|
temporalAddress: "temporal.stigmer.svc:7233",
|
|
37
|
+
temporalNamespace: "stigmer",
|
|
38
|
+
temporalConnectionEnv: {},
|
|
37
39
|
runnerImage: "ghcr.io/stigmer/runner:latest",
|
|
38
40
|
runnerCommand: "stigmer-runner",
|
|
39
41
|
kubernetesNamespace: "stigmer-sandboxes",
|
|
@@ -205,6 +207,7 @@ describe("the manifest shapes (the Java SandboxManifestFactory pins)", () => {
|
|
|
205
207
|
expect(envByName.get("TEMPORAL_SERVICE_ADDRESS")?.value).toBe(
|
|
206
208
|
config.temporalAddress,
|
|
207
209
|
);
|
|
210
|
+
expect(envByName.get("TEMPORAL_NAMESPACE")?.value).toBe("stigmer");
|
|
208
211
|
expect(envByName.get("WORKSPACE_ROOT_DIR")?.value).toBe("/workspace");
|
|
209
212
|
expect(envByName.get("STIGMER_TOKEN")?.valueFrom?.secretKeyRef?.name).toBe(
|
|
210
213
|
`${sandboxBaseName("session", "ses_1")}-env`,
|
|
@@ -233,4 +236,28 @@ describe("the manifest shapes (the Java SandboxManifestFactory pins)", () => {
|
|
|
233
236
|
const secret = buildSandboxSecret("session", "ses_1", "");
|
|
234
237
|
expect(secret.stringData).toEqual({});
|
|
235
238
|
});
|
|
239
|
+
|
|
240
|
+
it("an authenticated Temporal's settings live in the Secret and are only named by the Deployment", () => {
|
|
241
|
+
const connectionEnv = {
|
|
242
|
+
STIGMER_TEMPORAL_TLS: "true",
|
|
243
|
+
STIGMER_TEMPORAL_API_KEY: "api-key-1",
|
|
244
|
+
STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_DATA: "-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----\n",
|
|
245
|
+
};
|
|
246
|
+
const authenticated = { ...config, temporalConnectionEnv: connectionEnv };
|
|
247
|
+
const env = { taskQueue: "session:ses_1", stigmerToken: "tok", callerClass: "user" };
|
|
248
|
+
|
|
249
|
+
const secret = buildSandboxSecret("session", "ses_1", "tok", connectionEnv);
|
|
250
|
+
expect(secret.stringData).toEqual({ ...connectionEnv, STIGMER_TOKEN: "tok" });
|
|
251
|
+
|
|
252
|
+
const container = buildSandboxDeployment("session", "ses_1", env, authenticated).spec?.template.spec
|
|
253
|
+
?.containers[0];
|
|
254
|
+
for (const name of Object.keys(connectionEnv)) {
|
|
255
|
+
const entry = (container?.env ?? []).find((candidate) => candidate.name === name);
|
|
256
|
+
expect(entry?.value, `${name} must not be a plain manifest value`).toBeUndefined();
|
|
257
|
+
expect(entry?.valueFrom?.secretKeyRef).toEqual({
|
|
258
|
+
name: `${sandboxBaseName("session", "ses_1")}-env`,
|
|
259
|
+
key: name,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
236
263
|
});
|
|
@@ -54,6 +54,8 @@ describe.skipIf(process.platform === "win32")(
|
|
|
54
54
|
const config: SandboxDriverConfig = {
|
|
55
55
|
backendEndpoint: "http://127.0.0.1:7234",
|
|
56
56
|
temporalAddress: "127.0.0.1:7233",
|
|
57
|
+
temporalNamespace: "stigmer-smoke",
|
|
58
|
+
temporalConnectionEnv: { STIGMER_TEMPORAL_TLS: "true", STIGMER_TEMPORAL_API_KEY: "k-smoke" },
|
|
57
59
|
runnerImage: "unused-by-this-driver",
|
|
58
60
|
runnerCommand: script,
|
|
59
61
|
kubernetesNamespace: "unused-by-this-driver",
|
|
@@ -78,6 +80,9 @@ describe.skipIf(process.platform === "win32")(
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
it("ensure spawns once, injects the runner env contract, and is idempotent", async () => {
|
|
83
|
+
// A server configured by path: the child must get the rendered
|
|
84
|
+
// settings only, never the inherited _PATH item beside them.
|
|
85
|
+
process.env["STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH"] = "/etc/inherited-ca.pem";
|
|
81
86
|
await driver.ensureSessionSandbox("ses_smoke", {
|
|
82
87
|
taskQueue: "session:ses_smoke",
|
|
83
88
|
stigmerToken: "tok-smoke",
|
|
@@ -111,6 +116,11 @@ describe.skipIf(process.platform === "win32")(
|
|
|
111
116
|
`TEMPORAL_SERVICE_ADDRESS=${config.temporalAddress}`,
|
|
112
117
|
);
|
|
113
118
|
expect(dump).toMatch(/WORKSPACE_ROOT_DIR=.+session-ses_smoke/);
|
|
119
|
+
expect(dump).toContain("TEMPORAL_NAMESPACE=stigmer-smoke");
|
|
120
|
+
expect(dump).toContain("STIGMER_TEMPORAL_TLS=true");
|
|
121
|
+
expect(dump).toContain("STIGMER_TEMPORAL_API_KEY=k-smoke");
|
|
122
|
+
expect(dump).not.toContain("STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH");
|
|
123
|
+
delete process.env["STIGMER_TEMPORAL_TLS_SERVER_CA_CERT_PATH"];
|
|
114
124
|
});
|
|
115
125
|
|
|
116
126
|
it("deprovision kills the child; probe reports absent; re-ensure respawns", async () => {
|
|
@@ -34,6 +34,8 @@ const silentLogger = createLogger({
|
|
|
34
34
|
const driverConfig: SandboxDriverConfig = {
|
|
35
35
|
backendEndpoint: "http://host.docker.internal:7234",
|
|
36
36
|
temporalAddress: "host.docker.internal:7233",
|
|
37
|
+
temporalNamespace: "default",
|
|
38
|
+
temporalConnectionEnv: {},
|
|
37
39
|
runnerImage: "ghcr.io/stigmer/runner:latest",
|
|
38
40
|
runnerCommand: "stigmer-runner",
|
|
39
41
|
kubernetesNamespace: "stigmer-sandboxes",
|
package/src/sandbox/docker.ts
CHANGED
|
@@ -142,14 +142,20 @@ export const newDockerSandboxProvisioner: SandboxProvisionerFactory = ({
|
|
|
142
142
|
"--env",
|
|
143
143
|
`TEMPORAL_SERVICE_ADDRESS=${config.temporalAddress}`,
|
|
144
144
|
"--env",
|
|
145
|
+
`TEMPORAL_NAMESPACE=${config.temporalNamespace}`,
|
|
146
|
+
"--env",
|
|
145
147
|
`WORKSPACE_ROOT_DIR=${CONTAINER_WORKSPACE_DIR}`,
|
|
146
148
|
];
|
|
149
|
+
// Value-less --env inherits from the CLI's environment (module header:
|
|
150
|
+
// the token must never appear in argv). The Temporal connection
|
|
151
|
+
// settings ride the same channel: they carry credentials, and PEM text
|
|
152
|
+
// does not belong on a command line.
|
|
153
|
+
const secretEnv: Record<string, string> = { ...config.temporalConnectionEnv };
|
|
154
|
+
if (env.stigmerToken !== "") secretEnv["STIGMER_TOKEN"] = env.stigmerToken;
|
|
147
155
|
let runEnv: NodeJS.ProcessEnv | undefined;
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
runArgs.push("--env", "STIGMER_TOKEN");
|
|
152
|
-
runEnv = { ...process.env, STIGMER_TOKEN: env.stigmerToken };
|
|
156
|
+
if (Object.keys(secretEnv).length > 0) {
|
|
157
|
+
for (const name of Object.keys(secretEnv)) runArgs.push("--env", name);
|
|
158
|
+
runEnv = { ...process.env, ...secretEnv };
|
|
153
159
|
}
|
|
154
160
|
runArgs.push(config.runnerImage, ...RUNNER_CONTAINER_COMMAND);
|
|
155
161
|
await docker(runArgs, runEnv);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* @kubernetes/client-node, the official client — this driver is the one
|
|
7
7
|
* the cloud composition will eventually run in production, C4).
|
|
8
8
|
*
|
|
9
|
-
* Each sandbox = a per-sandbox Secret (STIGMER_TOKEN
|
|
9
|
+
* Each sandbox = a per-sandbox Secret (STIGMER_TOKEN, plus the Temporal
|
|
10
|
+
* connection settings when the server's Temporal is authenticated) + a single-replica
|
|
10
11
|
* Deployment (strategy Recreate) + a PVC-backed /workspace for the
|
|
11
12
|
* persistent scopes (session, workflow — the Java persistent-scope
|
|
12
13
|
* split; connect rides emptyDir). One shared namespace, never
|
|
@@ -255,6 +256,7 @@ export function buildSandboxSecret(
|
|
|
255
256
|
scope: SandboxScope,
|
|
256
257
|
id: string,
|
|
257
258
|
stigmerToken: string,
|
|
259
|
+
temporalConnectionEnv: Readonly<Record<string, string>> = {},
|
|
258
260
|
): V1Secret {
|
|
259
261
|
return {
|
|
260
262
|
metadata: {
|
|
@@ -264,7 +266,10 @@ export function buildSandboxSecret(
|
|
|
264
266
|
type: "Opaque",
|
|
265
267
|
// A token-less sandbox still gets its (empty) Secret — the object
|
|
266
268
|
// must exist for uniform cleanup (the Java posture).
|
|
267
|
-
stringData:
|
|
269
|
+
stringData: {
|
|
270
|
+
...temporalConnectionEnv,
|
|
271
|
+
...(stigmerToken !== "" ? { STIGMER_TOKEN: stigmerToken } : {}),
|
|
272
|
+
},
|
|
268
273
|
};
|
|
269
274
|
}
|
|
270
275
|
|
|
@@ -308,6 +313,7 @@ export function buildSandboxDeployment(
|
|
|
308
313
|
{ name: "STIGMER_TASK_QUEUE", value: env.taskQueue },
|
|
309
314
|
{ name: "STIGMER_BACKEND_ENDPOINT", value: config.backendEndpoint },
|
|
310
315
|
{ name: "TEMPORAL_SERVICE_ADDRESS", value: config.temporalAddress },
|
|
316
|
+
{ name: "TEMPORAL_NAMESPACE", value: config.temporalNamespace },
|
|
311
317
|
{ name: "WORKSPACE_ROOT_DIR", value: WORKSPACE_MOUNT_PATH },
|
|
312
318
|
];
|
|
313
319
|
if (env.stigmerToken !== "") {
|
|
@@ -318,6 +324,15 @@ export function buildSandboxDeployment(
|
|
|
318
324
|
},
|
|
319
325
|
});
|
|
320
326
|
}
|
|
327
|
+
// The Temporal connection settings sit in the same per-sandbox Secret as
|
|
328
|
+
// the token (buildSandboxSecret): they carry credentials, so the
|
|
329
|
+
// Deployment names them and never holds their values.
|
|
330
|
+
for (const name of Object.keys(config.temporalConnectionEnv)) {
|
|
331
|
+
containerEnv.push({
|
|
332
|
+
name,
|
|
333
|
+
valueFrom: { secretKeyRef: { name: `${baseName}-env`, key: name } },
|
|
334
|
+
});
|
|
335
|
+
}
|
|
321
336
|
|
|
322
337
|
return {
|
|
323
338
|
metadata: { name: baseName, labels },
|
|
@@ -417,7 +432,7 @@ export function newKubernetesSandboxProvisionerOverGateway(
|
|
|
417
432
|
// references it), claim (reused when it survived a repair), then
|
|
418
433
|
// the Deployment. No readiness wait (module header).
|
|
419
434
|
await gateway.applySecret(
|
|
420
|
-
buildSandboxSecret(scope, id, env.stigmerToken),
|
|
435
|
+
buildSandboxSecret(scope, id, env.stigmerToken, config.temporalConnectionEnv),
|
|
421
436
|
);
|
|
422
437
|
if (PERSISTENT_SCOPES.has(scope)) {
|
|
423
438
|
await gateway.applyPvc(buildWorkspacePvc(scope, id));
|
|
@@ -33,6 +33,8 @@ import { mkdirSync } from "node:fs";
|
|
|
33
33
|
import { homedir } from "node:os";
|
|
34
34
|
import path from "node:path";
|
|
35
35
|
|
|
36
|
+
import { TEMPORAL_CONNECTION_ENV_NAMES } from "@stigmer/temporal-codecs";
|
|
37
|
+
|
|
36
38
|
import type { Logger } from "../boot/logger.js";
|
|
37
39
|
import type {
|
|
38
40
|
SandboxEnvironment,
|
|
@@ -86,10 +88,18 @@ export const newLocalProcessSandboxProvisioner: SandboxProvisionerFactory = ({
|
|
|
86
88
|
);
|
|
87
89
|
mkdirSync(workspaceRoot, { recursive: true });
|
|
88
90
|
|
|
91
|
+
// The server's own Temporal connection settings are replaced by their
|
|
92
|
+
// rendered form, never inherited beside it: an inherited _PATH item
|
|
93
|
+
// next to its rendered _DATA twin is a boot refusal in the runner.
|
|
94
|
+
const inherited = Object.fromEntries(
|
|
95
|
+
Object.entries(process.env).filter(([name]) => !TEMPORAL_CONNECTION_ENV_NAMES.includes(name)),
|
|
96
|
+
);
|
|
89
97
|
const childEnv: NodeJS.ProcessEnv = {
|
|
90
|
-
...
|
|
98
|
+
...inherited,
|
|
99
|
+
...config.temporalConnectionEnv,
|
|
91
100
|
MODE: "local",
|
|
92
101
|
STIGMER_TASK_QUEUE: env.taskQueue,
|
|
102
|
+
TEMPORAL_NAMESPACE: config.temporalNamespace,
|
|
93
103
|
WORKSPACE_ROOT_DIR: workspaceRoot,
|
|
94
104
|
};
|
|
95
105
|
if (config.backendEndpoint !== "") {
|
|
@@ -136,6 +136,18 @@ export interface SandboxDriverConfig {
|
|
|
136
136
|
readonly backendEndpoint: string;
|
|
137
137
|
/** Temporal address as reachable from inside a sandbox (TEMPORAL_SERVICE_ADDRESS). */
|
|
138
138
|
readonly temporalAddress: string;
|
|
139
|
+
/** The Temporal namespace the sandbox's runner polls in (TEMPORAL_NAMESPACE). */
|
|
140
|
+
readonly temporalNamespace: string;
|
|
141
|
+
/**
|
|
142
|
+
* How the sandbox's runner authenticates to that Temporal: the server's
|
|
143
|
+
* own `STIGMER_TEMPORAL_*` settings rendered by `temporalConnectionEnv`
|
|
144
|
+
* (`@stigmer/temporal-codecs`), every PEM item in its `_DATA` form so no
|
|
145
|
+
* file has to exist inside the sandbox. Empty for a plaintext Temporal.
|
|
146
|
+
* The values include credentials, so each driver passes them through the
|
|
147
|
+
* channel it uses for the token, never through argv or a plain manifest
|
|
148
|
+
* value.
|
|
149
|
+
*/
|
|
150
|
+
readonly temporalConnectionEnv: Readonly<Record<string, string>>;
|
|
139
151
|
/** The runner image for container-based drivers (docker/kubernetes). */
|
|
140
152
|
readonly runnerImage: string;
|
|
141
153
|
/** The runner executable for the local-process driver. */
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* lives forever — latent until #21/#22 add factories);
|
|
12
12
|
* - the Go availability parity: getClient() is undefined only until
|
|
13
13
|
* the first successful connect;
|
|
14
|
+
* - both connections, the client's and the workers' native one, carry
|
|
15
|
+
* the configured connection security (TLS and API key) beside the
|
|
16
|
+
* address, and a plaintext manager passes neither;
|
|
14
17
|
* - deps.createWorker builds through THIS package's Worker.create with
|
|
15
18
|
* the manager's connection, namespace, and codec chain pre-wired
|
|
16
19
|
* (the finding-16 seam — factories never see the NativeConnection);
|
|
@@ -34,14 +37,34 @@ const sdkSpy = vi.hoisted(() => ({
|
|
|
34
37
|
createCalls: [] as Record<string, unknown>[],
|
|
35
38
|
/** The connection object the mocked NativeConnection.connect returned last. */
|
|
36
39
|
lastConnection: undefined as unknown,
|
|
40
|
+
/** Options of every NativeConnection.connect and Connection.connect call. */
|
|
41
|
+
nativeConnectCalls: [] as Record<string, unknown>[],
|
|
42
|
+
clientConnectCalls: [] as Record<string, unknown>[],
|
|
37
43
|
}));
|
|
38
44
|
|
|
45
|
+
vi.mock("@temporalio/client", async (importOriginal) => {
|
|
46
|
+
const original = await importOriginal<typeof import("@temporalio/client")>();
|
|
47
|
+
return {
|
|
48
|
+
...original,
|
|
49
|
+
Connection: {
|
|
50
|
+
connect: async (options: Record<string, unknown>) => {
|
|
51
|
+
sdkSpy.clientConnectCalls.push(options);
|
|
52
|
+
return { close: async () => {}, healthService: { check: async () => ({ status: 1 }) } };
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
Client: class {
|
|
56
|
+
constructor(readonly options: Record<string, unknown>) {}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
39
61
|
vi.mock("@temporalio/worker", async (importOriginal) => {
|
|
40
62
|
const original = await importOriginal<typeof import("@temporalio/worker")>();
|
|
41
63
|
return {
|
|
42
64
|
...original,
|
|
43
65
|
NativeConnection: {
|
|
44
|
-
connect: async () => {
|
|
66
|
+
connect: async (options: Record<string, unknown>) => {
|
|
67
|
+
sdkSpy.nativeConnectCalls.push(options);
|
|
45
68
|
const connection = { close: async () => {} };
|
|
46
69
|
sdkSpy.lastConnection = connection;
|
|
47
70
|
return connection;
|
|
@@ -74,6 +97,8 @@ const managers: TemporalManager[] = [];
|
|
|
74
97
|
afterEach(async () => {
|
|
75
98
|
sdkSpy.createCalls.length = 0;
|
|
76
99
|
sdkSpy.lastConnection = undefined;
|
|
100
|
+
sdkSpy.nativeConnectCalls.length = 0;
|
|
101
|
+
sdkSpy.clientConnectCalls.length = 0;
|
|
77
102
|
for (const manager of managers.splice(0)) {
|
|
78
103
|
await manager.close();
|
|
79
104
|
}
|
|
@@ -108,11 +133,13 @@ function newManager(
|
|
|
108
133
|
payloadCodecs?: ConstructorParameters<
|
|
109
134
|
typeof TemporalManager
|
|
110
135
|
>[0]["payloadCodecs"];
|
|
136
|
+
connection?: ConstructorParameters<typeof TemporalManager>[0]["connection"];
|
|
111
137
|
},
|
|
112
138
|
): TemporalManager {
|
|
113
139
|
const manager = new TemporalManager({
|
|
114
140
|
hostPort: "127.0.0.1:1",
|
|
115
141
|
namespace: "default",
|
|
142
|
+
connection: options?.connection ?? {},
|
|
116
143
|
logger: options?.logger ?? silentLogger,
|
|
117
144
|
payloadCodecs: options?.payloadCodecs ?? [],
|
|
118
145
|
workerFactories: factories,
|
|
@@ -283,6 +310,37 @@ describe("TemporalManager createWorker capability (the finding-16 seam)", () =>
|
|
|
283
310
|
});
|
|
284
311
|
});
|
|
285
312
|
|
|
313
|
+
describe("TemporalManager connection security", () => {
|
|
314
|
+
it("spreads TLS and the API key into both the client's and the workers' connections", async () => {
|
|
315
|
+
const security = {
|
|
316
|
+
tls: { serverNameOverride: "temporal.internal" },
|
|
317
|
+
apiKey: "api-key-1",
|
|
318
|
+
};
|
|
319
|
+
const manager = newManager([fakeWorkerFactory({ shutdowns: 0 })], {
|
|
320
|
+
connection: security,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await manager.initialConnect();
|
|
324
|
+
await manager.startWorkers();
|
|
325
|
+
|
|
326
|
+
expect(sdkSpy.clientConnectCalls).toHaveLength(1);
|
|
327
|
+
expect(sdkSpy.clientConnectCalls[0]).toMatchObject({ address: "127.0.0.1:1", ...security });
|
|
328
|
+
expect(sdkSpy.nativeConnectCalls).toHaveLength(1);
|
|
329
|
+
expect(sdkSpy.nativeConnectCalls[0]).toEqual({ address: "127.0.0.1:1", ...security });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("passes neither for a plaintext manager", async () => {
|
|
333
|
+
const manager = newManager([fakeWorkerFactory({ shutdowns: 0 })]);
|
|
334
|
+
|
|
335
|
+
await manager.initialConnect();
|
|
336
|
+
await manager.startWorkers();
|
|
337
|
+
|
|
338
|
+
expect(sdkSpy.clientConnectCalls[0]).not.toHaveProperty("tls");
|
|
339
|
+
expect(sdkSpy.clientConnectCalls[0]).not.toHaveProperty("apiKey");
|
|
340
|
+
expect(sdkSpy.nativeConnectCalls[0]).toEqual({ address: "127.0.0.1:1" });
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
286
344
|
describe("TemporalManager worker-death observability", () => {
|
|
287
345
|
it("logs a run() rejection at ERROR with the dead worker's queue identity", async () => {
|
|
288
346
|
const lines: string[] = [];
|
package/src/temporal/manager.ts
CHANGED
|
@@ -32,6 +32,12 @@
|
|
|
32
32
|
* connection errors exactly as Go's stale-client operations do, and the
|
|
33
33
|
* health monitor swaps in a fresh client when the server returns.
|
|
34
34
|
*
|
|
35
|
+
* One choke point for connection security: `connection` (TLS, mutual TLS,
|
|
36
|
+
* an API key; `@stigmer/temporal-codecs`' `loadTemporalConnectionConfig`)
|
|
37
|
+
* is spread into BOTH connects, the client's and the workers' native one,
|
|
38
|
+
* so every worker, OSS and extension alike, authenticates the same way
|
|
39
|
+
* through createWorker. Absent settings keep the plaintext connection.
|
|
40
|
+
*
|
|
35
41
|
* One choke point for payload decryption: the codecs are installed on the
|
|
36
42
|
* client at dial AND wired into every worker by the deps' createWorker
|
|
37
43
|
* capability (the TS SDK's Worker.create takes its own dataConverter; Go
|
|
@@ -40,6 +46,7 @@
|
|
|
40
46
|
*/
|
|
41
47
|
import { Client, Connection } from "@temporalio/client";
|
|
42
48
|
import type { PayloadCodec } from "@temporalio/common";
|
|
49
|
+
import type { TemporalConnectionConfig } from "@stigmer/temporal-codecs";
|
|
43
50
|
import {
|
|
44
51
|
NativeConnection,
|
|
45
52
|
Worker,
|
|
@@ -150,6 +157,8 @@ export type WorkerFactory = (deps: WorkerFactoryDeps) => Promise<Worker>;
|
|
|
150
157
|
export interface TemporalManagerOptions {
|
|
151
158
|
readonly hostPort: string;
|
|
152
159
|
readonly namespace: string;
|
|
160
|
+
/** TLS and API key for both connections; `{}` for plaintext. */
|
|
161
|
+
readonly connection: TemporalConnectionConfig;
|
|
153
162
|
readonly logger: Logger;
|
|
154
163
|
readonly payloadCodecs: PayloadCodec[];
|
|
155
164
|
readonly workerFactories: readonly WorkerFactory[];
|
|
@@ -163,6 +172,7 @@ interface RunningWorker {
|
|
|
163
172
|
export class TemporalManager {
|
|
164
173
|
private readonly hostPort: string;
|
|
165
174
|
private readonly namespace: string;
|
|
175
|
+
private readonly connectionSecurity: TemporalConnectionConfig;
|
|
166
176
|
private readonly logger: Logger;
|
|
167
177
|
private readonly payloadCodecs: PayloadCodec[];
|
|
168
178
|
private readonly workerFactories: readonly WorkerFactory[];
|
|
@@ -184,6 +194,7 @@ export class TemporalManager {
|
|
|
184
194
|
constructor(options: TemporalManagerOptions) {
|
|
185
195
|
this.hostPort = options.hostPort;
|
|
186
196
|
this.namespace = options.namespace;
|
|
197
|
+
this.connectionSecurity = options.connection;
|
|
187
198
|
this.logger = options.logger;
|
|
188
199
|
this.payloadCodecs = options.payloadCodecs;
|
|
189
200
|
this.workerFactories = options.workerFactories;
|
|
@@ -328,6 +339,7 @@ export class TemporalManager {
|
|
|
328
339
|
const connection = await Connection.connect({
|
|
329
340
|
address: this.hostPort,
|
|
330
341
|
connectTimeout: DIAL_CONNECT_TIMEOUT_MS,
|
|
342
|
+
...this.connectionSecurity,
|
|
331
343
|
});
|
|
332
344
|
const client = new Client({
|
|
333
345
|
connection,
|
|
@@ -494,6 +506,7 @@ export class TemporalManager {
|
|
|
494
506
|
private async createAndRunWorkers(): Promise<void> {
|
|
495
507
|
const nativeConnection = await NativeConnection.connect({
|
|
496
508
|
address: this.hostPort,
|
|
509
|
+
...this.connectionSecurity,
|
|
497
510
|
});
|
|
498
511
|
this.nativeConnection = nativeConnection;
|
|
499
512
|
|