@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.
- package/README.md +1 -1
- package/dist/authorization/model/data/authorization-model.json +16 -46
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +8 -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/agentexecution/create-execution-context-step.d.ts +7 -0
- package/dist/domain/agentexecution/create-execution-context-step.d.ts.map +1 -1
- package/dist/domain/agentexecution/create-execution-context-step.js +79 -4
- package/dist/domain/agentexecution/create-execution-context-step.js.map +1 -1
- package/dist/domain/iampolicy/constants.d.ts +11 -1
- package/dist/domain/iampolicy/constants.d.ts.map +1 -1
- package/dist/domain/iampolicy/constants.js +11 -1
- package/dist/domain/iampolicy/constants.js.map +1 -1
- package/dist/domain/iampolicy/controller.d.ts.map +1 -1
- package/dist/domain/iampolicy/controller.js +36 -12
- package/dist/domain/iampolicy/controller.js.map +1 -1
- package/dist/domain/iampolicy/steps.d.ts +98 -0
- package/dist/domain/iampolicy/steps.d.ts.map +1 -1
- package/dist/domain/iampolicy/steps.js +119 -4
- package/dist/domain/iampolicy/steps.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/domain/platformclient/constants.d.ts +11 -1
- package/dist/domain/platformclient/constants.d.ts.map +1 -1
- package/dist/domain/platformclient/constants.js +11 -1
- package/dist/domain/platformclient/constants.js.map +1 -1
- package/dist/domain/platformclient/verifier.d.ts.map +1 -1
- package/dist/domain/platformclient/verifier.js +7 -2
- package/dist/domain/platformclient/verifier.js.map +1 -1
- package/dist/extensions/identity.d.ts +14 -0
- package/dist/extensions/identity.d.ts.map +1 -1
- package/dist/extensions/identity.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/defaults.d.ts +5 -1
- package/dist/pipeline/steps/defaults.d.ts.map +1 -1
- package/dist/pipeline/steps/defaults.js +6 -1
- package/dist/pipeline/steps/defaults.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/authorization/__tests__/store-tests.test.ts +1 -0
- package/src/authorization/model/data/authorization-model.json +16 -46
- package/src/boot/__tests__/config.test.ts +23 -1
- package/src/boot/compose.ts +8 -0
- package/src/boot/config.ts +13 -0
- package/src/domain/agentexecution/__tests__/create-execution-context-step.test.ts +329 -0
- package/src/domain/agentexecution/__tests__/lifecycle.test.ts +7 -0
- package/src/domain/agentexecution/create-execution-context-step.ts +110 -4
- package/src/domain/iampolicy/__tests__/controller.test.ts +99 -3
- package/src/domain/iampolicy/__tests__/steps.test.ts +179 -0
- package/src/domain/iampolicy/constants.ts +15 -1
- package/src/domain/iampolicy/controller.ts +43 -9
- package/src/domain/iampolicy/steps.ts +164 -4
- 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/domain/platformclient/__tests__/mint.test.ts +7 -1
- package/src/domain/platformclient/__tests__/verifier-and-guard.test.ts +4 -2
- package/src/domain/platformclient/constants.ts +11 -1
- package/src/domain/platformclient/verifier.ts +7 -2
- package/src/extensions/__tests__/role-reconciliation-composed.test.ts +18 -10
- package/src/extensions/identity.ts +14 -0
- package/src/index.ts +5 -0
- package/src/pipeline/__tests__/steps.test.ts +72 -1
- package/src/pipeline/steps/defaults.ts +6 -1
- 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
|
@@ -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 !== "") {
|
|
@@ -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
|
|
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.
|
|
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
|
|
30
|
-
* the
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
|
194
|
-
identityAccountId: founderId,
|
|
195
|
-
|
|
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,
|
|
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",
|
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);
|