@stigmer/server 3.30.0 → 3.32.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.
Files changed (111) hide show
  1. package/README.md +1 -1
  2. package/dist/authorization/model/data/authorization-model.json +16 -46
  3. package/dist/boot/compose.d.ts.map +1 -1
  4. package/dist/boot/compose.js +8 -0
  5. package/dist/boot/compose.js.map +1 -1
  6. package/dist/boot/config.d.ts +10 -0
  7. package/dist/boot/config.d.ts.map +1 -1
  8. package/dist/boot/config.js +2 -0
  9. package/dist/boot/config.js.map +1 -1
  10. package/dist/domain/agentexecution/create-execution-context-step.d.ts +7 -0
  11. package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
  12. package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
  13. package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
  14. package/dist/domain/iampolicy/constants.d.ts +11 -1
  15. package/dist/domain/iampolicy/constants.d.ts.map +1 -1
  16. package/dist/domain/iampolicy/constants.js +11 -1
  17. package/dist/domain/iampolicy/constants.js.map +1 -1
  18. package/dist/domain/iampolicy/controller.d.ts.map +1 -1
  19. package/dist/domain/iampolicy/controller.js +36 -12
  20. package/dist/domain/iampolicy/controller.js.map +1 -1
  21. package/dist/domain/iampolicy/steps.d.ts +98 -0
  22. package/dist/domain/iampolicy/steps.d.ts.map +1 -1
  23. package/dist/domain/iampolicy/steps.js +119 -4
  24. package/dist/domain/iampolicy/steps.js.map +1 -1
  25. package/dist/domain/oauthapp/controller.d.ts +16 -8
  26. package/dist/domain/oauthapp/controller.d.ts.map +1 -1
  27. package/dist/domain/oauthapp/controller.js +28 -11
  28. package/dist/domain/oauthapp/controller.js.map +1 -1
  29. package/dist/domain/oauthapp/refresolution.d.ts.map +1 -1
  30. package/dist/domain/oauthapp/refresolution.js +3 -2
  31. package/dist/domain/oauthapp/refresolution.js.map +1 -1
  32. package/dist/domain/oauthapp/steps.d.ts +5 -5
  33. package/dist/domain/oauthapp/steps.js +6 -6
  34. package/dist/domain/oauthapp/steps.js.map +1 -1
  35. package/dist/domain/platformclient/constants.d.ts +11 -1
  36. package/dist/domain/platformclient/constants.d.ts.map +1 -1
  37. package/dist/domain/platformclient/constants.js +11 -1
  38. package/dist/domain/platformclient/constants.js.map +1 -1
  39. package/dist/domain/platformclient/verifier.d.ts.map +1 -1
  40. package/dist/domain/platformclient/verifier.js +7 -2
  41. package/dist/domain/platformclient/verifier.js.map +1 -1
  42. package/dist/extensions/identity.d.ts +14 -0
  43. package/dist/extensions/identity.d.ts.map +1 -1
  44. package/dist/extensions/identity.js.map +1 -1
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/pipeline/steps/defaults.d.ts +5 -1
  50. package/dist/pipeline/steps/defaults.d.ts.map +1 -1
  51. package/dist/pipeline/steps/defaults.js +6 -1
  52. package/dist/pipeline/steps/defaults.js.map +1 -1
  53. package/dist/sandbox/docker.d.ts.map +1 -1
  54. package/dist/sandbox/docker.js +13 -5
  55. package/dist/sandbox/docker.js.map +1 -1
  56. package/dist/sandbox/kubernetes.d.ts +1 -1
  57. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  58. package/dist/sandbox/kubernetes.js +18 -4
  59. package/dist/sandbox/kubernetes.js.map +1 -1
  60. package/dist/sandbox/local-process.d.ts.map +1 -1
  61. package/dist/sandbox/local-process.js +8 -1
  62. package/dist/sandbox/local-process.js.map +1 -1
  63. package/dist/sandbox/provisioner.d.ts +12 -0
  64. package/dist/sandbox/provisioner.d.ts.map +1 -1
  65. package/dist/sandbox/provisioner.js.map +1 -1
  66. package/dist/temporal/manager.d.ts +10 -0
  67. package/dist/temporal/manager.d.ts.map +1 -1
  68. package/dist/temporal/manager.js +10 -0
  69. package/dist/temporal/manager.js.map +1 -1
  70. package/package.json +6 -6
  71. package/src/authorization/__tests__/store-tests.test.ts +1 -0
  72. package/src/authorization/model/data/authorization-model.json +16 -46
  73. package/src/boot/__tests__/config.test.ts +23 -1
  74. package/src/boot/compose.ts +8 -0
  75. package/src/boot/config.ts +13 -0
  76. package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
  77. package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
  78. package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
  79. package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
  80. package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
  81. package/src/domain/iampolicy/constants.ts +15 -1
  82. package/src/domain/iampolicy/controller.ts +43 -9
  83. package/src/domain/iampolicy/steps.ts +164 -4
  84. package/src/domain/oauthapp/__tests__/oauthapp.test.ts +17 -10
  85. package/src/domain/oauthapp/controller.ts +51 -18
  86. package/src/domain/oauthapp/refresolution.ts +3 -2
  87. package/src/domain/oauthapp/steps.ts +6 -6
  88. package/src/domain/platformclient/__tests__/mint.test.ts +7 -1
  89. package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
  90. package/src/domain/platformclient/constants.ts +11 -1
  91. package/src/domain/platformclient/verifier.ts +7 -2
  92. package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
  93. package/src/extensions/identity.ts +14 -0
  94. package/src/index.ts +5 -0
  95. package/src/pipeline/__tests__/steps.test.ts +72 -1
  96. package/src/pipeline/steps/defaults.ts +6 -1
  97. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  98. package/src/sandbox/__tests__/kubernetes.test.ts +27 -0
  99. package/src/sandbox/__tests__/local-process.integration.test.ts +10 -0
  100. package/src/sandbox/__tests__/provisioner.test.ts +2 -0
  101. package/src/sandbox/docker.ts +11 -5
  102. package/src/sandbox/kubernetes.ts +18 -3
  103. package/src/sandbox/local-process.ts +11 -1
  104. package/src/sandbox/provisioner.ts +12 -0
  105. package/src/temporal/__tests__/manager.test.ts +59 -1
  106. package/src/temporal/manager.ts +13 -0
  107. package/dist/domain/oauthapp/refresolution/refresolution.d.ts +0 -12
  108. package/dist/domain/oauthapp/refresolution/refresolution.d.ts.map +0 -1
  109. package/dist/domain/oauthapp/refresolution/refresolution.js +0 -79
  110. package/dist/domain/oauthapp/refresolution/refresolution.js.map +0 -1
  111. package/src/domain/oauthapp/refresolution/refresolution.ts +0 -94
@@ -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
- * read surface; the marker round-trips on update/apply as "keep the stored
16
- * secret"; client-supplied enc:v<N>:-shaped values are refused on every
17
- * write door (oss#395). Deletion is blocked while an McpServer's
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
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies, and
21
- * Publish steps (no multi-tenant auth, IAM/FGA, or event publishing), and
22
- * OAuthApp is deliberately not search-indexed (a configuration resource;
23
- * `not_search_indexed` in the kind registry).
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 resource for the audit trail — unredacted, exactly as Go does
265
- * (see redactOAuthApp's header for the disclosure).
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
- return deleted as OAuthApp;
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 — all OAuthApps whose metadata.org matches, each redacted,
386
- * sorted created_at descending (Go listByOrgStep). No pagination:
387
- * typically 1–5 per org. No authorization filtering in OSS.
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
- /** The domain-local list step: load all, filter by org, redact, sort. */
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 apps: OAuthApp[] = [];
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). This file seeds src/domain/oauthapp/;
11
- * the domain's controllers arrive with #13.
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/refresolution.js";
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. Go's
44
- * response list deliberately excludes DELETE — the delete RPC returns the
45
- * STORED resource with its encrypted secret intact, ported byte-faithfully
46
- * and disclosed in the PR (a cross-edition follow-up candidate: with
47
- * encryption disabled the stored value is plaintext).
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 !== "") {
@@ -4,7 +4,8 @@
4
4
  * - no ring (a server that trusts every request) and a verify-only ring
5
5
  * refuse FAILED_PRECONDITION before any credential is read;
6
6
  * - an unknown client_id and a wrong secret answer one UNAUTHENTICATED
7
- * copy; an expired secret refuses, an unset or never-expiring one mints;
7
+ * copy; an expired secret refuses with copy naming the owner's fix (not
8
+ * rotation, #1254), an unset or never-expiring one mints;
8
9
  * an org_id other than the owning organization refuses;
9
10
  * - an existing platform-client account is reused with no grant and no
10
11
  * write; an account under the subject in any other mode is refused;
@@ -232,6 +233,11 @@ describe("mintUserToken — the server and the client", () => {
232
233
  );
233
234
  expect(expired.code).toBe(Code.FailedPrecondition);
234
235
  expect(expired.rawMessage).toBe(EXPIRED_CLIENT_SECRET_MESSAGE);
236
+ // The literal, so a change to the copy is deliberate: it names the
237
+ // owner's fix, never rotation, which leaves the expiry as it is (#1254).
238
+ expect(expired.rawMessage).toBe(
239
+ "PlatformClient secret has expired. Rotating the secret does not extend it; the client's owner must set a later expires_at or set never_expires.",
240
+ );
235
241
 
236
242
  await mintUserToken(harness({ client: platformClient() }).deps, request());
237
243
  await mintUserToken(
@@ -5,7 +5,8 @@
5
5
  * or typed one, refuses a bad one with the envelope's copy, refuses a
6
6
  * token naming no client, refuses a deleted client's token with the
7
7
  * cloud's liveness copy, lets a store fault propagate (never a
8
- * revocation), and stamps the account from `sub` as a `user`;
8
+ * revocation), and stamps the account from `sub` as a `user` with the
9
+ * minting client it verified, for the audit actor (#1256);
9
10
  * - the guard reads no client for a request without an Origin, passes an
10
11
  * open allowlist and a listed origin case-insensitively, refuses an
11
12
  * unlisted origin and the opaque "null" with the cloud's copy, and
@@ -83,7 +84,7 @@ async function refusal(promise: Promise<unknown>): Promise<ConnectError> {
83
84
  }
84
85
 
85
86
  describe("the PlatformClient user-token verifier", () => {
86
- it("stamps the account the token was minted for as a user, with the asserted profile", async () => {
87
+ it("stamps the account the token was minted for as a user, with the asserted profile and the minting client", async () => {
87
88
  const token = userToken();
88
89
  const identity = await newPlatformClientTokenVerifier({
89
90
  keys: ring,
@@ -96,6 +97,7 @@ describe("the PlatformClient user-token verifier", () => {
96
97
  rawToken: token,
97
98
  email: "pat@example.com",
98
99
  displayName: "Pat",
100
+ platformClientId: "pcl_dashboard",
99
101
  });
100
102
  });
101
103
 
@@ -11,6 +11,11 @@
11
11
  * sentences open source adds — a server that cannot mint, the guest
12
12
  * method's edition refusal, the mismatched-account guard — follow the
13
13
  * house style (single-quoted handles).
14
+ *
15
+ * One inherited sentence was changed on purpose: the expired-secret
16
+ * refusal told integrators to rotate, which never helps, because rotating
17
+ * leaves the expiry as it is (steps.ts, newRotateClientCredentialsStep).
18
+ * It now names the one fix and who can apply it (stigmer/stigmer#1254).
14
19
  */
15
20
 
16
21
  /** The kind's name as the shared error constructors render it. */
@@ -63,8 +68,13 @@ export function referenceNotFoundMessage(org: string, slug: string): string {
63
68
  export const INVALID_CLIENT_CREDENTIALS_MESSAGE =
64
69
  "Invalid client_id or client_secret";
65
70
 
71
+ /**
72
+ * The mint's refusal for a client whose expires_at has passed. Rotating
73
+ * does not extend the expiry, and both update and rotateSecret need the
74
+ * client's owner (can_edit), so the sentence names the owner's fix.
75
+ */
66
76
  export const EXPIRED_CLIENT_SECRET_MESSAGE =
67
- "PlatformClient secret has expired. Use rotateSecret to generate a new one.";
77
+ "PlatformClient secret has expired. Rotating the secret does not extend it; the client's owner must set a later expires_at or set never_expires.";
68
78
 
69
79
  export function organizationMismatchMessage(owningOrg: string): string {
70
80
  return `org_id must be empty or the PlatformClient's owning organization ('${owningOrg}'); cross-organization minting is not supported`;
@@ -26,8 +26,12 @@
26
26
  * The identity is the account the token was minted for: `sub` IS the
27
27
  * account id (the mint signs it), so no account read is needed; class
28
28
  * `user`, issuer "stigmer", the token carried (the memory-capture gate and
29
- * the origin guard read its `platform_client_id`), and the email and name
30
- * the platform asserted, for display only.
29
+ * the origin guard read its `platform_client_id`, guards decoding their
30
+ * own skip logic from the raw token as extensions/caller-guards.ts
31
+ * prescribes), the email and name the platform asserted, for display
32
+ * only, and the minting client as `platformClientId` for the audit-actor
33
+ * seam, so every resource a minted user writes names the client they came
34
+ * through (stigmer/stigmer#1256).
31
35
  */
32
36
  import { Code, ConnectError } from "@connectrpc/connect";
33
37
 
@@ -100,6 +104,7 @@ export function newPlatformClientTokenVerifier(
100
104
  rawToken: token,
101
105
  ...(email !== undefined ? { email } : {}),
102
106
  ...(displayName !== undefined ? { displayName } : {}),
107
+ platformClientId,
103
108
  };
104
109
  },
105
110
  };
@@ -17,10 +17,13 @@
17
17
  * `owner`, the member `member` — each written AS the account it names
18
18
  * (the audit actor), `findMyOrganizations` answers the organization
19
19
  * to both, and the marker is set.
20
- * 3. The founder revokes everyone, the member first and then themself,
21
- * and the directory boots once more: NO row returns. The marker, not
22
- * "zero rows", is what makes the reconciliation one-shot — a revoked
23
- * founder is not handed the organization back by a reboot.
20
+ * 3. The founder removes the member, and is refused removing themself
21
+ * (an organization keeps its last owner on the member lanes); the
22
+ * founder's row then goes from the store, the way boot 2 made its
23
+ * shape, and the directory boots once more: NO row returns. The
24
+ * marker, not "zero rows", is what makes the reconciliation one-shot
25
+ * — a founder whose row is gone is not handed the organization back
26
+ * by a reboot.
24
27
  *
25
28
  * The unit vouches for unsigned JWT-shaped tokens and declares the
26
29
  * require-authentication posture with no Authorizer (composed-support.ts;
@@ -35,7 +38,7 @@ import { tmpdir } from "node:os";
35
38
  import path from "node:path";
36
39
 
37
40
  import { fromBinary } from "@bufbuild/protobuf";
38
- import { createClient } from "@connectrpc/connect";
41
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
39
42
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
40
43
 
41
44
  import { AgentCommandController } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/command_pb";
@@ -184,16 +187,21 @@ describe("role reconciliation (composed server, OIDC with no unit Authorizer, th
184
187
  ]);
185
188
  });
186
189
 
187
- it("boot 3: every role revoked after the marker stays revoked — a reboot hands nothing back", async () => {
190
+ it("boot 3: every role removed after the marker stays removed — a reboot hands nothing back", async () => {
188
191
  const policies = createClient(IamPolicyCommandController, asFounder());
189
192
  await policies.revokeOrgAccess({
190
193
  identityAccountId: memberId,
191
194
  organizationId: ORG,
192
195
  });
193
- await policies.revokeOrgAccess({
194
- identityAccountId: founderId,
195
- organizationId: ORG,
196
- });
196
+ const lastOwner = await policies
197
+ .revokeOrgAccess({ identityAccountId: founderId, organizationId: ORG })
198
+ .then(
199
+ () => undefined,
200
+ (error: unknown) => ConnectError.from(error),
201
+ );
202
+ expect(lastOwner?.code).toBe(Code.FailedPrecondition);
203
+ // The founder's row, the one left, goes the way boot 2 made its shape.
204
+ await server.store.deleteResourcesByKind(ApiResourceKind.iam_policy);
197
205
  expect(await rolesIn(server.store)).toEqual([]);
198
206
  await server.shutdown();
199
207
 
@@ -74,6 +74,20 @@ export interface CallerIdentity {
74
74
  */
75
75
  readonly email?: string;
76
76
  readonly displayName?: string;
77
+ /**
78
+ * The PlatformClient whose minted user token this caller presented,
79
+ * carried for the audit-actor seam as `email` and `displayName` are:
80
+ * the audit actor records it (ApiResourceAuditActor.platform_client_id),
81
+ * and that server-owned record is what later readers key on, never this
82
+ * field (stigmer/stigmer#1256). Set ONLY by the PlatformClient
83
+ * user-token verifier (domain/platformclient/verifier.ts), from the
84
+ * claim it has just verified and whose client it has just found alive;
85
+ * absent on every other credential. It is provenance, not authorization
86
+ * data (the account the token names is still the principal), and not a
87
+ * guard vocabulary: caller guards decode their own skip logic from
88
+ * `rawToken` (extensions/caller-guards.ts).
89
+ */
90
+ readonly platformClientId?: string;
77
91
  /**
78
92
  * The transport the request entered through (C2 Stage 3, ruling R5).
79
93
  * Absent = the wire; the in-process interceptor stamps `in-process` on
package/src/index.ts CHANGED
@@ -168,6 +168,11 @@ export type {
168
168
  IamPolicyStoreContractFixture,
169
169
  } from "./domain/iampolicy/store-contract.js";
170
170
  export { iamPolicyStoreContract } from "./domain/iampolicy/store-contract.js";
171
+ // The owner rule's refusal, the one sentence every door that grants the
172
+ // owner role answers when its caller is no owner: the IamPolicy lanes here,
173
+ // and an edition's own lanes that grant a role on a person's behalf (an
174
+ // owner invitation).
175
+ export { OWNER_ASSIGNMENT_DENIED_MESSAGE } from "./domain/iampolicy/constants.js";
171
176
  export type { PolicyGrantScope } from "./extensions/policy-grant-scope.js";
172
177
  export type { AuthorizationQueryEngine } from "./extensions/authorization-queries.js";
173
178
  // How an access list names a grantee that is not a person
@@ -5,7 +5,10 @@
5
5
  * state building (status clearing, ULID minting, audit slots #540,
6
6
  * visibility default + oss#573 immutability), the loaders' error copy, the
7
7
  * best-effort search indexing, visibility validation with cloud-identical
8
- * copy, reference normalization/validation, and operator identity (#400).
8
+ * copy, reference normalization/validation, operator identity (#400), and
9
+ * the PlatformClient provenance the audit actor records for a minted
10
+ * caller (#1256): stamped from the verified identity only, never from the
11
+ * request, and kept through an update by anyone else.
9
12
  *
10
13
  * Uses the organization resource (the vertical slice) and agent (whose
11
14
  * spec carries ApiResourceReference fields) as vehicles.
@@ -248,6 +251,74 @@ describe("operator identity (#400) → audit-actor derivation (O2 ruling Q5)", (
248
251
  });
249
252
  });
250
253
 
254
+ describe("PlatformClient provenance on the audit actor (#1256)", () => {
255
+ const minted = testCallerIdentity({
256
+ identityId: "ida_pat",
257
+ issuer: "stigmer",
258
+ platformClientId: "pcl_dashboard",
259
+ });
260
+
261
+ it("a minted caller's actor names the client; every other identity stamps it empty", () => {
262
+ expect(auditActorFor(minted).platformClientId).toBe("pcl_dashboard");
263
+ expect(auditActorFor(minted).id).toBe("ida_pat");
264
+ expect(auditActorFor(trustedLocalIdentity()).platformClientId).toBe("");
265
+ expect(auditActorFor(testCallerIdentity()).platformClientId).toBe("");
266
+ });
267
+
268
+ it("BuildNewState stamps the caller's client and discards a client the request claimed", async () => {
269
+ const forged = create(OrganizationSchema, {
270
+ apiVersion: "tenancy.stigmer.ai/v1",
271
+ kind: "Organization",
272
+ metadata: { name: "Acme" },
273
+ spec: { description: "a test organization" },
274
+ status: {
275
+ audit: {
276
+ specAudit: {
277
+ createdBy: { id: "ida_pat", platformClientId: "pcl_forged" },
278
+ },
279
+ },
280
+ },
281
+ });
282
+ const byMinted = new RequestContext(
283
+ OrganizationSchema,
284
+ clone(OrganizationSchema, forged),
285
+ minted,
286
+ ORG,
287
+ );
288
+ await newBuildNewStateStep<typeof OrganizationSchema>().execute(byMinted);
289
+ expect(
290
+ byMinted.newState.status?.audit?.specAudit?.createdBy?.platformClientId,
291
+ ).toBe("pcl_dashboard");
292
+
293
+ const byOther = orgCtx(clone(OrganizationSchema, forged));
294
+ await newBuildNewStateStep<typeof OrganizationSchema>().execute(byOther);
295
+ expect(
296
+ byOther.newState.status?.audit?.specAudit?.createdBy?.platformClientId,
297
+ ).toBe("");
298
+ });
299
+
300
+ it("an update by another caller keeps the creator's client and stamps its own actor", async () => {
301
+ const createCtx = new RequestContext(
302
+ OrganizationSchema,
303
+ org({ name: "Acme" }),
304
+ minted,
305
+ ORG,
306
+ );
307
+ await newResolveSlugStep<typeof OrganizationSchema>().execute(createCtx);
308
+ await newBuildNewStateStep<typeof OrganizationSchema>().execute(createCtx);
309
+ const existing = createCtx.newState;
310
+
311
+ const ctx = orgCtx(org({ id: existing.metadata!.id, name: "Acme Renamed" }));
312
+ ctx.set(EXISTING_RESOURCE_KEY, clone(OrganizationSchema, existing));
313
+ await newBuildUpdateStateStep<typeof OrganizationSchema>().execute(ctx);
314
+
315
+ const audit = ctx.newState.status?.audit?.specAudit;
316
+ expect(audit?.createdBy?.platformClientId).toBe("pcl_dashboard");
317
+ expect(audit?.updatedBy?.id).toBe("test-caller");
318
+ expect(audit?.updatedBy?.platformClientId).toBe("");
319
+ });
320
+ });
321
+
251
322
  describe("BuildUpdateState", () => {
252
323
  it("preserves id/slug/org/visibility (oss#573), replaces spec, keeps creation audit", async () => {
253
324
  // The stored resource, created through the real create steps.
@@ -237,13 +237,18 @@ export function operatorIdentitySnapshot(): {
237
237
  * unconfigured → the "system" placeholder, which the runner deliberately
238
238
  * demotes to anonymous (SYSTEM_CREATOR_SENTINEL). Verifier-produced
239
239
  * identities (O3's OIDC claims onward) stamp their own email/displayName
240
- * when present, identityId alone otherwise.
240
+ * when present, identityId alone otherwise. A PlatformClient-minted
241
+ * caller also stamps the client it came through (platform_client_id):
242
+ * the account alone cannot say, and the execution-context builder keys
243
+ * the PlatformClient environment layer on this record (#1256). Every
244
+ * other identity stamps it empty, so local-posture bytes are unchanged.
241
245
  */
242
246
  export function auditActorFor(identity: CallerIdentity): ApiResourceAuditActor {
243
247
  return create(ApiResourceAuditActorSchema, {
244
248
  id: identity.identityId,
245
249
  email: identity.email ?? "",
246
250
  displayName: identity.displayName ?? "",
251
+ platformClientId: identity.platformClientId ?? "",
247
252
  });
248
253
  }
249
254
 
@@ -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",
@@ -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 (env.stigmerToken !== "") {
149
- // Value-less --env inherits from the CLI's environment (module
150
- // header: the token must never appear in argv).
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);