@stigmer/server 3.37.1 → 3.38.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +4 -1
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +18 -7
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js +1 -0
- package/dist/boot/config.js.map +1 -1
- package/dist/domain/iampolicy/grant-path.d.ts +7 -3
- package/dist/domain/iampolicy/grant-path.d.ts.map +1 -1
- package/dist/domain/iampolicy/grant-path.js +10 -2
- package/dist/domain/iampolicy/grant-path.js.map +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.js +10 -4
- package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/connect.d.ts +2 -1
- package/dist/domain/mcpserver/connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/connect.js +5 -2
- package/dist/domain/mcpserver/connect.js.map +1 -1
- package/dist/domain/mcpserver/controller.d.ts.map +1 -1
- package/dist/domain/mcpserver/controller.js +7 -3
- package/dist/domain/mcpserver/controller.js.map +1 -1
- package/dist/domain/mcpserver/get-oauth-grant-status.js +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.js +8 -4
- package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/start-connect.d.ts +2 -2
- package/dist/domain/mcpserver/start-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/start-connect.js +10 -4
- package/dist/domain/mcpserver/start-connect.js.map +1 -1
- package/dist/domain/organization/controller.d.ts +4 -1
- package/dist/domain/organization/controller.d.ts.map +1 -1
- package/dist/domain/organization/controller.js +28 -4
- package/dist/domain/organization/controller.js.map +1 -1
- package/dist/domain/organization/steps.d.ts +22 -0
- package/dist/domain/organization/steps.d.ts.map +1 -1
- package/dist/domain/organization/steps.js +50 -2
- package/dist/domain/organization/steps.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +7 -5
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +4 -2
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/extensions/gate-slots.d.ts +14 -1
- package/dist/extensions/gate-slots.d.ts.map +1 -1
- package/dist/extensions/gate-slots.js +1 -0
- package/dist/extensions/gate-slots.js.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.d.ts +6 -2
- package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
- package/dist/pipeline/steps/authorization-tuples.js +6 -2
- package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
- package/package.json +6 -6
- package/src/boot/__tests__/config.test.ts +31 -0
- package/src/boot/compose.ts +4 -1
- package/src/boot/config.ts +23 -7
- package/src/domain/agentexecution/__tests__/store-faults.test.ts +6 -26
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +40 -7
- package/src/domain/iampolicy/grant-path.ts +19 -5
- package/src/domain/mcpserver/__tests__/store-faults.test.ts +333 -0
- package/src/domain/mcpserver/complete-oauth-connect.ts +13 -4
- package/src/domain/mcpserver/connect.ts +7 -3
- package/src/domain/mcpserver/controller.ts +7 -3
- package/src/domain/mcpserver/get-oauth-grant-status.ts +1 -1
- package/src/domain/mcpserver/initiate-oauth-connect.ts +8 -4
- package/src/domain/mcpserver/start-connect.ts +12 -6
- package/src/domain/organization/__tests__/organization-delete.test.ts +266 -0
- package/src/domain/organization/controller.ts +40 -8
- package/src/domain/organization/steps.ts +66 -2
- package/src/domain/platform/__tests__/platform.test.ts +55 -3
- package/src/domain/platform/controller.ts +11 -7
- package/src/domain/workflowexecution/__tests__/store-faults.test.ts +6 -26
- package/src/extensions/__tests__/registry.test.ts +3 -0
- package/src/extensions/gate-slots.ts +14 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +0 -5
- package/src/pipeline/__tests__/support.ts +48 -5
- package/src/pipeline/steps/authorization-tuples.ts +6 -2
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the organization delete's order through a composed server in the
|
|
3
|
+
* trusted-local posture: everything that names the organization goes
|
|
4
|
+
* before its row, and a fault stops the delete with the organization in
|
|
5
|
+
* place. An organization's id is its slug and the delete frees the slug for
|
|
6
|
+
* anyone, so a row left behind would belong to the slug's next holder.
|
|
7
|
+
*
|
|
8
|
+
* - the `org-delete:pre-delete` slot runs with the organization loaded
|
|
9
|
+
* and still stored, and its rows still in place;
|
|
10
|
+
* - a slot step's refusal answers its own code and copy, and leaves the
|
|
11
|
+
* organization and its rows;
|
|
12
|
+
* - the organization's policy rows are revoked before its row, as
|
|
13
|
+
* principal and as resource: a revocation fault answers Internal with
|
|
14
|
+
* fixed copy, leaves the organization and its owner row, and a retry
|
|
15
|
+
* completes the delete;
|
|
16
|
+
* - a slug freed by the delete and created again carries none of the old
|
|
17
|
+
* organization's rows.
|
|
18
|
+
*
|
|
19
|
+
* The policy store is the library's in-memory double, composed as the
|
|
20
|
+
* IamPolicy store driver so a test can make one row's delete fault; the
|
|
21
|
+
* grant path, the role lifecycle and the chain are the library's own.
|
|
22
|
+
*/
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
28
|
+
import { Code, ConnectError, createClient } from "@connectrpc/connect";
|
|
29
|
+
import type { Client, Transport } from "@connectrpc/connect";
|
|
30
|
+
import { createGrpcTransport } from "@connectrpc/connect-node";
|
|
31
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
32
|
+
|
|
33
|
+
import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
|
|
34
|
+
import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
35
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
36
|
+
import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
|
|
37
|
+
import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
|
|
38
|
+
|
|
39
|
+
import { loadConfig } from "../../../boot/config.js";
|
|
40
|
+
import { composeServer } from "../../../boot/compose.js";
|
|
41
|
+
import type { ComposedServer } from "../../../boot/compose.js";
|
|
42
|
+
import { createLogger } from "../../../boot/logger.js";
|
|
43
|
+
import type { GateSlotName } from "../../../extensions/gate-slots.js";
|
|
44
|
+
import type { ServerExtension } from "../../../extensions/registry.js";
|
|
45
|
+
import type { PipelineStep } from "../../../pipeline/pipeline.js";
|
|
46
|
+
import { EXISTING_RESOURCE_KEY } from "../../../pipeline/steps/load-existing.js";
|
|
47
|
+
import {
|
|
48
|
+
resetOperatorIdentityForTests,
|
|
49
|
+
setOperatorIdentity,
|
|
50
|
+
} from "../../../pipeline/steps/defaults.js";
|
|
51
|
+
import {
|
|
52
|
+
fakeIamPolicyStore,
|
|
53
|
+
orgRole,
|
|
54
|
+
triple,
|
|
55
|
+
} from "../../iampolicy/__tests__/support.js";
|
|
56
|
+
|
|
57
|
+
const OPERATOR_EMAIL = "operator@example.com";
|
|
58
|
+
const REFUSED_SLUG = "delete-refused";
|
|
59
|
+
const OUTSIDER = "ida_0byc5k14t1e7b7kdxft7hwz1f7";
|
|
60
|
+
const AGENT = "agt_01hzzzzzzzzzzzzzzzzzzzzzzz";
|
|
61
|
+
|
|
62
|
+
async function grpcError(run: () => Promise<unknown>): Promise<ConnectError> {
|
|
63
|
+
try {
|
|
64
|
+
await run();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof ConnectError) {
|
|
67
|
+
return error;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
throw new Error("expected the call to fail");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("organization delete (composed server, trusted-local posture)", () => {
|
|
75
|
+
const policies = fakeIamPolicyStore();
|
|
76
|
+
/** A policy id whose delete faults while set: the store answering an error mid-revocation. */
|
|
77
|
+
let faultingDelete: string | undefined;
|
|
78
|
+
const faultablePolicies = {
|
|
79
|
+
...policies,
|
|
80
|
+
async deleteById(id: string): Promise<void> {
|
|
81
|
+
if (id === faultingDelete) {
|
|
82
|
+
throw new Error("policy store unavailable");
|
|
83
|
+
}
|
|
84
|
+
await policies.deleteById(id);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** What the slot's step saw, per organization, while it ran. */
|
|
89
|
+
const seenBySlot = new Map<string, { loaded: string; rowsNaming: number }>();
|
|
90
|
+
const slotStep: PipelineStep<DescMessage> = {
|
|
91
|
+
name: "FakeOrgDeleteStep",
|
|
92
|
+
execute: (ctx) => {
|
|
93
|
+
const organization = ctx.get(EXISTING_RESOURCE_KEY) as
|
|
94
|
+
| Organization
|
|
95
|
+
| undefined;
|
|
96
|
+
const id = organization?.metadata?.id ?? "";
|
|
97
|
+
seenBySlot.set(id, { loaded: id, rowsNaming: rowsNaming(id).length });
|
|
98
|
+
if (id === REFUSED_SLUG) {
|
|
99
|
+
throw new ConnectError(
|
|
100
|
+
"fake edition keeps something for this organization",
|
|
101
|
+
Code.FailedPrecondition,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
const unit: ServerExtension = {
|
|
107
|
+
name: "fake-org-delete",
|
|
108
|
+
gateSteps: new Map<GateSlotName, ReadonlyArray<PipelineStep<DescMessage>>>([
|
|
109
|
+
["org-delete:pre-delete", [slotStep]],
|
|
110
|
+
]),
|
|
111
|
+
drivers: { iamPolicyStore: faultablePolicies },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
let dir: string;
|
|
115
|
+
let server: ComposedServer;
|
|
116
|
+
let transport: Transport;
|
|
117
|
+
let organizations: Client<typeof OrganizationCommandController>;
|
|
118
|
+
let organizationQuery: Client<typeof OrganizationQueryController>;
|
|
119
|
+
/** The platform's own pipeline: the in-process transport's `internal` caller. */
|
|
120
|
+
let platform: Client<typeof IamPolicyCommandController>;
|
|
121
|
+
|
|
122
|
+
function rowsNaming(org: string): IamPolicy[] {
|
|
123
|
+
return [...policies.rows.values()].filter(
|
|
124
|
+
(policy) =>
|
|
125
|
+
(policy.spec?.resource?.kind === "organization" &&
|
|
126
|
+
policy.spec.resource.id === org) ||
|
|
127
|
+
(policy.spec?.principal?.kind === "organization" &&
|
|
128
|
+
policy.spec.principal.id === org),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** An organization with its creator's owner row, an outsider's member row, and an agent's scope link to it. */
|
|
133
|
+
async function organizationWithRows(slug: string): Promise<void> {
|
|
134
|
+
await organizations.create({
|
|
135
|
+
apiVersion: "tenancy.stigmer.ai/v1",
|
|
136
|
+
kind: "Organization",
|
|
137
|
+
metadata: { name: slug, slug, org: "" },
|
|
138
|
+
spec: { description: "created by the organization delete test" },
|
|
139
|
+
});
|
|
140
|
+
await platform.bootstrapPolicy(orgRole(OUTSIDER, "member", slug));
|
|
141
|
+
await platform.bootstrapPolicy(
|
|
142
|
+
triple({ kind: "organization", id: slug }, "organization", {
|
|
143
|
+
kind: "agent",
|
|
144
|
+
id: AGENT,
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeAll(async () => {
|
|
150
|
+
dir = mkdtempSync(path.join(tmpdir(), "organization-delete-test-"));
|
|
151
|
+
setOperatorIdentity(OPERATOR_EMAIL, "The Operator");
|
|
152
|
+
server = await composeServer({
|
|
153
|
+
config: loadConfig({
|
|
154
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
155
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:1",
|
|
156
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
157
|
+
STORAGE_PATH: path.join(dir, "storage"),
|
|
158
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
159
|
+
STIGMER_OPERATOR_EMAIL: OPERATOR_EMAIL,
|
|
160
|
+
}),
|
|
161
|
+
logger: createLogger({ level: "error", pretty: false, write: () => {} }),
|
|
162
|
+
extensions: [unit],
|
|
163
|
+
portOverride: 0,
|
|
164
|
+
host: "127.0.0.1",
|
|
165
|
+
});
|
|
166
|
+
const port = await server.start();
|
|
167
|
+
transport = createGrpcTransport({ baseUrl: `http://127.0.0.1:${port}` });
|
|
168
|
+
organizations = createClient(OrganizationCommandController, transport);
|
|
169
|
+
organizationQuery = createClient(OrganizationQueryController, transport);
|
|
170
|
+
platform = createClient(
|
|
171
|
+
IamPolicyCommandController,
|
|
172
|
+
server.inProcessTransport,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
afterAll(async () => {
|
|
177
|
+
await server.shutdown();
|
|
178
|
+
resetOperatorIdentityForTests();
|
|
179
|
+
rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
faultingDelete = undefined;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("runs the slot with the organization and its rows in place, then removes every row naming it", async () => {
|
|
187
|
+
await organizationWithRows("delete-clean");
|
|
188
|
+
expect(rowsNaming("delete-clean")).toHaveLength(3);
|
|
189
|
+
|
|
190
|
+
await organizations.delete({ value: "delete-clean" });
|
|
191
|
+
|
|
192
|
+
expect(seenBySlot.get("delete-clean")).toEqual({
|
|
193
|
+
loaded: "delete-clean",
|
|
194
|
+
rowsNaming: 3,
|
|
195
|
+
});
|
|
196
|
+
expect(rowsNaming("delete-clean")).toEqual([]);
|
|
197
|
+
const gone = await grpcError(() =>
|
|
198
|
+
organizationQuery.get({ value: "delete-clean" }),
|
|
199
|
+
);
|
|
200
|
+
expect(gone.code).toBe(Code.NotFound);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("a slot step's refusal answers its own copy and leaves the organization and its rows", async () => {
|
|
204
|
+
await organizationWithRows(REFUSED_SLUG);
|
|
205
|
+
|
|
206
|
+
const refused = await grpcError(() =>
|
|
207
|
+
organizations.delete({ value: REFUSED_SLUG }),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(refused.code).toBe(Code.FailedPrecondition);
|
|
211
|
+
expect(refused.rawMessage).toBe(
|
|
212
|
+
"fake edition keeps something for this organization",
|
|
213
|
+
);
|
|
214
|
+
expect(
|
|
215
|
+
(await organizationQuery.get({ value: REFUSED_SLUG })).metadata?.id,
|
|
216
|
+
).toBe(REFUSED_SLUG);
|
|
217
|
+
expect(rowsNaming(REFUSED_SLUG)).toHaveLength(3);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("a revocation fault fails the delete with the organization and its owner in place, and the retry completes it", async () => {
|
|
221
|
+
await organizationWithRows("delete-faults");
|
|
222
|
+
const member = rowsNaming("delete-faults").find(
|
|
223
|
+
(row) => row.spec?.relation === "member",
|
|
224
|
+
);
|
|
225
|
+
faultingDelete = member?.metadata?.id;
|
|
226
|
+
|
|
227
|
+
const failed = await grpcError(() =>
|
|
228
|
+
organizations.delete({ value: "delete-faults" }),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(failed.code).toBe(Code.Internal);
|
|
232
|
+
expect(failed.rawMessage).toBe(
|
|
233
|
+
"failed to remove the organization's access policies",
|
|
234
|
+
);
|
|
235
|
+
expect(
|
|
236
|
+
(await organizationQuery.get({ value: "delete-faults" })).metadata?.id,
|
|
237
|
+
).toBe("delete-faults");
|
|
238
|
+
// The agent's scope link went first; the revocation stopped at the
|
|
239
|
+
// member row, before the owner row the retry needs.
|
|
240
|
+
expect(
|
|
241
|
+
rowsNaming("delete-faults")
|
|
242
|
+
.map((row) => row.spec?.relation)
|
|
243
|
+
.sort(),
|
|
244
|
+
).toEqual(["member", "owner"]);
|
|
245
|
+
|
|
246
|
+
faultingDelete = undefined;
|
|
247
|
+
await organizations.delete({ value: "delete-faults" });
|
|
248
|
+
expect(rowsNaming("delete-faults")).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("a slug freed by the delete and created again carries none of the old organization's rows", async () => {
|
|
252
|
+
await organizationWithRows("delete-reborn");
|
|
253
|
+
await organizations.delete({ value: "delete-reborn" });
|
|
254
|
+
|
|
255
|
+
await organizations.create({
|
|
256
|
+
apiVersion: "tenancy.stigmer.ai/v1",
|
|
257
|
+
kind: "Organization",
|
|
258
|
+
metadata: { name: "delete-reborn", slug: "delete-reborn", org: "" },
|
|
259
|
+
spec: { description: "the slug, taken again" },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(
|
|
263
|
+
rowsNaming("delete-reborn").map((row) => row.spec?.relation),
|
|
264
|
+
).toEqual(["owner"]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -54,6 +54,7 @@ import type { ResolvedGateSteps } from "../../extensions/gate-slots.js";
|
|
|
54
54
|
import { stepsForSlot } from "../../extensions/gate-slots.js";
|
|
55
55
|
import { ALL_ORGANIZATIONS } from "../../extensions/organization-directory.js";
|
|
56
56
|
import type { OrganizationDirectory } from "../../extensions/organization-directory.js";
|
|
57
|
+
import type { IamPolicyGrantPath } from "../iampolicy/grant-path.js";
|
|
57
58
|
import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
|
|
58
59
|
import { apiResourceKindKey } from "../../pipeline/interceptors/apiresource.js";
|
|
59
60
|
import {
|
|
@@ -105,15 +106,21 @@ import { newValidateVisibilityStep } from "../../pipeline/steps/validate-visibil
|
|
|
105
106
|
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
106
107
|
import type { Store } from "../../store/interface.js";
|
|
107
108
|
import { organizationSearchExtractor } from "./search-extractor.js";
|
|
108
|
-
import {
|
|
109
|
+
import {
|
|
110
|
+
newCheckOrgDuplicateStep,
|
|
111
|
+
newCopySlugToIdStep,
|
|
112
|
+
newRevokeOrganizationPoliciesStep,
|
|
113
|
+
} from "./steps.js";
|
|
109
114
|
|
|
110
115
|
export interface OrganizationControllerDeps {
|
|
111
116
|
readonly store: Store;
|
|
112
117
|
readonly logger: Logger;
|
|
113
118
|
/** The composed authorization seam — the Authorize step at position 1 of every chain calls it (O2, DD-007 §3). */
|
|
114
119
|
readonly authorizer: Authorizer;
|
|
115
|
-
/** The composed slot registrations — this domain's
|
|
120
|
+
/** The composed slot registrations — this domain's create and delete slots (O4). */
|
|
116
121
|
readonly gateSteps: ResolvedGateSteps;
|
|
122
|
+
/** The one grant path: the delete revokes the organization's rows through it before its row goes. */
|
|
123
|
+
readonly grantPath: IamPolicyGrantPath;
|
|
117
124
|
/** The composed tuple-lifecycle driver — undefined = the shared steps no-op (C2). */
|
|
118
125
|
readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
|
|
119
126
|
/** The composed query directory — undefined = OSS single-tenant behavior (C2). */
|
|
@@ -319,22 +326,38 @@ async function apply(
|
|
|
319
326
|
: update(deps, withResolvedApplyId(OrganizationSchema, org, reqCtx), ctx);
|
|
320
327
|
}
|
|
321
328
|
|
|
322
|
-
/**
|
|
329
|
+
/**
|
|
330
|
+
* Delete — returns the deleted organization (gRPC audit-trail convention).
|
|
331
|
+
*
|
|
332
|
+
* Everything that names the organization goes before its row, and a fault
|
|
333
|
+
* stops the delete with the organization intact: an organization's id is
|
|
334
|
+
* its slug, the delete frees the slug for anyone, and whatever outlived
|
|
335
|
+
* the row would belong to the slug's next holder. So, after the load:
|
|
336
|
+
*
|
|
337
|
+
* 1. the `org-delete:pre-delete` slot, where an edition refuses or
|
|
338
|
+
* removes the rows it keeps for the organization (empty in OSS);
|
|
339
|
+
* 2. RevokeOrganizationPolicies, every policy row naming the
|
|
340
|
+
* organization, through the grant path, never caught;
|
|
341
|
+
* 3. the row;
|
|
342
|
+
* 4. CleanupIamPolicies, the lifecycle's post-delete event: best-effort
|
|
343
|
+
* like every delete chain's, it revokes whatever a concurrent write
|
|
344
|
+
* named the organization with after step 2, and runs a composed
|
|
345
|
+
* driver's post-delete companions;
|
|
346
|
+
* 5. the search entry.
|
|
347
|
+
*/
|
|
323
348
|
async function deleteOrganization(
|
|
324
349
|
deps: OrganizationControllerDeps,
|
|
325
350
|
orgId: OrganizationId,
|
|
326
351
|
ctx: HandlerContext,
|
|
327
352
|
): Promise<Organization> {
|
|
353
|
+
type DeleteInput = typeof OrganizationCommandController.method.delete.input;
|
|
328
354
|
const reqCtx = new RequestContext(
|
|
329
355
|
OrganizationCommandController.method.delete.input,
|
|
330
356
|
orgId,
|
|
331
357
|
callerIdentityOf(ctx),
|
|
332
358
|
kindOf(ctx),
|
|
333
359
|
);
|
|
334
|
-
|
|
335
|
-
"organization-delete",
|
|
336
|
-
deps.logger,
|
|
337
|
-
)
|
|
360
|
+
const builder = newPipeline<DeleteInput>("organization-delete", deps.logger)
|
|
338
361
|
.addStep(
|
|
339
362
|
newAuthorizeStep(
|
|
340
363
|
OrganizationCommandController.method.delete,
|
|
@@ -343,7 +366,16 @@ async function deleteOrganization(
|
|
|
343
366
|
)
|
|
344
367
|
.addStep(newValidateProtoStep())
|
|
345
368
|
.addStep(newExtractResourceIdStep())
|
|
346
|
-
.addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema))
|
|
369
|
+
.addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema));
|
|
370
|
+
// The pre-delete gate slot (see the doc comment above). Empty in OSS.
|
|
371
|
+
for (const step of stepsForSlot<DeleteInput>(
|
|
372
|
+
deps.gateSteps,
|
|
373
|
+
"org-delete:pre-delete",
|
|
374
|
+
)) {
|
|
375
|
+
builder.addStep(step);
|
|
376
|
+
}
|
|
377
|
+
await builder
|
|
378
|
+
.addStep(newRevokeOrganizationPoliciesStep<DeleteInput>(deps.grantPath))
|
|
347
379
|
.addStep(newDeleteResourceStep(deps.store))
|
|
348
380
|
.addStep(
|
|
349
381
|
newCleanupIamPoliciesStep(deps.authorizationLifecycle, deps.logger),
|
|
@@ -10,16 +10,28 @@
|
|
|
10
10
|
* uniqueness guarantee, mirroring cloud's OrganizationCreateHandler
|
|
11
11
|
* (CheckDuplicate + CopySlugToId) step-for-step.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* The same deviation shapes the delete: a freed slug is anyone's to take,
|
|
14
|
+
* so newRevokeOrganizationPoliciesStep revokes the organization's policy
|
|
15
|
+
* rows BEFORE its row is deleted, and fails the delete when it cannot.
|
|
16
|
+
*
|
|
17
|
+
* Proven by organization.conformance.test.ts (CONFORMANCE_TARGET=local),
|
|
18
|
+
* __tests__/organization.test.ts and __tests__/organization-delete.test.ts.
|
|
15
19
|
*/
|
|
20
|
+
import { create } from "@bufbuild/protobuf";
|
|
21
|
+
import type { DescMessage } from "@bufbuild/protobuf";
|
|
22
|
+
|
|
16
23
|
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
17
24
|
import type { Store } from "../../store/interface.js";
|
|
18
25
|
import { alreadyExistsError, internalError } from "../../pipeline/errors.js";
|
|
19
26
|
import type { PipelineStep } from "../../pipeline/pipeline.js";
|
|
20
27
|
import type { RequestContext } from "../../pipeline/request-context.js";
|
|
28
|
+
import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
|
|
21
29
|
import { metadataOf } from "../../pipeline/steps/shapes.js";
|
|
30
|
+
import type { IamPolicyGrantPath } from "../iampolicy/grant-path.js";
|
|
22
31
|
|
|
32
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
33
|
+
import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
|
|
34
|
+
import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
23
35
|
import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
|
|
24
36
|
|
|
25
37
|
/**
|
|
@@ -98,3 +110,55 @@ export function newCopySlugToIdStep(): PipelineStep<typeof OrganizationSchema> {
|
|
|
98
110
|
},
|
|
99
111
|
};
|
|
100
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Revokes every policy row that names the organization, as principal or as
|
|
116
|
+
* resource, before the delete removes its row: the rows its members hold
|
|
117
|
+
* on it, and the scope link every organization-scoped resource holds to
|
|
118
|
+
* it. Runs after the `org-delete:pre-delete` slot and before
|
|
119
|
+
* DeleteResource.
|
|
120
|
+
*
|
|
121
|
+
* The generic delete chains clean up after the row and log a fault,
|
|
122
|
+
* because a deleted resource's rows grant nothing once it is gone. An
|
|
123
|
+
* organization is the exception: its id is its slug, the delete frees the
|
|
124
|
+
* slug, and a row left behind would grant whoever creates the slug next.
|
|
125
|
+
* So this step does not catch. A fault fails the delete with the
|
|
126
|
+
* organization in place, and a retry resumes where the revocation stopped
|
|
127
|
+
* (the grant path revokes the organization's owners last, so the owner
|
|
128
|
+
* who retries still can). The grant path fires the composed driver's
|
|
129
|
+
* `onPolicyRevoked` before each row, so a composition's tuples go with
|
|
130
|
+
* their rows. The post-delete CleanupIamPolicies still runs: it catches
|
|
131
|
+
* whatever a concurrent write named the organization with in between.
|
|
132
|
+
*/
|
|
133
|
+
export function newRevokeOrganizationPoliciesStep<Desc extends DescMessage>(
|
|
134
|
+
grantPath: IamPolicyGrantPath,
|
|
135
|
+
): PipelineStep<Desc> {
|
|
136
|
+
return {
|
|
137
|
+
name: "RevokeOrganizationPolicies",
|
|
138
|
+
async execute(ctx: RequestContext<Desc>): Promise<void> {
|
|
139
|
+
const organization = ctx.get(EXISTING_RESOURCE_KEY) as
|
|
140
|
+
| Organization
|
|
141
|
+
| undefined;
|
|
142
|
+
const id = organization?.metadata?.id ?? "";
|
|
143
|
+
if (id === "") {
|
|
144
|
+
throw internalError(
|
|
145
|
+
new Error("organization delete reached RevokeOrganizationPolicies without its loaded row"),
|
|
146
|
+
"failed to remove the organization's access policies",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await grantPath.cleanupResource(
|
|
151
|
+
create(ApiResourceRefSchema, {
|
|
152
|
+
kind: ApiResourceKind[ApiResourceKind.organization],
|
|
153
|
+
id,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
throw internalError(
|
|
158
|
+
error,
|
|
159
|
+
"failed to remove the organization's access policies",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* executioncontext decrypt lane uses, bound to exactly the named
|
|
15
15
|
* execution id;
|
|
16
16
|
* - getRunnerBootstrapConfig echoes the configured Temporal coordinates
|
|
17
|
-
* with the token fields empty (minting a proxy token is cloud-only)
|
|
17
|
+
* with the token fields empty (minting a proxy token is cloud-only),
|
|
18
|
+
* and publishes the runner-bootstrap address rather than the one the
|
|
19
|
+
* server dials when the two differ (stigmer#1357);
|
|
18
20
|
* - getServerInfo reports the build stamp by default and the release a
|
|
19
21
|
* library consumer states through ComposeOptions.version otherwise
|
|
20
22
|
* (stigmer#1168: unbundled, the stamp is always "dev"), and a
|
|
@@ -278,11 +280,61 @@ describe("platform domain (composed with a stated version)", () => {
|
|
|
278
280
|
});
|
|
279
281
|
});
|
|
280
282
|
|
|
283
|
+
/**
|
|
284
|
+
* stigmer#1357 through the real stack: a server that dials Temporal by a
|
|
285
|
+
* name its runners cannot resolve publishes the address they can, from
|
|
286
|
+
* the environment through loadConfig and the composition root to the
|
|
287
|
+
* wire. The composed-server arm above is the default: with no override,
|
|
288
|
+
* the published address is the server's own.
|
|
289
|
+
*/
|
|
290
|
+
describe("platform domain (composed for runners off the server's network)", () => {
|
|
291
|
+
let server: ComposedServer;
|
|
292
|
+
let client: PlatformClient;
|
|
293
|
+
let dir: string;
|
|
294
|
+
|
|
295
|
+
beforeAll(async () => {
|
|
296
|
+
dir = mkdtempSync(
|
|
297
|
+
path.join(tmpdir(), "platform-domain-runner-address-test-"),
|
|
298
|
+
);
|
|
299
|
+
vi.stubEnv("STIGMER_ENCRYPTION_KEY", ENC_KEY.toString("base64"));
|
|
300
|
+
server = await composeServer({
|
|
301
|
+
config: loadConfig({
|
|
302
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
303
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
304
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
305
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:7777",
|
|
306
|
+
TEMPORAL_NAMESPACE: "conformance-ns",
|
|
307
|
+
STIGMER_RUNNER_BOOTSTRAP_TEMPORAL_ADDRESS: "temporal.example.test:7233",
|
|
308
|
+
}),
|
|
309
|
+
logger: silentLogger,
|
|
310
|
+
portOverride: 0,
|
|
311
|
+
host: "127.0.0.1",
|
|
312
|
+
});
|
|
313
|
+
const port = await server.start();
|
|
314
|
+
client = createClient(
|
|
315
|
+
PlatformQueryController,
|
|
316
|
+
createGrpcTransport({ baseUrl: `http://127.0.0.1:${port}` }),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterAll(async () => {
|
|
321
|
+
await server.shutdown();
|
|
322
|
+
rmSync(dir, { recursive: true, force: true });
|
|
323
|
+
vi.unstubAllEnvs();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("getRunnerBootstrapConfig publishes the runner-bootstrap address, with the server's namespace", async () => {
|
|
327
|
+
const config = await client.getRunnerBootstrapConfig({});
|
|
328
|
+
expect(config.temporalAddress).toBe("temporal.example.test:7233");
|
|
329
|
+
expect(config.temporalNamespace).toBe("conformance-ns");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
281
333
|
describe("platform domain (keyless runner-token service)", () => {
|
|
282
334
|
it("a keyless service answers not-minted — fail-soft, never an error", async () => {
|
|
283
335
|
const transport = createRouterTransport((router) => {
|
|
284
336
|
registerPlatformServices(router, {
|
|
285
|
-
|
|
337
|
+
runnerBootstrapTemporalAddress: "localhost:7233",
|
|
286
338
|
temporalNamespace: "default",
|
|
287
339
|
runnerAuthService: newExecutionScopedRunnerCredentialProvider(
|
|
288
340
|
RunnerAuthService.create(undefined),
|
|
@@ -318,7 +370,7 @@ describe("platform domain (license status)", () => {
|
|
|
318
370
|
function clientWith(provider: LicenseStatusProvider) {
|
|
319
371
|
const transport = createRouterTransport((router) => {
|
|
320
372
|
registerPlatformServices(router, {
|
|
321
|
-
|
|
373
|
+
runnerBootstrapTemporalAddress: "localhost:7233",
|
|
322
374
|
temporalNamespace: "default",
|
|
323
375
|
runnerAuthService: newExecutionScopedRunnerCredentialProvider(
|
|
324
376
|
RunnerAuthService.create(undefined),
|
|
@@ -78,12 +78,14 @@ import { TOKEN_TYPE_EXECUTION_SCOPED } from "../../runnerauth/runnerauth.js";
|
|
|
78
78
|
|
|
79
79
|
export interface PlatformControllerDeps {
|
|
80
80
|
/**
|
|
81
|
-
* Temporal coordinates
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* The Temporal coordinates published to embedded runners. The address
|
|
82
|
+
* is the one a runner should dial, which is not always the one this
|
|
83
|
+
* server dials: a clustered server reaches Temporal by an internal name
|
|
84
|
+
* its desktop runners cannot resolve (stigmer#1357). The composition
|
|
85
|
+
* root passes config.runnerBootstrapTemporalAddress, which defaults to
|
|
86
|
+
* the server's own address; the namespace is the server's.
|
|
85
87
|
*/
|
|
86
|
-
readonly
|
|
88
|
+
readonly runnerBootstrapTemporalAddress: string;
|
|
87
89
|
readonly temporalNamespace: string;
|
|
88
90
|
/**
|
|
89
91
|
* Mints the execution-scoped tokens getRunnerScopedToken hands to
|
|
@@ -202,7 +204,9 @@ async function getLicenseStatus(
|
|
|
202
204
|
|
|
203
205
|
/**
|
|
204
206
|
* Go GetRunnerBootstrapConfig: the Temporal coordinates an embedded runner
|
|
205
|
-
* should connect to so it can self-bootstrap from a token alone
|
|
207
|
+
* should connect to so it can self-bootstrap from a token alone: the
|
|
208
|
+
* address deps name for runners, never assumed to be the server's own
|
|
209
|
+
* (the proto's "the one reachable by the caller"). The
|
|
206
210
|
* RunnerAccessToken / TokenType / RunnerAccessTokenExpiresInSeconds fields
|
|
207
211
|
* are intentionally left empty in OSS: minting an iss=stigmer proxy token
|
|
208
212
|
* is a cloud-only capability (OSS has no Cursor BiDi proxy to authenticate
|
|
@@ -221,7 +225,7 @@ async function getRunnerBootstrapConfig(
|
|
|
221
225
|
ctx: HandlerContext,
|
|
222
226
|
): Promise<GetRunnerBootstrapConfigOutput> {
|
|
223
227
|
const output = create(GetRunnerBootstrapConfigOutputSchema, {
|
|
224
|
-
temporalAddress: deps.
|
|
228
|
+
temporalAddress: deps.runnerBootstrapTemporalAddress,
|
|
225
229
|
temporalNamespace: deps.temporalNamespace,
|
|
226
230
|
});
|
|
227
231
|
const bootstrapCredentials =
|
|
@@ -45,7 +45,12 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
|
|
|
45
45
|
|
|
46
46
|
import { createLogger } from "../../../boot/logger.js";
|
|
47
47
|
import { callerIdentityKey } from "../../../pipeline/interceptors/auth.js";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
errorOf,
|
|
50
|
+
failingStore,
|
|
51
|
+
testCallerIdentity,
|
|
52
|
+
untouchable,
|
|
53
|
+
} from "../../../pipeline/__tests__/support.js";
|
|
49
54
|
import { RequestContext } from "../../../pipeline/request-context.js";
|
|
50
55
|
import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
|
|
51
56
|
import { ResourceNotFoundError } from "../../../store/interface.js";
|
|
@@ -80,35 +85,10 @@ const WORKFLOW_ID = "wfl_storefault";
|
|
|
80
85
|
const EXECUTION_FAULT_COPY = "failed to load workflow execution";
|
|
81
86
|
const WORKFLOW_FAULT_COPY = "failed to load workflow";
|
|
82
87
|
|
|
83
|
-
/** A dependency the call must never reach once its load has failed. */
|
|
84
|
-
function untouchable<T extends object>(name: string): T {
|
|
85
|
-
return new Proxy({} as T, {
|
|
86
|
-
get(_target, prop) {
|
|
87
|
-
throw new Error(`${name}.${String(prop)} reached after a failed load`);
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A store whose every read fails with the given error. */
|
|
93
|
-
function failingStore(error: Error): Store {
|
|
94
|
-
return {
|
|
95
|
-
getResource: () => Promise.reject(error),
|
|
96
|
-
} as unknown as Store;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
88
|
const MISSING = (): Error =>
|
|
100
89
|
new ResourceNotFoundError(`workflow_execution/${EXECUTION_ID}`);
|
|
101
90
|
const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
|
|
102
91
|
|
|
103
|
-
async function errorOf(call: () => Promise<unknown>): Promise<ConnectError> {
|
|
104
|
-
const error = await call().then(
|
|
105
|
-
() => undefined,
|
|
106
|
-
(e: unknown) => e,
|
|
107
|
-
);
|
|
108
|
-
expect(error, "the call must fail").toBeInstanceOf(ConnectError);
|
|
109
|
-
return error as ConnectError;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
92
|
function handlerContext(): HandlerContext {
|
|
113
93
|
const values = createContextValues();
|
|
114
94
|
values.set(callerIdentityKey, testCallerIdentity());
|
|
@@ -491,6 +491,9 @@ describe("resolveExtensions — loud-fail throws (DD-006 §2b)", () => {
|
|
|
491
491
|
// The ninth: the organization create chain before Persist, where a
|
|
492
492
|
// limit on which organizations may exist refuses with nothing written.
|
|
493
493
|
"org-create:pre-side-effect-gate",
|
|
494
|
+
// The tenth: the organization delete chain before any write, where an
|
|
495
|
+
// edition removes or refuses what it keeps for the organization.
|
|
496
|
+
"org-delete:pre-delete",
|
|
494
497
|
"sandbox-acquisition:gate",
|
|
495
498
|
"session-create:pre-side-effect-gate",
|
|
496
499
|
]);
|
|
@@ -53,6 +53,19 @@
|
|
|
53
53
|
* limit belongs here too. `apply` delegates to create on its create arm,
|
|
54
54
|
* so the slot fires there as well.
|
|
55
55
|
*
|
|
56
|
+
* The tenth, `org-delete:pre-delete`: the organization delete chain after
|
|
57
|
+
* LoadExistingForDelete and before any write, so the organization exists
|
|
58
|
+
* and is loaded (EXISTING_RESOURCE_KEY) while its steps run. An
|
|
59
|
+
* organization's id is its slug and its delete frees the slug for anyone,
|
|
60
|
+
* so whatever an edition keeps for the organization must go before the row
|
|
61
|
+
* does, or be refused: a row left behind would belong to whoever creates
|
|
62
|
+
* the slug next. Its steps may refuse (Enterprise refuses while an
|
|
63
|
+
* identity provider still signs in platform-managed organizations) or
|
|
64
|
+
* remove the edition's own rows; either way a throw fails the delete with
|
|
65
|
+
* the organization intact, and the retry re-runs every step, so each owns
|
|
66
|
+
* its idempotency. After the slot the chain revokes the organization's own
|
|
67
|
+
* policy rows, also before the row and also failing closed.
|
|
68
|
+
*
|
|
56
69
|
* Two enforcement layers, deliberately redundant, both derived from the
|
|
57
70
|
* ONE literal tuple below (lockstep by construction):
|
|
58
71
|
* - GateSlotName (compile time): the union of declared slot literals.
|
|
@@ -92,6 +105,7 @@ export const GATE_SLOT_NAMES = [
|
|
|
92
105
|
"identity-account-provision:post-persist",
|
|
93
106
|
"iam-policy-create:pre-side-effect-gate",
|
|
94
107
|
"org-create:pre-side-effect-gate",
|
|
108
|
+
"org-delete:pre-delete",
|
|
95
109
|
] as const;
|
|
96
110
|
|
|
97
111
|
/** The declared slot-name union — a registration outside it fails tsc. */
|
|
@@ -44,11 +44,6 @@ const PENDING: ReadonlyMap<string, number> = new Map([
|
|
|
44
44
|
["domain/artifact/controller.ts", 1],
|
|
45
45
|
["domain/environment/controller.ts", 1],
|
|
46
46
|
["domain/environment/steps.ts", 1],
|
|
47
|
-
["domain/mcpserver/complete-oauth-connect.ts", 1],
|
|
48
|
-
["domain/mcpserver/connect.ts", 1],
|
|
49
|
-
["domain/mcpserver/controller.ts", 1],
|
|
50
|
-
["domain/mcpserver/initiate-oauth-connect.ts", 1],
|
|
51
|
-
["domain/mcpserver/start-connect.ts", 2],
|
|
52
47
|
["domain/plugin/controller.ts", 1],
|
|
53
48
|
["domain/skill/controller.ts", 1],
|
|
54
49
|
["domain/workflow/controller.ts", 1],
|