@stigmer/server 3.31.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/dist/authorization/model/data/authorization-model.json +16 -46
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +3 -0
- package/dist/boot/compose.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/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/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/compose.ts +3 -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/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
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
* agent's env united with the session servers' env, and a session
|
|
12
12
|
* server's saved key reaching the run from the personal environment — for
|
|
13
13
|
* an agent-bound run and for the built-in assistant alike.
|
|
14
|
+
*
|
|
15
|
+
* And the minting PlatformClient's layer (#1256): an execution whose audit
|
|
16
|
+
* names the client a minted user came through receives the client's
|
|
17
|
+
* environments BELOW the instance layer and runtime_env; a creator with no
|
|
18
|
+
* client reads no client at all; a deleted client or one of another
|
|
19
|
+
* organization contributes nothing; an unresolvable ref fails the create;
|
|
20
|
+
* and the layer survives the runner's status writes, so recovery — which
|
|
21
|
+
* rebuilds from the persisted execution with no minted caller — delivers
|
|
22
|
+
* it again.
|
|
14
23
|
*/
|
|
15
24
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
25
|
import { tmpdir } from "node:os";
|
|
@@ -22,7 +31,10 @@ import { afterAll, beforeAll, expect, it } from "vitest";
|
|
|
22
31
|
|
|
23
32
|
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
24
33
|
import { AgentInstanceSchema } from "@stigmer/protos/ai/stigmer/agentic/agentinstance/v1/api_pb";
|
|
34
|
+
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
25
35
|
import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
36
|
+
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
37
|
+
import { AgentExecutionUpdateStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
|
|
26
38
|
import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
|
|
27
39
|
import type { EnvironmentValue } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
|
|
28
40
|
import { EnvironmentValueSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
|
|
@@ -30,6 +42,9 @@ import type { ExecutionContext } from "@stigmer/protos/ai/stigmer/agentic/execut
|
|
|
30
42
|
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
31
43
|
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
32
44
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
45
|
+
import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
46
|
+
import type { PlatformClient } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
47
|
+
import { PlatformClientSchema } from "@stigmer/protos/ai/stigmer/iam/platformclient/v1/api_pb";
|
|
33
48
|
|
|
34
49
|
import { createLogger } from "../../../boot/logger.js";
|
|
35
50
|
import { SqliteStore } from "../../../store/sqlite/store.js";
|
|
@@ -38,6 +53,7 @@ import type { ManagedEnvironmentService } from "../../mcpserver/oauth/managed-en
|
|
|
38
53
|
|
|
39
54
|
import type { ExecutionContextBuilderDeps } from "../create-execution-context-step.js";
|
|
40
55
|
import { buildAndPersistExecutionContext } from "../create-execution-context-step.js";
|
|
56
|
+
import { applyUpdateStatusMerge } from "../update-status.js";
|
|
41
57
|
|
|
42
58
|
const silentLogger = createLogger({
|
|
43
59
|
level: "error",
|
|
@@ -45,6 +61,16 @@ const silentLogger = createLogger({
|
|
|
45
61
|
write: () => {},
|
|
46
62
|
});
|
|
47
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The client port for every execution no minted user created: the layer
|
|
66
|
+
* answers from the execution's own audit, so reaching this is a failure.
|
|
67
|
+
*/
|
|
68
|
+
const unreadPlatformClients: ExecutionContextBuilderDeps["platformClients"] = {
|
|
69
|
+
findById: async () => {
|
|
70
|
+
throw new Error("no minting client: the client must not be read");
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
48
74
|
let dir: string;
|
|
49
75
|
let store: Store;
|
|
50
76
|
|
|
@@ -113,6 +139,7 @@ it("an unresolvable environment ref surfaces the inner status code with Go's wra
|
|
|
113
139
|
readSecretValue: async () => "",
|
|
114
140
|
updateSecrets: async () => {},
|
|
115
141
|
} as unknown as ManagedEnvironmentService,
|
|
142
|
+
platformClients: unreadPlatformClients,
|
|
116
143
|
};
|
|
117
144
|
|
|
118
145
|
const execution = create(AgentExecutionSchema, {
|
|
@@ -251,6 +278,7 @@ it("assembles merge → filter → OAuth injection → EC persist over a non-emp
|
|
|
251
278
|
},
|
|
252
279
|
}),
|
|
253
280
|
managedEnvService,
|
|
281
|
+
platformClients: unreadPlatformClients,
|
|
254
282
|
// The vendor's token endpoint: rotates the refresh token and issues
|
|
255
283
|
// a fresh access token.
|
|
256
284
|
fetchImpl: async () =>
|
|
@@ -394,6 +422,7 @@ it("a session-level server's declared key saved in the personal environment reac
|
|
|
394
422
|
readSecretValue: async () => "",
|
|
395
423
|
updateSecrets: async () => {},
|
|
396
424
|
} as unknown as ManagedEnvironmentService,
|
|
425
|
+
platformClients: unreadPlatformClients,
|
|
397
426
|
};
|
|
398
427
|
const execution = create(AgentExecutionSchema, {
|
|
399
428
|
metadata: { id: "aexec_rule", org: ORG },
|
|
@@ -480,6 +509,7 @@ it("the built-in assistant: no instance, no agent, the session's servers are the
|
|
|
480
509
|
readSecretValue: async () => "",
|
|
481
510
|
updateSecrets: async () => {},
|
|
482
511
|
} as unknown as ManagedEnvironmentService,
|
|
512
|
+
platformClients: unreadPlatformClients,
|
|
483
513
|
};
|
|
484
514
|
const execution = create(AgentExecutionSchema, {
|
|
485
515
|
metadata: { id: "aexec_assistant", org: ORG },
|
|
@@ -500,3 +530,302 @@ it("the built-in assistant: no instance, no agent, the session's servers are the
|
|
|
500
530
|
expect(data["STRAY"]).toBeUndefined();
|
|
501
531
|
expect(reads).toEqual(["NOTES_TOKEN"]);
|
|
502
532
|
});
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// The minting PlatformClient's layer (#1256): keyed on the execution's
|
|
536
|
+
// audit created_by.platform_client_id, which the server stamped from the
|
|
537
|
+
// verified token, below every other layer.
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
|
|
540
|
+
const PC_ORG = "acme";
|
|
541
|
+
|
|
542
|
+
function environmentOf(
|
|
543
|
+
slug: string,
|
|
544
|
+
data: Record<string, string>,
|
|
545
|
+
org = PC_ORG,
|
|
546
|
+
): ReturnType<typeof create<typeof EnvironmentSchema>> {
|
|
547
|
+
return create(EnvironmentSchema, {
|
|
548
|
+
metadata: { id: `env_${slug}`, org, slug },
|
|
549
|
+
spec: {
|
|
550
|
+
data: Object.fromEntries(
|
|
551
|
+
Object.entries(data).map(([key, value]) => [key, { value, isSecret: true }]),
|
|
552
|
+
),
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A PlatformClient of `org` whose environment_refs name `slugs`. */
|
|
558
|
+
function mintingClient(org: string, ...slugs: string[]): PlatformClient {
|
|
559
|
+
return create(PlatformClientSchema, {
|
|
560
|
+
metadata: { id: "pcl_dashboard", org, slug: "dashboard" },
|
|
561
|
+
spec: {
|
|
562
|
+
environmentRefs: slugs.map((slug) => ({
|
|
563
|
+
kind: ApiResourceKind.environment,
|
|
564
|
+
org,
|
|
565
|
+
slug,
|
|
566
|
+
})),
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** An execution in PC_ORG created by an actor who came through `platformClientId`. */
|
|
572
|
+
function executionCreatedThrough(
|
|
573
|
+
id: string,
|
|
574
|
+
platformClientId: string,
|
|
575
|
+
runtimeEnv: Record<string, string> = {},
|
|
576
|
+
): AgentExecution {
|
|
577
|
+
return create(AgentExecutionSchema, {
|
|
578
|
+
metadata: { id, org: PC_ORG },
|
|
579
|
+
spec: {
|
|
580
|
+
sessionId: `ses_${id}`,
|
|
581
|
+
message: "hi",
|
|
582
|
+
runtimeEnv: Object.fromEntries(
|
|
583
|
+
Object.entries(runtimeEnv).map(([key, value]) => [key, { value, isSecret: true }]),
|
|
584
|
+
),
|
|
585
|
+
},
|
|
586
|
+
status: {
|
|
587
|
+
audit: {
|
|
588
|
+
specAudit: { createdBy: { id: "ida_pat", platformClientId } },
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Builder deps for an agent that declares nothing (every merged key passes
|
|
596
|
+
* the filter) over an instance whose one ref is `instance-secrets`. Every
|
|
597
|
+
* resolved reference and every client read is recorded.
|
|
598
|
+
*/
|
|
599
|
+
function platformClientLayerDeps(opts: {
|
|
600
|
+
readonly client: PlatformClient | undefined;
|
|
601
|
+
readonly environments: ReadonlyArray<ReturnType<typeof environmentOf>>;
|
|
602
|
+
readonly resolved: ApiResourceReference[];
|
|
603
|
+
readonly clientReads: string[];
|
|
604
|
+
readonly createdEcs: ExecutionContext[];
|
|
605
|
+
}): ExecutionContextBuilderDeps {
|
|
606
|
+
return {
|
|
607
|
+
store,
|
|
608
|
+
logger: silentLogger,
|
|
609
|
+
agentLoader: () => ({
|
|
610
|
+
get: async () =>
|
|
611
|
+
create(AgentSchema, { metadata: { id: "agt_pc", org: PC_ORG } }),
|
|
612
|
+
}),
|
|
613
|
+
agentInstanceLoader: () => ({
|
|
614
|
+
get: async (instanceId) =>
|
|
615
|
+
create(AgentInstanceSchema, {
|
|
616
|
+
metadata: { id: instanceId, org: PC_ORG },
|
|
617
|
+
spec: {
|
|
618
|
+
agentId: "agt_pc",
|
|
619
|
+
environmentRefs: [
|
|
620
|
+
{
|
|
621
|
+
kind: ApiResourceKind.environment,
|
|
622
|
+
org: PC_ORG,
|
|
623
|
+
slug: "instance-secrets",
|
|
624
|
+
},
|
|
625
|
+
],
|
|
626
|
+
},
|
|
627
|
+
}),
|
|
628
|
+
}),
|
|
629
|
+
sessionLoader: () => ({
|
|
630
|
+
get: async (sessionId) =>
|
|
631
|
+
create(SessionSchema, {
|
|
632
|
+
metadata: { id: sessionId, org: PC_ORG },
|
|
633
|
+
spec: { agentInstanceId: "agi_pc" },
|
|
634
|
+
}),
|
|
635
|
+
}),
|
|
636
|
+
environmentReader: () => ({
|
|
637
|
+
list: async () => {
|
|
638
|
+
throw new Error("personal-env lookup not needed in this test");
|
|
639
|
+
},
|
|
640
|
+
getSecretValue: async () => {
|
|
641
|
+
throw new Error("personal-env lookup not needed in this test");
|
|
642
|
+
},
|
|
643
|
+
}),
|
|
644
|
+
environmentResolution: {
|
|
645
|
+
resolveByReference: async (ref: ApiResourceReference) => {
|
|
646
|
+
opts.resolved.push(ref);
|
|
647
|
+
const found = opts.environments.find(
|
|
648
|
+
(env) => env.metadata?.slug === ref.slug && env.metadata?.org === ref.org,
|
|
649
|
+
);
|
|
650
|
+
if (found === undefined) {
|
|
651
|
+
throw new ConnectError(`environment not found: ${ref.slug}`, Code.NotFound);
|
|
652
|
+
}
|
|
653
|
+
return found;
|
|
654
|
+
},
|
|
655
|
+
} as unknown as ExecutionContextBuilderDeps["environmentResolution"],
|
|
656
|
+
executionContextCreator: () => ({
|
|
657
|
+
create: async (ec) => {
|
|
658
|
+
opts.createdEcs.push(ec);
|
|
659
|
+
return ec;
|
|
660
|
+
},
|
|
661
|
+
}),
|
|
662
|
+
managedEnvService: {
|
|
663
|
+
readSecretValue: async () => "",
|
|
664
|
+
updateSecrets: async () => {},
|
|
665
|
+
} as unknown as ManagedEnvironmentService,
|
|
666
|
+
platformClients: {
|
|
667
|
+
findById: async (id) => {
|
|
668
|
+
opts.clientReads.push(id);
|
|
669
|
+
return opts.client;
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const instanceSecrets = environmentOf("instance-secrets", {
|
|
676
|
+
INSTANCE_WINS: "instance",
|
|
677
|
+
});
|
|
678
|
+
const clientSecrets = environmentOf("embed-secrets", {
|
|
679
|
+
SHARED_API_SECRET: "client-secret",
|
|
680
|
+
INSTANCE_WINS: "client",
|
|
681
|
+
RUNTIME_WINS: "client",
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("a minted user's execution receives its client's environments below the instance layer and runtime_env", async () => {
|
|
685
|
+
const resolved: ApiResourceReference[] = [];
|
|
686
|
+
const clientReads: string[] = [];
|
|
687
|
+
const createdEcs: ExecutionContext[] = [];
|
|
688
|
+
const deps = platformClientLayerDeps({
|
|
689
|
+
client: mintingClient(PC_ORG, "embed-secrets"),
|
|
690
|
+
environments: [instanceSecrets, clientSecrets],
|
|
691
|
+
resolved,
|
|
692
|
+
clientReads,
|
|
693
|
+
createdEcs,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
await buildAndPersistExecutionContext(
|
|
697
|
+
deps,
|
|
698
|
+
executionCreatedThrough("aexec_pc_layer", "pcl_dashboard", {
|
|
699
|
+
RUNTIME_WINS: "runtime",
|
|
700
|
+
}),
|
|
701
|
+
"",
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
const data = createdEcs[0]?.spec?.data ?? {};
|
|
705
|
+
// The client's own key reaches the run.
|
|
706
|
+
expect(data["SHARED_API_SECRET"]?.value).toBe("client-secret");
|
|
707
|
+
// The precedence #1256 asks to pin: instance refs, then runtime_env,
|
|
708
|
+
// override the client on a key conflict.
|
|
709
|
+
expect(data["INSTANCE_WINS"]?.value).toBe("instance");
|
|
710
|
+
expect(data["RUNTIME_WINS"]?.value).toBe("runtime");
|
|
711
|
+
expect(clientReads).toEqual(["pcl_dashboard"]);
|
|
712
|
+
expect(resolved.map((ref) => `${ref.org}/${ref.slug}`)).toEqual([
|
|
713
|
+
"acme/instance-secrets",
|
|
714
|
+
"acme/embed-secrets",
|
|
715
|
+
]);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
it("no client, a deleted client and a client of another organization contribute nothing", async () => {
|
|
719
|
+
// A creator who came through no client: no client is read at all.
|
|
720
|
+
const noClient: ExecutionContext[] = [];
|
|
721
|
+
await buildAndPersistExecutionContext(
|
|
722
|
+
{
|
|
723
|
+
...platformClientLayerDeps({
|
|
724
|
+
client: undefined,
|
|
725
|
+
environments: [instanceSecrets],
|
|
726
|
+
resolved: [],
|
|
727
|
+
clientReads: [],
|
|
728
|
+
createdEcs: noClient,
|
|
729
|
+
}),
|
|
730
|
+
platformClients: unreadPlatformClients,
|
|
731
|
+
},
|
|
732
|
+
executionCreatedThrough("aexec_pc_none", ""),
|
|
733
|
+
"",
|
|
734
|
+
);
|
|
735
|
+
expect(noClient[0]?.spec?.data["INSTANCE_WINS"]?.value).toBe("instance");
|
|
736
|
+
|
|
737
|
+
// The client was deleted since: the run proceeds without its layer.
|
|
738
|
+
const deleted: ExecutionContext[] = [];
|
|
739
|
+
const deletedReads: string[] = [];
|
|
740
|
+
await buildAndPersistExecutionContext(
|
|
741
|
+
platformClientLayerDeps({
|
|
742
|
+
client: undefined,
|
|
743
|
+
environments: [instanceSecrets, clientSecrets],
|
|
744
|
+
resolved: [],
|
|
745
|
+
clientReads: deletedReads,
|
|
746
|
+
createdEcs: deleted,
|
|
747
|
+
}),
|
|
748
|
+
executionCreatedThrough("aexec_pc_deleted", "pcl_dashboard"),
|
|
749
|
+
"",
|
|
750
|
+
);
|
|
751
|
+
expect(deletedReads).toEqual(["pcl_dashboard"]);
|
|
752
|
+
expect(deleted[0]?.spec?.data["SHARED_API_SECRET"]).toBeUndefined();
|
|
753
|
+
expect(deleted[0]?.spec?.data["INSTANCE_WINS"]?.value).toBe("instance");
|
|
754
|
+
|
|
755
|
+
// A client of another organization: no environment value crosses an
|
|
756
|
+
// organization, so its refs are never even resolved.
|
|
757
|
+
const foreign: ExecutionContext[] = [];
|
|
758
|
+
const foreignResolved: ApiResourceReference[] = [];
|
|
759
|
+
await buildAndPersistExecutionContext(
|
|
760
|
+
platformClientLayerDeps({
|
|
761
|
+
client: mintingClient("globex", "embed-secrets"),
|
|
762
|
+
environments: [instanceSecrets, environmentOf("embed-secrets", { SHARED_API_SECRET: "x" }, "globex")],
|
|
763
|
+
resolved: foreignResolved,
|
|
764
|
+
clientReads: [],
|
|
765
|
+
createdEcs: foreign,
|
|
766
|
+
}),
|
|
767
|
+
executionCreatedThrough("aexec_pc_foreign", "pcl_dashboard"),
|
|
768
|
+
"",
|
|
769
|
+
);
|
|
770
|
+
expect(foreign[0]?.spec?.data["SHARED_API_SECRET"]).toBeUndefined();
|
|
771
|
+
expect(foreignResolved.map((ref) => ref.org)).toEqual(["acme"]);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("a client environment that no longer exists fails the create with the client named in the chain", async () => {
|
|
775
|
+
try {
|
|
776
|
+
await buildAndPersistExecutionContext(
|
|
777
|
+
platformClientLayerDeps({
|
|
778
|
+
client: mintingClient(PC_ORG, "gone-secrets"),
|
|
779
|
+
environments: [instanceSecrets],
|
|
780
|
+
resolved: [],
|
|
781
|
+
clientReads: [],
|
|
782
|
+
createdEcs: [],
|
|
783
|
+
}),
|
|
784
|
+
executionCreatedThrough("aexec_pc_gone", "pcl_dashboard"),
|
|
785
|
+
"",
|
|
786
|
+
);
|
|
787
|
+
expect.unreachable("expected NotFound");
|
|
788
|
+
} catch (error) {
|
|
789
|
+
const connectError = ConnectError.from(error);
|
|
790
|
+
expect(connectError.code).toBe(Code.NotFound);
|
|
791
|
+
expect(connectError.rawMessage).toBe(
|
|
792
|
+
"resolve platform client pcl_dashboard environment_refs: " +
|
|
793
|
+
"resolve environment ref (org=acme, slug=gone-secrets): " +
|
|
794
|
+
"rpc error: code = NotFound desc = environment not found: gone-secrets",
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it("the layer survives the runner's status writes, so recovery delivers it again", async () => {
|
|
800
|
+
const execution = executionCreatedThrough("aexec_pc_recover", "pcl_dashboard");
|
|
801
|
+
// The runner's progressive status write, as UpdateStatus applies it.
|
|
802
|
+
applyUpdateStatusMerge(
|
|
803
|
+
execution,
|
|
804
|
+
create(AgentExecutionUpdateStatusInputSchema, {
|
|
805
|
+
executionId: "aexec_pc_recover",
|
|
806
|
+
status: { phase: ExecutionPhase.EXECUTION_FAILED, error: "boom" },
|
|
807
|
+
}),
|
|
808
|
+
silentLogger,
|
|
809
|
+
);
|
|
810
|
+
expect(
|
|
811
|
+
execution.status?.audit?.specAudit?.createdBy?.platformClientId,
|
|
812
|
+
).toBe("pcl_dashboard");
|
|
813
|
+
|
|
814
|
+
// Recover rebuilds from the persisted execution with no pre-resolved
|
|
815
|
+
// instance and no minted caller (lifecycle.ts's recreate step).
|
|
816
|
+
const createdEcs: ExecutionContext[] = [];
|
|
817
|
+
await buildAndPersistExecutionContext(
|
|
818
|
+
platformClientLayerDeps({
|
|
819
|
+
client: mintingClient(PC_ORG, "embed-secrets"),
|
|
820
|
+
environments: [instanceSecrets, clientSecrets],
|
|
821
|
+
resolved: [],
|
|
822
|
+
clientReads: [],
|
|
823
|
+
createdEcs,
|
|
824
|
+
}),
|
|
825
|
+
execution,
|
|
826
|
+
"",
|
|
827
|
+
);
|
|
828
|
+
expect(createdEcs[0]?.spec?.data["SHARED_API_SECRET"]?.value).toBe(
|
|
829
|
+
"client-secret",
|
|
830
|
+
);
|
|
831
|
+
});
|
|
@@ -434,6 +434,13 @@ function stubBuilderDeps(overrides?: {
|
|
|
434
434
|
readSecretValue: async () => "",
|
|
435
435
|
updateSecrets: async () => {},
|
|
436
436
|
} as unknown as ManagedEnvironmentService,
|
|
437
|
+
// No execution here was created by a minted user, so the minting
|
|
438
|
+
// client's layer answers from the audit without reading a client.
|
|
439
|
+
platformClients: {
|
|
440
|
+
findById: async () => {
|
|
441
|
+
throw new Error("no minting client in this test");
|
|
442
|
+
},
|
|
443
|
+
},
|
|
437
444
|
};
|
|
438
445
|
}
|
|
439
446
|
|
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
* no instance layers, and the session's own MCP servers are the whole
|
|
17
17
|
* tool set.
|
|
18
18
|
*
|
|
19
|
-
* Merge priority (lowest to highest):
|
|
20
|
-
* environment_refs
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* Merge priority (lowest to highest): the minting PlatformClient's
|
|
20
|
+
* environment_refs, for an execution a PlatformClient-minted user created
|
|
21
|
+
* (#1256) → schedule/workflow-task environment_refs (the
|
|
22
|
+
* share/channel/schedule/agent_call layering) → instance environment_refs
|
|
23
|
+
* (via the environment RuntimeResolutionService — decrypted, the RPC
|
|
24
|
+
* surface redacts, oss#405) → spec.runtime_env. Every layer is keyed on
|
|
25
|
+
* the persisted execution alone (its labels, its audit), never on the
|
|
26
|
+
* request's caller, so recovery rebuilds exactly what create built.
|
|
23
27
|
*
|
|
24
28
|
* Then ONE declaration rule for every shape: the run declares what its
|
|
25
29
|
* agent declares and what its session's MCP servers declare, and the
|
|
@@ -96,6 +100,7 @@ import type { PersonalEnvironmentResolution } from "../environment/personal.js";
|
|
|
96
100
|
import type { RuntimeResolutionService } from "../environment/resolution/resolution.js";
|
|
97
101
|
import type { ManagedEnvironmentService } from "../mcpserver/oauth/managed-env.js";
|
|
98
102
|
import { refreshTokenIfExpired } from "../mcpserver/oauth/refresh.js";
|
|
103
|
+
import type { PlatformClientStore } from "../platformclient/store.js";
|
|
99
104
|
import { unmarshalTaskConfig } from "../workflow/converter/unmarshal.js";
|
|
100
105
|
|
|
101
106
|
import { DEFAULT_INSTANCE_ID_KEY } from "./create-steps.js";
|
|
@@ -160,6 +165,12 @@ export interface ExecutionContextBuilderDeps {
|
|
|
160
165
|
readonly environmentResolution: RuntimeResolutionService;
|
|
161
166
|
readonly executionContextCreator: () => ExecutionContextCreator;
|
|
162
167
|
readonly managedEnvService: ManagedEnvironmentService;
|
|
168
|
+
/**
|
|
169
|
+
* The PlatformClient port the minting client's environment layer reads
|
|
170
|
+
* (4.7). The port, not the Store: a composition may keep client rows in
|
|
171
|
+
* a table of its own (domain/platformclient/store.ts).
|
|
172
|
+
*/
|
|
173
|
+
readonly platformClients: Pick<PlatformClientStore, "findById">;
|
|
163
174
|
/** Test seam forwarded to the OAuth token-endpoint refresh. */
|
|
164
175
|
readonly fetchImpl?: typeof fetch;
|
|
165
176
|
}
|
|
@@ -323,6 +334,22 @@ export async function buildAndPersistExecutionContext(
|
|
|
323
334
|
environments = [...workflowEnvironments, ...environments];
|
|
324
335
|
}
|
|
325
336
|
|
|
337
|
+
// 4.7 PlatformClient-minted executions: the minting client's own
|
|
338
|
+
// environment_refs, BELOW every other layer — the fifth application of
|
|
339
|
+
// the connection-resource mechanism (#381, restored by #1256). The key
|
|
340
|
+
// is the execution's audit created_by, which the server stamped from
|
|
341
|
+
// the verified token, so it holds on recover, where no minted caller
|
|
342
|
+
// exists. Independent of 4.5/4.6: a minted user's own turn carries no
|
|
343
|
+
// schedule or workflow provenance, and a fire or a workflow task stamps
|
|
344
|
+
// no minted actor.
|
|
345
|
+
const platformClientEnvironments = await resolvePlatformClientEnvironments(
|
|
346
|
+
deps,
|
|
347
|
+
execution,
|
|
348
|
+
);
|
|
349
|
+
if (platformClientEnvironments.length > 0) {
|
|
350
|
+
environments = [...platformClientEnvironments, ...environments];
|
|
351
|
+
}
|
|
352
|
+
|
|
326
353
|
// 5. Merge all layers.
|
|
327
354
|
const merged = mergeEnvironmentLayers(
|
|
328
355
|
environments,
|
|
@@ -745,6 +772,85 @@ async function resolveScheduleEnvironments(
|
|
|
745
772
|
}
|
|
746
773
|
}
|
|
747
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Resolves the environment_refs of the PlatformClient whose minted user
|
|
777
|
+
* created this execution (its audit created_by.platform_client_id, stamped
|
|
778
|
+
* by the server from the verified token). No minting client — every
|
|
779
|
+
* execution not created by a minted user — answers empty without a read.
|
|
780
|
+
* A DELETED client degrades to no client environments (the schedule
|
|
781
|
+
* layer's posture; its tokens are already refused). A client of another
|
|
782
|
+
* organization than the execution's contributes nothing: no environment
|
|
783
|
+
* value crosses an organization, the run-time twin of the reference
|
|
784
|
+
* rule's cross-organization clause (pipeline/steps/references.ts). An
|
|
785
|
+
* expired client still contributes: expiry stops minting, not the tokens
|
|
786
|
+
* already minted. Environment visibility is not consulted, as for every
|
|
787
|
+
* layer (the reference rule's reasoning: the server resolves the
|
|
788
|
+
* environment on the run's behalf). An unresolvable REF fails the create.
|
|
789
|
+
*/
|
|
790
|
+
async function resolvePlatformClientEnvironments(
|
|
791
|
+
deps: ExecutionContextBuilderDeps,
|
|
792
|
+
execution: AgentExecution,
|
|
793
|
+
): Promise<Environment[]> {
|
|
794
|
+
const platformClientId =
|
|
795
|
+
execution.status?.audit?.specAudit?.createdBy?.platformClientId ?? "";
|
|
796
|
+
if (platformClientId === "") {
|
|
797
|
+
return [];
|
|
798
|
+
}
|
|
799
|
+
const executionId = execution.metadata?.id ?? "";
|
|
800
|
+
|
|
801
|
+
let client;
|
|
802
|
+
try {
|
|
803
|
+
client = await deps.platformClients.findById(platformClientId);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
`load platform client ${platformClientId} for environment resolution: ${error instanceof Error ? error.message : String(error)}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
if (client === undefined) {
|
|
810
|
+
deps.logger.warn(
|
|
811
|
+
"Minting platform client is gone — running without its environments",
|
|
812
|
+
{ platformClientId, executionId },
|
|
813
|
+
);
|
|
814
|
+
return [];
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const clientOrg = client.metadata?.org ?? "";
|
|
818
|
+
const executionOrg = execution.metadata?.org ?? "";
|
|
819
|
+
if (clientOrg !== executionOrg) {
|
|
820
|
+
deps.logger.warn(
|
|
821
|
+
"Minting platform client belongs to another organization — its environments are not delivered",
|
|
822
|
+
{ platformClientId, clientOrg, executionOrg, executionId },
|
|
823
|
+
);
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const refs = client.spec?.environmentRefs ?? [];
|
|
828
|
+
if (refs.length === 0) {
|
|
829
|
+
return [];
|
|
830
|
+
}
|
|
831
|
+
// Stored refs are absolute (NormalizeReferences on the client's writes);
|
|
832
|
+
// an empty org is filled from the client's own, as the schedule layer
|
|
833
|
+
// does for its manifest refs.
|
|
834
|
+
const resolved = refs.map((ref) =>
|
|
835
|
+
ref.org === ""
|
|
836
|
+
? create(ApiResourceReferenceSchema, {
|
|
837
|
+
kind: ref.kind,
|
|
838
|
+
org: clientOrg,
|
|
839
|
+
slug: ref.slug,
|
|
840
|
+
})
|
|
841
|
+
: ref,
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
return await resolveEnvironments(deps, resolved);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
chainError(
|
|
848
|
+
`resolve platform client ${platformClientId} environment_refs`,
|
|
849
|
+
error,
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
748
854
|
/**
|
|
749
855
|
* Resolves the environment_refs of the agent_call task that created this
|
|
750
856
|
* execution (the workflow-provenance labels). Missing labels — every
|
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
* the row's kind and id as the target); delete authorizes before it
|
|
26
26
|
* loads; create validates the role before it writes, then runs the
|
|
27
27
|
* `iam-policy-create:pre-side-effect-gate` slot, whose refusal leaves
|
|
28
|
-
* no row; bootstrapPolicy never runs that slot
|
|
28
|
+
* no row; bootstrapPolicy never runs that slot;
|
|
29
|
+
* - owner is assigned by owners: an admin is refused granting owner,
|
|
30
|
+
* revoking it and removing an owner, after position 1 and before any
|
|
31
|
+
* write; an owner is allowed each; the last owner is kept; the
|
|
32
|
+
* bootstrap twins run neither owner step.
|
|
29
33
|
*/
|
|
30
34
|
import { create } from "@bufbuild/protobuf";
|
|
31
35
|
import type { DescMessage } from "@bufbuild/protobuf";
|
|
@@ -76,6 +80,8 @@ import {
|
|
|
76
80
|
import {
|
|
77
81
|
AUTHENTICATION_REQUIRED_MESSAGE,
|
|
78
82
|
AUTHORIZATION_QUERIES_UNIMPLEMENTED_MESSAGE,
|
|
83
|
+
LAST_OWNER_MESSAGE,
|
|
84
|
+
OWNER_ASSIGNMENT_DENIED_MESSAGE,
|
|
79
85
|
policyIdFor,
|
|
80
86
|
policyNotFoundMessage,
|
|
81
87
|
principalNotGrantableMessage,
|
|
@@ -129,7 +135,8 @@ function stampCaller(identity: CallerIdentity): Interceptor {
|
|
|
129
135
|
|
|
130
136
|
interface RecordingAuthorizer extends Authorizer {
|
|
131
137
|
readonly checks: AuthzCheck[];
|
|
132
|
-
|
|
138
|
+
/** One answer for every check, or one per check (a throw is an outage). */
|
|
139
|
+
decision: AuthzDecision | ((check: AuthzCheck) => AuthzDecision);
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
function recordingAuthorizer(): RecordingAuthorizer {
|
|
@@ -140,7 +147,7 @@ function recordingAuthorizer(): RecordingAuthorizer {
|
|
|
140
147
|
authorize(_caller, check) {
|
|
141
148
|
checks.push(check);
|
|
142
149
|
if (typeof authorizer.decision === "function") {
|
|
143
|
-
return authorizer.decision();
|
|
150
|
+
return Promise.resolve(authorizer.decision(check));
|
|
144
151
|
}
|
|
145
152
|
return Promise.resolve(authorizer.decision);
|
|
146
153
|
},
|
|
@@ -933,6 +940,95 @@ describe("the write lanes' order", () => {
|
|
|
933
940
|
});
|
|
934
941
|
});
|
|
935
942
|
|
|
943
|
+
describe("owner is assigned by owners, on the three caller lanes", () => {
|
|
944
|
+
/** alice is an admin of acme: she may grant access there, and may not assign owner. */
|
|
945
|
+
const adminOfAcme = (check: AuthzCheck): AuthzDecision =>
|
|
946
|
+
check.permission === IamPermission.can_assign_roles
|
|
947
|
+
? { kind: "deny", reason: "" }
|
|
948
|
+
: { kind: "allow" };
|
|
949
|
+
const assignRolesOnAcme: AuthzCheck = {
|
|
950
|
+
permission: IamPermission.can_assign_roles,
|
|
951
|
+
resourceKind: ApiResourceKind.organization,
|
|
952
|
+
resourceId: "acme",
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
/** acme's roles, written straight through the grant path under the harness's store. */
|
|
956
|
+
async function acmeWith(
|
|
957
|
+
h: Harness,
|
|
958
|
+
roles: ReadonlyArray<readonly [string, string]>,
|
|
959
|
+
): Promise<void> {
|
|
960
|
+
const path = newIamPolicyGrantPath({ policies: h.policies, lifecycle: undefined, logger: silent });
|
|
961
|
+
for (const [account, role] of roles) {
|
|
962
|
+
await path.grant(orgRole(account, role, "acme"), internal);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
it("an admin is refused granting owner, after the role check and before the gate and the write", async () => {
|
|
967
|
+
const seen: string[] = [];
|
|
968
|
+
const h = await harness({
|
|
969
|
+
caller: alice,
|
|
970
|
+
createGate: { name: "RecordForTest", execute: () => void seen.push("gate") },
|
|
971
|
+
});
|
|
972
|
+
h.authorizer.decision = adminOfAcme;
|
|
973
|
+
const error = await refusal(() => h.command.create(orgRole("ida_bob", "owner", "acme")));
|
|
974
|
+
expect(error.code).toBe(Code.PermissionDenied);
|
|
975
|
+
expect(error.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
|
|
976
|
+
expect(h.authorizer.checks.at(-1)).toEqual(assignRolesOnAcme);
|
|
977
|
+
expect(seen).toEqual([]);
|
|
978
|
+
expect(h.policies.rows.size).toBe(0);
|
|
979
|
+
|
|
980
|
+
// Every role up to admin is still hers to grant, and asks nothing more.
|
|
981
|
+
await h.command.create(orgRole("ida_bob", "admin", "acme"));
|
|
982
|
+
expect(h.authorizer.checks.at(-1)?.permission).toBe(IamPermission.can_grant_access);
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("an admin is refused revoking owner and removing an owner, and neither row moves", async () => {
|
|
986
|
+
const asAlice = await harness({ caller: alice });
|
|
987
|
+
await acmeWith(asAlice, [["ida_root", "owner"], ["ida_rhea", "owner"]]);
|
|
988
|
+
asAlice.authorizer.decision = adminOfAcme;
|
|
989
|
+
|
|
990
|
+
const revoke = await refusal(() => asAlice.command.delete(orgRole("ida_rhea", "owner", "acme")));
|
|
991
|
+
expect(revoke.code).toBe(Code.PermissionDenied);
|
|
992
|
+
expect(revoke.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
|
|
993
|
+
const remove = await refusal(() =>
|
|
994
|
+
asAlice.command.revokeOrgAccess({ identityAccountId: "ida_rhea", organizationId: "acme" }),
|
|
995
|
+
);
|
|
996
|
+
expect(remove.code).toBe(Code.PermissionDenied);
|
|
997
|
+
expect(remove.rawMessage).toBe(OWNER_ASSIGNMENT_DENIED_MESSAGE);
|
|
998
|
+
expect(asAlice.policies.rows.size).toBe(2);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
it("an owner grants, revokes and removes owner, but never the last one", async () => {
|
|
1002
|
+
const h = await harness({ caller: alice });
|
|
1003
|
+
await acmeWith(h, [["ida_alice", "owner"]]);
|
|
1004
|
+
|
|
1005
|
+
await h.command.create(orgRole("ida_bob", "owner", "acme"));
|
|
1006
|
+
await h.command.delete(orgRole("ida_bob", "owner", "acme"));
|
|
1007
|
+
await h.command.create(orgRole("ida_bob", "owner", "acme"));
|
|
1008
|
+
await h.command.revokeOrgAccess({ identityAccountId: "ida_bob", organizationId: "acme" });
|
|
1009
|
+
expect([...h.policies.rows.values()].map((row) => row.spec?.principal?.id)).toEqual(["ida_alice"]);
|
|
1010
|
+
|
|
1011
|
+
const revoke = await refusal(() => h.command.delete(orgRole("ida_alice", "owner", "acme")));
|
|
1012
|
+
expect(revoke.code).toBe(Code.FailedPrecondition);
|
|
1013
|
+
expect(revoke.rawMessage).toBe(LAST_OWNER_MESSAGE);
|
|
1014
|
+
const remove = await refusal(() =>
|
|
1015
|
+
h.command.revokeOrgAccess({ identityAccountId: "ida_alice", organizationId: "acme" }),
|
|
1016
|
+
);
|
|
1017
|
+
expect(remove.code).toBe(Code.FailedPrecondition);
|
|
1018
|
+
expect(remove.rawMessage).toBe(LAST_OWNER_MESSAGE);
|
|
1019
|
+
expect(h.policies.rows.size).toBe(1);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
it("the bootstrap twins run neither owner step: the platform removes the last owner", async () => {
|
|
1023
|
+
const h = await harness({ caller: machine });
|
|
1024
|
+
await acmeWith(h, [["ida_root", "owner"]]);
|
|
1025
|
+
h.authorizer.decision = adminOfAcme;
|
|
1026
|
+
await h.command.bootstrapRevokeOrgAccess({ identityAccountId: "ida_root", organizationId: "acme" });
|
|
1027
|
+
expect(h.policies.rows.size).toBe(0);
|
|
1028
|
+
expect(h.authorizer.checks.map((check) => check.permission)).not.toContain(IamPermission.can_assign_roles);
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
936
1032
|
describe("a wire kind string is refused BEFORE position 1 on every annotated lane (Q-S6-1)", () => {
|
|
937
1033
|
// A kind string that names no kind names no authorization target, so
|
|
938
1034
|
// there is nothing to ask the Authorizer — the same reason
|