@stigmer/server 3.14.0-dev.20260910084630 → 3.14.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/dist/domain/agentexecution/constants.d.ts +16 -0
- package/dist/domain/agentexecution/constants.d.ts.map +1 -1
- package/dist/domain/agentexecution/constants.js +22 -0
- package/dist/domain/agentexecution/constants.js.map +1 -1
- package/dist/domain/agentexecution/controller.d.ts.map +1 -1
- package/dist/domain/agentexecution/controller.js +10 -3
- package/dist/domain/agentexecution/controller.js.map +1 -1
- package/dist/domain/agentexecution/run-target.d.ts +26 -0
- package/dist/domain/agentexecution/run-target.d.ts.map +1 -0
- package/dist/domain/agentexecution/run-target.js +31 -0
- package/dist/domain/agentexecution/run-target.js.map +1 -0
- package/dist/domain/session/constants.d.ts +15 -0
- package/dist/domain/session/constants.d.ts.map +1 -0
- package/dist/domain/session/constants.js +17 -0
- package/dist/domain/session/constants.js.map +1 -0
- package/dist/domain/session/controller.d.ts.map +1 -1
- package/dist/domain/session/controller.js +10 -0
- package/dist/domain/session/controller.js.map +1 -1
- package/dist/domain/session/run-target.d.ts +15 -0
- package/dist/domain/session/run-target.d.ts.map +1 -0
- package/dist/domain/session/run-target.js +14 -0
- package/dist/domain/session/run-target.js.map +1 -0
- package/dist/domain/workflowexecution/constants.d.ts +9 -0
- package/dist/domain/workflowexecution/constants.d.ts.map +1 -1
- package/dist/domain/workflowexecution/constants.js +13 -0
- package/dist/domain/workflowexecution/constants.js.map +1 -1
- package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
- package/dist/domain/workflowexecution/controller.js +8 -0
- package/dist/domain/workflowexecution/controller.js.map +1 -1
- package/dist/domain/workflowexecution/run-target.d.ts +18 -0
- package/dist/domain/workflowexecution/run-target.d.ts.map +1 -0
- package/dist/domain/workflowexecution/run-target.js +23 -0
- package/dist/domain/workflowexecution/run-target.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/steps/authorize-run-target.d.ts +105 -0
- package/dist/pipeline/steps/authorize-run-target.d.ts.map +1 -0
- package/dist/pipeline/steps/authorize-run-target.js +61 -0
- package/dist/pipeline/steps/authorize-run-target.js.map +1 -0
- package/dist/query/search/criteria.d.ts +10 -3
- package/dist/query/search/criteria.d.ts.map +1 -1
- package/dist/query/search/criteria.js +11 -4
- package/dist/query/search/criteria.js.map +1 -1
- package/package.json +4 -4
- package/src/domain/agentexecution/__tests__/run-gate.test.ts +245 -0
- package/src/domain/agentexecution/__tests__/run-target.test.ts +117 -0
- package/src/domain/agentexecution/constants.ts +25 -0
- package/src/domain/agentexecution/controller.ts +10 -3
- package/src/domain/agentexecution/run-target.ts +64 -0
- package/src/domain/session/__tests__/run-gate.test.ts +219 -0
- package/src/domain/session/__tests__/run-target.test.ts +43 -0
- package/src/domain/session/constants.ts +17 -0
- package/src/domain/session/controller.ts +10 -0
- package/src/domain/session/run-target.ts +30 -0
- package/src/domain/workflowexecution/__tests__/run-gate.test.ts +200 -0
- package/src/domain/workflowexecution/__tests__/run-target.test.ts +80 -0
- package/src/domain/workflowexecution/constants.ts +17 -0
- package/src/domain/workflowexecution/controller.ts +10 -0
- package/src/domain/workflowexecution/run-target.ts +47 -0
- package/src/extensions/__tests__/extension-composition.test.ts +49 -0
- package/src/index.ts +7 -0
- package/src/pipeline/__tests__/support.ts +34 -0
- package/src/pipeline/steps/__tests__/authorize-run-target.test.ts +261 -0
- package/src/pipeline/steps/authorize-run-target.ts +148 -0
- package/src/query/search/__tests__/criteria.test.ts +18 -1
- package/src/query/search/criteria.ts +11 -4
|
@@ -49,6 +49,7 @@ import { newPipeline } from "../../pipeline/pipeline.js";
|
|
|
49
49
|
import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
|
|
50
50
|
import { RequestContext } from "../../pipeline/request-context.js";
|
|
51
51
|
import { newAuthorizeStep } from "../../pipeline/steps/authorize.js";
|
|
52
|
+
import { newAuthorizeRunTargetStep } from "../../pipeline/steps/authorize-run-target.js";
|
|
52
53
|
import { newGuardReservedLabelsStep } from "../../pipeline/steps/guard-reserved-labels.js";
|
|
53
54
|
import { newBuildUpdateStateStep } from "../../pipeline/steps/build-update-state.js";
|
|
54
55
|
import { newBuildNewStateStep } from "../../pipeline/steps/defaults.js";
|
|
@@ -116,6 +117,7 @@ import { getEventLog } from "./get-event-log.js";
|
|
|
116
117
|
import { getExecutionSummary } from "./get-execution-summary.js";
|
|
117
118
|
import { listPendingApprovals } from "./list-pending-approvals.js";
|
|
118
119
|
import { loadAllWorkflowExecutions } from "./queries.js";
|
|
120
|
+
import { workflowExecutionRunTarget } from "./run-target.js";
|
|
119
121
|
import { workflowExecutionSearchExtractor } from "./search-extractor.js";
|
|
120
122
|
import type { SandboxLane } from "../../sandbox/lane.js";
|
|
121
123
|
import type { WorkflowSandboxTerminalObserver } from "../../sandbox/steps.js";
|
|
@@ -243,6 +245,11 @@ export function registerWorkflowExecutionServices(
|
|
|
243
245
|
* Create — create.go buildCreatePipeline, step-for-step (the numbered
|
|
244
246
|
* 15-step chain): validation (proto → visibility → slug →
|
|
245
247
|
* workflow-or-instance presence, the #196 InvalidArgument contrast) →
|
|
248
|
+
* the run gate (AuthorizeRunTarget, P1 sp.run-gate: the first step after
|
|
249
|
+
* the target reference is guaranteed, asking workflow_instance#can_execute
|
|
250
|
+
* or workflow#can_execute by request shape — before the engine gate so a
|
|
251
|
+
* denied caller learns nothing about engine state, and before the workflow
|
|
252
|
+
* lookup, so a denial reads nothing) →
|
|
246
253
|
* the ENGINE GATE at its pinned position (before every side effect — a
|
|
247
254
|
* down engine orphans nothing; Go's unit test pins that it precedes even
|
|
248
255
|
* the workflow lookup) → default-instance resolution → the standard
|
|
@@ -276,6 +283,9 @@ async function createExecution(
|
|
|
276
283
|
.addStep(newValidateVisibilityStep())
|
|
277
284
|
.addStep(newResolveSlugStep())
|
|
278
285
|
.addStep(newValidateWorkflowOrInstanceStep())
|
|
286
|
+
.addStep(
|
|
287
|
+
newAuthorizeRunTargetStep(deps.authorizer, workflowExecutionRunTarget),
|
|
288
|
+
)
|
|
279
289
|
.addStep(newEnsureEngineAvailableStep(deps.engineState))
|
|
280
290
|
.addStep(
|
|
281
291
|
newCreateDefaultInstanceIfNeededStep({
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow-execution run-target resolver (P1 sp.run-gate): the chain's
|
|
3
|
+
* own precedence — an explicit workflow_instance_id fully names the target
|
|
4
|
+
* (CreateDefaultInstanceIfNeeded returns early on it):
|
|
5
|
+
* workflow_instance#can_execute; else workflow_id names the blueprint whose
|
|
6
|
+
* default instance the chain resolves (or mints) only AFTER the gate:
|
|
7
|
+
* workflow#can_execute (the model derives the default instance's viewers
|
|
8
|
+
* from the blueprint's, so asking the blueprint's question is what lets the
|
|
9
|
+
* gate precede the side effect).
|
|
10
|
+
*
|
|
11
|
+
* Neither set is ValidateWorkflowOrInstance's INVALID_ARGUMENT, thrown
|
|
12
|
+
* before this resolver runs — never a check. Pure over the record being
|
|
13
|
+
* built.
|
|
14
|
+
*/
|
|
15
|
+
import type { WorkflowExecution } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
RUN_GATE_CHECKS,
|
|
19
|
+
type RunTarget,
|
|
20
|
+
} from "../../pipeline/steps/authorize-run-target.js";
|
|
21
|
+
import {
|
|
22
|
+
runWorkflowDeniedMessage,
|
|
23
|
+
runWorkflowInstanceDeniedMessage,
|
|
24
|
+
} from "./constants.js";
|
|
25
|
+
|
|
26
|
+
export function workflowExecutionRunTarget(
|
|
27
|
+
execution: WorkflowExecution,
|
|
28
|
+
): RunTarget | undefined {
|
|
29
|
+
const spec = execution.spec;
|
|
30
|
+
const workflowInstanceId = spec?.workflowInstanceId ?? "";
|
|
31
|
+
if (workflowInstanceId !== "") {
|
|
32
|
+
return {
|
|
33
|
+
...RUN_GATE_CHECKS.workflowInstance,
|
|
34
|
+
resourceId: workflowInstanceId,
|
|
35
|
+
deniedMessage: runWorkflowInstanceDeniedMessage(workflowInstanceId),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const workflowId = spec?.workflowId ?? "";
|
|
39
|
+
if (workflowId !== "") {
|
|
40
|
+
return {
|
|
41
|
+
...RUN_GATE_CHECKS.workflow,
|
|
42
|
+
resourceId: workflowId,
|
|
43
|
+
deniedMessage: runWorkflowDeniedMessage(workflowId),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -178,6 +178,55 @@ describe("extension composition (composed server)", () => {
|
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The third edition on the wire (editions program, sp.edition-contract):
|
|
183
|
+
* a unit declaring ServerEdition.enterprise is answered verbatim by
|
|
184
|
+
* getServerInfo. The registry is generic over concrete editions, so this
|
|
185
|
+
* pins the end-to-end path (declaration → resolved registry → platform
|
|
186
|
+
* controller → wire) for the value no composition emits yet — the day
|
|
187
|
+
* P4's Enterprise composition declares it, the wire is already proven.
|
|
188
|
+
*/
|
|
189
|
+
describe("extension composition (enterprise edition on the wire)", () => {
|
|
190
|
+
let server: ComposedServer;
|
|
191
|
+
let dir: string;
|
|
192
|
+
let portTransport: Transport;
|
|
193
|
+
|
|
194
|
+
const enterpriseUnit: ServerExtension = {
|
|
195
|
+
name: "fake-enterprise",
|
|
196
|
+
edition: ServerEdition.enterprise,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
beforeAll(async () => {
|
|
200
|
+
dir = mkdtempSync(path.join(tmpdir(), "extension-composition-ee-test-"));
|
|
201
|
+
server = await composeServer({
|
|
202
|
+
config: loadConfig({
|
|
203
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
204
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
205
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
206
|
+
}),
|
|
207
|
+
logger: createLogger({ level: "warn", pretty: false, write: () => {} }),
|
|
208
|
+
extensions: [enterpriseUnit],
|
|
209
|
+
portOverride: 0,
|
|
210
|
+
host: "127.0.0.1",
|
|
211
|
+
});
|
|
212
|
+
const port = await server.start();
|
|
213
|
+
portTransport = createGrpcTransport({
|
|
214
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterAll(async () => {
|
|
219
|
+
await server.shutdown();
|
|
220
|
+
rmSync(dir, { recursive: true, force: true });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("answers enterprise on getServerInfo", async () => {
|
|
224
|
+
const client = createClient(PlatformQueryController, portTransport);
|
|
225
|
+
const info = await client.getServerInfo({});
|
|
226
|
+
expect(info.edition).toBe(ServerEdition.enterprise);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
181
230
|
/**
|
|
182
231
|
* The caller-guard arm (entry 20260902.02 ruling Q1): a composed guard
|
|
183
232
|
* is enforced on the SERVING chain and structurally absent from the
|
package/src/index.ts
CHANGED
|
@@ -194,6 +194,13 @@ export {
|
|
|
194
194
|
} from "./pipeline/interceptors/auth.js";
|
|
195
195
|
export { newPipeline } from "./pipeline/pipeline.js";
|
|
196
196
|
export { newAuthorizeStep } from "./pipeline/steps/authorize.js";
|
|
197
|
+
// The run gate's check set (P1 sp.run-gate): an edition's Authorizer keys
|
|
198
|
+
// its lane admission on this predicate — the runtime lanes it mints (a
|
|
199
|
+
// guest share, a channel, a schedule fire, a workflow sandbox) were
|
|
200
|
+
// admitted upstream by their own gate steps and are not re-asked here.
|
|
201
|
+
// OSS defines the gate, so OSS defines which checks are the gate; a
|
|
202
|
+
// composition never re-derives the set.
|
|
203
|
+
export { isRunGateCheck } from "./pipeline/steps/authorize-run-target.js";
|
|
197
204
|
export { newValidateProtoStep } from "./pipeline/steps/validation.js";
|
|
198
205
|
|
|
199
206
|
// The driver interfaces and the store-fault classes the ratified mapping
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* site in production code. Overrides let adversarial tests pin specific
|
|
6
6
|
* caller classes.
|
|
7
7
|
*/
|
|
8
|
+
import type {
|
|
9
|
+
Authorizer,
|
|
10
|
+
AuthzCheck,
|
|
11
|
+
AuthzDecision,
|
|
12
|
+
} from "../../extensions/authorizer.js";
|
|
8
13
|
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
14
|
+
import { isRunGateCheck } from "../steps/authorize-run-target.js";
|
|
9
15
|
|
|
10
16
|
export function testCallerIdentity(
|
|
11
17
|
overrides: Partial<CallerIdentity> = {},
|
|
@@ -18,3 +24,31 @@ export function testCallerIdentity(
|
|
|
18
24
|
...overrides,
|
|
19
25
|
};
|
|
20
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An Authorizer that answers `decide()` to run-gate checks ONLY and allows
|
|
30
|
+
* everything else (the position-1 organization checks, the reserved-label
|
|
31
|
+
* guard's operator check), recording the run-gate checks it saw. Composed
|
|
32
|
+
* into a test server as an extension unit, it isolates the AuthorizeRunTarget
|
|
33
|
+
* splice from every other authorization site on the chain: a refusal it
|
|
34
|
+
* produces can only have come from the run gate. `decide` is read per
|
|
35
|
+
* check so one composed server can pin the allow and the deny arms.
|
|
36
|
+
*/
|
|
37
|
+
export function runGateOnlyAuthorizer(decide: () => AuthzDecision): {
|
|
38
|
+
authorizer: Authorizer;
|
|
39
|
+
runGateChecks: AuthzCheck[];
|
|
40
|
+
} {
|
|
41
|
+
const runGateChecks: AuthzCheck[] = [];
|
|
42
|
+
return {
|
|
43
|
+
runGateChecks,
|
|
44
|
+
authorizer: {
|
|
45
|
+
authorize(_caller, check) {
|
|
46
|
+
if (!isRunGateCheck(check)) {
|
|
47
|
+
return Promise.resolve({ kind: "allow" });
|
|
48
|
+
}
|
|
49
|
+
runGateChecks.push(check);
|
|
50
|
+
return Promise.resolve(decide());
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins the AuthorizeRunTarget step's contract (P1 sp.run-gate, 2026-09-11):
|
|
3
|
+
* the step is a thin shell around authorizeResolvedResource — a resolver
|
|
4
|
+
* that answers no target makes NO check (the chain's own invariant guards
|
|
5
|
+
* own the shape-less arm); a resolved target reaches the Authorizer
|
|
6
|
+
* verbatim; deny carries the RESOLVER's copy; not-found answers the
|
|
7
|
+
* load-first NOT_FOUND; unavailable is INTERNAL; the internal caller class
|
|
8
|
+
* skips. Also pins isRunGateCheck: exactly the five (kind, permission)
|
|
9
|
+
* pairs the resolvers can produce, and nothing adjacent — the cloud's
|
|
10
|
+
* lane-admission decorator keys on this predicate, so a drift here would
|
|
11
|
+
* either deny a schedule fire or admit a lane to a check it never owned.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { create } from "@bufbuild/protobuf";
|
|
15
|
+
import { Code, ConnectError } from "@connectrpc/connect";
|
|
16
|
+
|
|
17
|
+
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
18
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
19
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
Authorizer,
|
|
23
|
+
AuthzCheck,
|
|
24
|
+
AuthzDecision,
|
|
25
|
+
} from "../../../extensions/authorizer.js";
|
|
26
|
+
import { testCallerIdentity } from "../../__tests__/support.js";
|
|
27
|
+
import { RequestContext } from "../../request-context.js";
|
|
28
|
+
import { AUTHORIZATION_UNAVAILABLE_MESSAGE } from "../authorize.js";
|
|
29
|
+
import {
|
|
30
|
+
RUN_GATE_CHECKS,
|
|
31
|
+
isRunGateCheck,
|
|
32
|
+
newAuthorizeRunTargetStep,
|
|
33
|
+
} from "../authorize-run-target.js";
|
|
34
|
+
import type { RunTarget } from "../authorize-run-target.js";
|
|
35
|
+
|
|
36
|
+
function fakeAuthorizer(decision: AuthzDecision): {
|
|
37
|
+
authorizer: Authorizer;
|
|
38
|
+
checks: AuthzCheck[];
|
|
39
|
+
} {
|
|
40
|
+
const checks: AuthzCheck[] = [];
|
|
41
|
+
return {
|
|
42
|
+
checks,
|
|
43
|
+
authorizer: {
|
|
44
|
+
authorize(_caller, check) {
|
|
45
|
+
checks.push(check);
|
|
46
|
+
return Promise.resolve(decision);
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function captureError(
|
|
53
|
+
run: () => void | Promise<void>,
|
|
54
|
+
): Promise<ConnectError> {
|
|
55
|
+
try {
|
|
56
|
+
await run();
|
|
57
|
+
} catch (error) {
|
|
58
|
+
return ConnectError.from(error);
|
|
59
|
+
}
|
|
60
|
+
throw new Error("expected the step to reject");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const TARGET: RunTarget = {
|
|
64
|
+
...RUN_GATE_CHECKS.agentInstance,
|
|
65
|
+
resourceId: "agi_01target",
|
|
66
|
+
deniedMessage: "unauthorized to run agent instance 'agi_01target'",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function sessionCtx(callerClass?: string) {
|
|
70
|
+
return new RequestContext(
|
|
71
|
+
SessionSchema,
|
|
72
|
+
create(SessionSchema, { metadata: { name: "s", org: "acme" } }),
|
|
73
|
+
callerClass === undefined
|
|
74
|
+
? testCallerIdentity()
|
|
75
|
+
: testCallerIdentity({ callerClass }),
|
|
76
|
+
ApiResourceKind.session,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("AuthorizeRunTarget — the step shell", () => {
|
|
81
|
+
it("is named AuthorizeRunTarget in every chain (shared vocabulary)", () => {
|
|
82
|
+
const { authorizer } = fakeAuthorizer({ kind: "allow" });
|
|
83
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
84
|
+
authorizer,
|
|
85
|
+
() => TARGET,
|
|
86
|
+
);
|
|
87
|
+
expect(step.name).toBe("AuthorizeRunTarget");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("makes NO check when the resolver answers no target", async () => {
|
|
91
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "deny", reason: "" });
|
|
92
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
93
|
+
authorizer,
|
|
94
|
+
() => undefined,
|
|
95
|
+
);
|
|
96
|
+
await step.execute(sessionCtx());
|
|
97
|
+
expect(checks).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("hands the resolved target to the Authorizer verbatim and proceeds on allow", async () => {
|
|
101
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
102
|
+
const seen: unknown[] = [];
|
|
103
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
104
|
+
authorizer,
|
|
105
|
+
(record) => {
|
|
106
|
+
seen.push(record);
|
|
107
|
+
return TARGET;
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
const ctx = sessionCtx();
|
|
111
|
+
await step.execute(ctx);
|
|
112
|
+
expect(checks).toEqual([
|
|
113
|
+
{
|
|
114
|
+
permission: IamPermission.can_execute,
|
|
115
|
+
resourceKind: ApiResourceKind.agent_instance,
|
|
116
|
+
resourceId: "agi_01target",
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
// The resolver reads the record being BUILT (newState), where the
|
|
120
|
+
// chain's own resolution steps write, never the immutable input.
|
|
121
|
+
expect(seen).toEqual([ctx.newState]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("deny → PERMISSION_DENIED carrying the resolver's copy", async () => {
|
|
125
|
+
const { authorizer } = fakeAuthorizer({
|
|
126
|
+
kind: "deny",
|
|
127
|
+
reason: "fga said no",
|
|
128
|
+
});
|
|
129
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
130
|
+
authorizer,
|
|
131
|
+
() => TARGET,
|
|
132
|
+
);
|
|
133
|
+
const err = await captureError(() => step.execute(sessionCtx()));
|
|
134
|
+
expect(err.code).toBe(Code.PermissionDenied);
|
|
135
|
+
expect(err.rawMessage).toBe(TARGET.deniedMessage);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("not-found → the load-first chain's NOT_FOUND naming the target", async () => {
|
|
139
|
+
const { authorizer } = fakeAuthorizer({ kind: "not-found" });
|
|
140
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
141
|
+
authorizer,
|
|
142
|
+
() => TARGET,
|
|
143
|
+
);
|
|
144
|
+
const err = await captureError(() => step.execute(sessionCtx()));
|
|
145
|
+
expect(err.code).toBe(Code.NotFound);
|
|
146
|
+
expect(err.rawMessage).toContain("agi_01target");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("unavailable → INTERNAL, never a softened denial", async () => {
|
|
150
|
+
const { authorizer } = fakeAuthorizer({
|
|
151
|
+
kind: "unavailable",
|
|
152
|
+
cause: new Error("fga down"),
|
|
153
|
+
});
|
|
154
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
155
|
+
authorizer,
|
|
156
|
+
() => TARGET,
|
|
157
|
+
);
|
|
158
|
+
const err = await captureError(() => step.execute(sessionCtx()));
|
|
159
|
+
expect(err.code).toBe(Code.Internal);
|
|
160
|
+
expect(err.rawMessage).toBe(AUTHORIZATION_UNAVAILABLE_MESSAGE);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("the internal caller class skips (the server acting as itself)", async () => {
|
|
164
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "deny", reason: "" });
|
|
165
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
166
|
+
authorizer,
|
|
167
|
+
() => TARGET,
|
|
168
|
+
);
|
|
169
|
+
await step.execute(sessionCtx("internal"));
|
|
170
|
+
expect(checks).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("every other caller class is checked — a lane is admitted by its Authorizer, never by this step", async () => {
|
|
174
|
+
for (const callerClass of [
|
|
175
|
+
"user",
|
|
176
|
+
"machine",
|
|
177
|
+
"runner",
|
|
178
|
+
"guest",
|
|
179
|
+
"schedule",
|
|
180
|
+
"workflow_sandbox",
|
|
181
|
+
]) {
|
|
182
|
+
const { authorizer, checks } = fakeAuthorizer({ kind: "allow" });
|
|
183
|
+
const step = newAuthorizeRunTargetStep<typeof SessionSchema>(
|
|
184
|
+
authorizer,
|
|
185
|
+
() => TARGET,
|
|
186
|
+
);
|
|
187
|
+
await step.execute(sessionCtx(callerClass));
|
|
188
|
+
expect(checks, callerClass).toHaveLength(1);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("isRunGateCheck — the one definition of the run-gate check set", () => {
|
|
194
|
+
const pairs: ReadonlyArray<[string, ApiResourceKind, IamPermission]> = [
|
|
195
|
+
["agent", ApiResourceKind.agent, IamPermission.can_execute],
|
|
196
|
+
[
|
|
197
|
+
"agent_instance",
|
|
198
|
+
ApiResourceKind.agent_instance,
|
|
199
|
+
IamPermission.can_execute,
|
|
200
|
+
],
|
|
201
|
+
["session", ApiResourceKind.session, IamPermission.can_create_execution_in],
|
|
202
|
+
["workflow", ApiResourceKind.workflow, IamPermission.can_execute],
|
|
203
|
+
[
|
|
204
|
+
"workflow_instance",
|
|
205
|
+
ApiResourceKind.workflow_instance,
|
|
206
|
+
IamPermission.can_execute,
|
|
207
|
+
],
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
it.each(pairs)(
|
|
211
|
+
"%s is a run-gate check",
|
|
212
|
+
(_name, resourceKind, permission) => {
|
|
213
|
+
expect(
|
|
214
|
+
isRunGateCheck({ permission, resourceKind, resourceId: "x" }),
|
|
215
|
+
).toBe(true);
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
it("names exactly five checks, one per entry the resolvers draw from", () => {
|
|
220
|
+
expect(Object.keys(RUN_GATE_CHECKS).sort()).toEqual(
|
|
221
|
+
[
|
|
222
|
+
"agent",
|
|
223
|
+
"agentInstance",
|
|
224
|
+
"session",
|
|
225
|
+
"workflow",
|
|
226
|
+
"workflowInstance",
|
|
227
|
+
].sort(),
|
|
228
|
+
);
|
|
229
|
+
for (const [, resourceKind, permission] of pairs) {
|
|
230
|
+
expect(
|
|
231
|
+
Object.values(RUN_GATE_CHECKS).filter(
|
|
232
|
+
(c) => c.resourceKind === resourceKind && c.permission === permission,
|
|
233
|
+
),
|
|
234
|
+
).toHaveLength(1);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it.each([
|
|
239
|
+
["session#can_view", ApiResourceKind.session, IamPermission.can_view],
|
|
240
|
+
["agent#can_edit", ApiResourceKind.agent, IamPermission.can_edit],
|
|
241
|
+
["agent#can_view", ApiResourceKind.agent, IamPermission.can_view],
|
|
242
|
+
[
|
|
243
|
+
"agent_execution#can_execute",
|
|
244
|
+
ApiResourceKind.agent_execution,
|
|
245
|
+
IamPermission.can_execute,
|
|
246
|
+
],
|
|
247
|
+
[
|
|
248
|
+
"organization#can_create_session",
|
|
249
|
+
ApiResourceKind.organization,
|
|
250
|
+
IamPermission.can_create_session,
|
|
251
|
+
],
|
|
252
|
+
["session#can_execute", ApiResourceKind.session, IamPermission.can_execute],
|
|
253
|
+
] as const)(
|
|
254
|
+
"%s is NOT a run-gate check",
|
|
255
|
+
(_name, resourceKind, permission) => {
|
|
256
|
+
expect(
|
|
257
|
+
isRunGateCheck({ permission, resourceKind, resourceId: "x" }),
|
|
258
|
+
).toBe(false);
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AuthorizeRunTarget — the run gate (P1 sp.run-gate, 2026-09-11;
|
|
3
|
+
* stigmer-cloud#709): may this caller RUN what this record targets?
|
|
4
|
+
*
|
|
5
|
+
* Every create that starts or continues a run names a target — a blueprint
|
|
6
|
+
* (`agent`, `workflow`), an instance (`agent_instance`, `workflow_instance`)
|
|
7
|
+
* or a conversation (`session`) — and until this step nothing asked whether
|
|
8
|
+
* the caller may use it: session-create authorized only the organization's
|
|
9
|
+
* `can_create_session`, agent- and workflow-execution create are
|
|
10
|
+
* `is_skip_authorization`, and `can_execute` was defined on every blueprint
|
|
11
|
+
* and instance type of the FGA model and checked nowhere. The invariant the
|
|
12
|
+
* model states ("you can run what you can read") is enforced here.
|
|
13
|
+
*
|
|
14
|
+
* One shared step, one domain resolver: the shape of IndexSearch (a shared
|
|
15
|
+
* step over a domain extractor). The resolver is a PURE function of the
|
|
16
|
+
* record being built (`ctx.newState`, where the chain's own resolution
|
|
17
|
+
* steps write) that names the target and the domain's byte-pinned deny
|
|
18
|
+
* copy; this step hands it to authorizeResolvedResource — the ratified
|
|
19
|
+
* mid-chain resolved-id form (DD-007; the ListVersions and listByChannel
|
|
20
|
+
* precedent) for lanes the position-1 annotation cannot express. A
|
|
21
|
+
* resolver that answers no target makes NO check: the chain's own
|
|
22
|
+
* invariant guards (EnsureSessionOrAgentResolved,
|
|
23
|
+
* ValidateWorkflowOrInstance) own the shape-less arm, and this step never
|
|
24
|
+
* second-guesses them.
|
|
25
|
+
*
|
|
26
|
+
* Position, in every chain: immediately after the step that guarantees the
|
|
27
|
+
* target reference exists — before EnsureEngineAvailable (a denied caller
|
|
28
|
+
* learns nothing about engine state), before every gate slot, before every
|
|
29
|
+
* side effect the chain owns. The chains' own convention, Authorize before
|
|
30
|
+
* Validate, is what places it there.
|
|
31
|
+
*
|
|
32
|
+
* Admission has one owner per lane. This step asks for EVERY caller but the
|
|
33
|
+
* `internal` class (authorizeResolvedResource's contract): a direct
|
|
34
|
+
* principal is admitted by the Authorizer; a runtime lane (a guest share, a
|
|
35
|
+
* channel, a schedule fire, a workflow sandbox's child run) is admitted by
|
|
36
|
+
* the gate step that vouches for it, and the edition's Authorizer says so
|
|
37
|
+
* for the lanes it mints — the OSS chain stays policy-free. RUN_GATE_CHECKS
|
|
38
|
+
* is the one definition of "the run-gate checks" that such an Authorizer
|
|
39
|
+
* keys on (isRunGateCheck, exported on the barrel); the resolvers draw their
|
|
40
|
+
* (kind, permission) pairs from the same table, so the predicate cannot
|
|
41
|
+
* drift from what the step actually asks.
|
|
42
|
+
*/
|
|
43
|
+
import type { DescMessage, MessageShape } from "@bufbuild/protobuf";
|
|
44
|
+
|
|
45
|
+
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
46
|
+
import { IamPermission } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
47
|
+
|
|
48
|
+
import type { Authorizer, AuthzCheck } from "../../extensions/authorizer.js";
|
|
49
|
+
import type { PipelineStep } from "../pipeline.js";
|
|
50
|
+
import type { RequestContext } from "../request-context.js";
|
|
51
|
+
import { authorizeResolvedResource } from "./authorize.js";
|
|
52
|
+
|
|
53
|
+
/** One (kind, permission) pair the run gate asks about. */
|
|
54
|
+
export interface RunGateCheck {
|
|
55
|
+
readonly permission: IamPermission;
|
|
56
|
+
readonly resourceKind: ApiResourceKind;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The run-gate check set — exactly the pairs a resolver can produce. Each
|
|
61
|
+
* is the FGA model's own relation for "may run this": `can_execute` on the
|
|
62
|
+
* blueprints and instances (`agent.fga` `can_execute: viewer`;
|
|
63
|
+
* `agent_instance.fga` `can_execute: can_view`; the workflow twins), and
|
|
64
|
+
* `can_create_execution_in` on a session (`session.fga`: adding a turn to a
|
|
65
|
+
* conversation is the session's own permission, not the agent's).
|
|
66
|
+
*/
|
|
67
|
+
export const RUN_GATE_CHECKS = {
|
|
68
|
+
agent: {
|
|
69
|
+
permission: IamPermission.can_execute,
|
|
70
|
+
resourceKind: ApiResourceKind.agent,
|
|
71
|
+
},
|
|
72
|
+
agentInstance: {
|
|
73
|
+
permission: IamPermission.can_execute,
|
|
74
|
+
resourceKind: ApiResourceKind.agent_instance,
|
|
75
|
+
},
|
|
76
|
+
session: {
|
|
77
|
+
permission: IamPermission.can_create_execution_in,
|
|
78
|
+
resourceKind: ApiResourceKind.session,
|
|
79
|
+
},
|
|
80
|
+
workflow: {
|
|
81
|
+
permission: IamPermission.can_execute,
|
|
82
|
+
resourceKind: ApiResourceKind.workflow,
|
|
83
|
+
},
|
|
84
|
+
workflowInstance: {
|
|
85
|
+
permission: IamPermission.can_execute,
|
|
86
|
+
resourceKind: ApiResourceKind.workflow_instance,
|
|
87
|
+
},
|
|
88
|
+
} as const satisfies Record<string, RunGateCheck>;
|
|
89
|
+
|
|
90
|
+
const RUN_GATE_CHECK_LIST: ReadonlyArray<RunGateCheck> =
|
|
91
|
+
Object.values(RUN_GATE_CHECKS);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether `check` is one of the run gate's — the predicate an edition's
|
|
95
|
+
* Authorizer keys its lane admission on. Reads only the pair (an AuthzCheck
|
|
96
|
+
* is one); the resource id is the target's identity, not part of the
|
|
97
|
+
* question.
|
|
98
|
+
*/
|
|
99
|
+
export function isRunGateCheck(check: RunGateCheck | AuthzCheck): boolean {
|
|
100
|
+
return RUN_GATE_CHECK_LIST.some(
|
|
101
|
+
(candidate) =>
|
|
102
|
+
candidate.permission === check.permission &&
|
|
103
|
+
candidate.resourceKind === check.resourceKind,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A resolved run target: the check plus the domain's byte-pinned deny copy
|
|
109
|
+
* for it (the lane's error_msg, in the position-1 annotation's terms).
|
|
110
|
+
*/
|
|
111
|
+
export interface RunTarget extends RunGateCheck {
|
|
112
|
+
readonly resourceId: string;
|
|
113
|
+
readonly deniedMessage: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Names a record's run target, or `undefined` when the record names none
|
|
118
|
+
* (a state the chain's own guards refuse; never this step's arm). Pure over
|
|
119
|
+
* the record being built — no store, no clock, no caller.
|
|
120
|
+
*/
|
|
121
|
+
export type RunTargetResolver<Desc extends DescMessage> = (
|
|
122
|
+
record: MessageShape<Desc>,
|
|
123
|
+
) => RunTarget | undefined;
|
|
124
|
+
|
|
125
|
+
export function newAuthorizeRunTargetStep<Desc extends DescMessage>(
|
|
126
|
+
authorizer: Authorizer,
|
|
127
|
+
resolveRunTarget: RunTargetResolver<Desc>,
|
|
128
|
+
): PipelineStep<Desc> {
|
|
129
|
+
return {
|
|
130
|
+
name: "AuthorizeRunTarget",
|
|
131
|
+
async execute(ctx: RequestContext<Desc>): Promise<void> {
|
|
132
|
+
const target = resolveRunTarget(ctx.newState);
|
|
133
|
+
if (target === undefined) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await authorizeResolvedResource(
|
|
137
|
+
authorizer,
|
|
138
|
+
ctx.callerIdentity,
|
|
139
|
+
{
|
|
140
|
+
permission: target.permission,
|
|
141
|
+
resourceKind: target.resourceKind,
|
|
142
|
+
resourceId: target.resourceId,
|
|
143
|
+
},
|
|
144
|
+
target.deniedMessage,
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ApiResourceKind,
|
|
12
|
+
ResourceTier,
|
|
13
|
+
} from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
11
14
|
|
|
15
|
+
import { getKindMeta } from "../../../pipeline/apiresource-meta.js";
|
|
12
16
|
import {
|
|
13
17
|
DEFAULT_PAGE_SIZE,
|
|
14
18
|
MAX_PAGE_SIZE,
|
|
@@ -68,6 +72,19 @@ describe("searchIndexedKinds derivation (kind_meta)", () => {
|
|
|
68
72
|
expect(kinds).not.toContain(ApiResourceKind.channel_app);
|
|
69
73
|
expect(kinds).not.toContain(ApiResourceKind.memory);
|
|
70
74
|
});
|
|
75
|
+
|
|
76
|
+
it("contains no kind above the open_source tier — the index is the core's (editions program)", () => {
|
|
77
|
+
// Read from the descriptors, not a hand list: a kind re-tiered to
|
|
78
|
+
// enterprise or cloud_only must drop out of the derivation whatever
|
|
79
|
+
// its not_search_indexed flag says. Today every such kind is also
|
|
80
|
+
// not_search_indexed, so this pins a guard, not a behaviour change.
|
|
81
|
+
for (const kind of searchIndexedKinds()) {
|
|
82
|
+
expect(
|
|
83
|
+
getKindMeta(kind).tier,
|
|
84
|
+
`${ApiResourceKind[kind]} is searchable but not open_source-tier`,
|
|
85
|
+
).toBe(ResourceTier.open_source);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
71
88
|
});
|
|
72
89
|
|
|
73
90
|
describe("SearchCriteria normalization (Go NewSearchCriteria)", () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* discover).
|
|
6
6
|
*
|
|
7
7
|
* The searchable-kind set is DERIVED from the proto kind_meta extension
|
|
8
|
-
* (not_search_indexed: false, tier
|
|
8
|
+
* (not_search_indexed: false, tier == open_source) — Go's
|
|
9
9
|
* SearchIndexedKinds() posture since stigmer/stigmer#439, where hand-copied
|
|
10
10
|
* kind lists drifted from the proto and from each other. Go still carries a
|
|
11
11
|
* hand map (SearchableKinds) pinned to the derivation by an invariant test;
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* registry unit test pins the registered extractors against this same
|
|
14
14
|
* derivation (the #439 invariant's TS home).
|
|
15
15
|
*
|
|
16
|
+
* The tier clause admits open_source alone, not "anything but cloud_only":
|
|
17
|
+
* the index is the core's, and a kind tiered enterprise (editions program,
|
|
18
|
+
* DD-001) must not enter it by accident. This derivation is edition-blind
|
|
19
|
+
* by design (the same code runs in every composition); an edition-aware
|
|
20
|
+
* derivation is a design act with a named trigger — the first searchable
|
|
21
|
+
* kind above open_source.
|
|
22
|
+
*
|
|
16
23
|
* kinds are kept VERBATIM from the request; filtering to the searchable set
|
|
17
24
|
* happens in effectiveKinds(). Keeping the raw request is what
|
|
18
25
|
* distinguishes "no kinds requested" (discover mode) from "kinds requested
|
|
@@ -45,8 +52,8 @@ let searchableKindSetCache: ReadonlySet<ApiResourceKind> | undefined;
|
|
|
45
52
|
|
|
46
53
|
/**
|
|
47
54
|
* Go SearchIndexedKinds(): every kind this edition's search read side
|
|
48
|
-
* serves — kind_meta declares it search-indexed and
|
|
49
|
-
*
|
|
55
|
+
* serves — kind_meta declares it search-indexed and open_source-tier.
|
|
56
|
+
* Sorted ascending by enum value, exactly Go's sort. Kinds whose
|
|
50
57
|
* kind_meta cannot be read are skipped (Go's contract; the registry unit
|
|
51
58
|
* test separately fails on such a defect).
|
|
52
59
|
*/
|
|
@@ -64,7 +71,7 @@ export function searchIndexedKinds(): readonly ApiResourceKind[] {
|
|
|
64
71
|
} catch {
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
|
-
if (meta.notSearchIndexed || meta.tier
|
|
74
|
+
if (meta.notSearchIndexed || meta.tier !== ResourceTier.open_source) {
|
|
68
75
|
continue;
|
|
69
76
|
}
|
|
70
77
|
kinds.push(kind);
|