@stigmer/server 3.37.0 → 3.38.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/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/artifacts.d.ts.map +1 -1
- package/dist/domain/agentexecution/artifacts.js +15 -6
- package/dist/domain/agentexecution/artifacts.js.map +1 -1
- package/dist/domain/agentexecution/lifecycle.d.ts.map +1 -1
- package/dist/domain/agentexecution/lifecycle.js +5 -3
- package/dist/domain/agentexecution/lifecycle.js.map +1 -1
- package/dist/domain/agentexecution/platform-rows.d.ts +63 -0
- package/dist/domain/agentexecution/platform-rows.d.ts.map +1 -0
- package/dist/domain/agentexecution/platform-rows.js +106 -0
- package/dist/domain/agentexecution/platform-rows.js.map +1 -0
- package/dist/domain/agentexecution/subscribe.d.ts.map +1 -1
- package/dist/domain/agentexecution/subscribe.js +7 -4
- package/dist/domain/agentexecution/subscribe.js.map +1 -1
- package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
- package/dist/domain/agentexecution/update-status.js +48 -17
- package/dist/domain/agentexecution/update-status.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/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/workflowexecution/create-steps.d.ts.map +1 -1
- package/dist/domain/workflowexecution/create-steps.js +6 -2
- package/dist/domain/workflowexecution/create-steps.js.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.d.ts.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.js +5 -2
- package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
- package/dist/domain/workflowexecution/send-signal.d.ts.map +1 -1
- package/dist/domain/workflowexecution/send-signal.js +6 -3
- package/dist/domain/workflowexecution/send-signal.js.map +1 -1
- package/dist/domain/workflowexecution/submit-approval.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-approval.js +6 -2
- package/dist/domain/workflowexecution/submit-approval.js.map +1 -1
- package/dist/domain/workflowexecution/submit-file-decision.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-file-decision.js +6 -2
- package/dist/domain/workflowexecution/submit-file-decision.js.map +1 -1
- package/dist/domain/workflowexecution/submit-workflow-task-approval.d.ts.map +1 -1
- package/dist/domain/workflowexecution/submit-workflow-task-approval.js +6 -2
- package/dist/domain/workflowexecution/submit-workflow-task-approval.js.map +1 -1
- package/dist/domain/workflowexecution/subscribe-events.d.ts.map +1 -1
- package/dist/domain/workflowexecution/subscribe-events.js +6 -2
- package/dist/domain/workflowexecution/subscribe-events.js.map +1 -1
- package/dist/domain/workflowexecution/subscribe.d.ts.map +1 -1
- package/dist/domain/workflowexecution/subscribe.js +7 -4
- package/dist/domain/workflowexecution/subscribe.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/dist/temporal/agentexecution/runner-result.d.ts +12 -0
- package/dist/temporal/agentexecution/runner-result.d.ts.map +1 -1
- package/dist/temporal/agentexecution/runner-result.js +15 -0
- package/dist/temporal/agentexecution/runner-result.js.map +1 -1
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.d.ts.map +1 -1
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js +44 -22
- package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js.map +1 -1
- package/package.json +6 -6
- package/src/boot/compose.ts +3 -0
- package/src/domain/agentexecution/__tests__/store-faults.test.ts +315 -0
- package/src/domain/agentexecution/__tests__/update-status.test.ts +217 -0
- package/src/domain/agentexecution/artifacts.ts +21 -12
- package/src/domain/agentexecution/lifecycle.ts +5 -3
- package/src/domain/agentexecution/platform-rows.ts +133 -0
- package/src/domain/agentexecution/subscribe.ts +7 -3
- package/src/domain/agentexecution/update-status.ts +70 -19
- package/src/domain/iampolicy/__tests__/grant-path.test.ts +40 -7
- package/src/domain/iampolicy/grant-path.ts +19 -5
- 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/workflowexecution/__tests__/store-faults.test.ts +418 -0
- package/src/domain/workflowexecution/create-steps.ts +6 -2
- package/src/domain/workflowexecution/lifecycle.ts +5 -2
- package/src/domain/workflowexecution/send-signal.ts +11 -4
- package/src/domain/workflowexecution/submit-approval.ts +6 -2
- package/src/domain/workflowexecution/submit-file-decision.ts +6 -2
- package/src/domain/workflowexecution/submit-workflow-task-approval.ts +6 -2
- package/src/domain/workflowexecution/subscribe-events.ts +6 -2
- package/src/domain/workflowexecution/subscribe.ts +11 -4
- 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 +297 -0
- package/src/pipeline/steps/authorization-tuples.ts +6 -2
- package/src/temporal/agentexecution/__tests__/invoke-workflow.test.ts +51 -1
- package/src/temporal/agentexecution/__tests__/own-behalf-status-writes.test.ts +149 -9
- package/src/temporal/agentexecution/__tests__/replay-histories/runner-failed.json +757 -0
- package/src/temporal/agentexecution/__tests__/replay-histories/user-cancel.json +755 -0
- package/src/temporal/agentexecution/__tests__/replay.test.ts +4 -2
- package/src/temporal/agentexecution/runner-result.ts +16 -0
- package/src/temporal/agentexecution/workflows/invoke-agent-execution.ts +49 -22
- package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +0 -78
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform rows: the MESSAGE_SYSTEM lines the server itself writes into
|
|
3
|
+
* an execution's transcript, and the transcript rules that keep them.
|
|
4
|
+
* The runner owns the transcript; these are the few rows only the
|
|
5
|
+
* platform can write, because only the platform knows the fact they
|
|
6
|
+
* state (stigmer#980).
|
|
7
|
+
*
|
|
8
|
+
* - Stop rows say why a run stopped when the stop came from outside the
|
|
9
|
+
* turn. The runner cannot tell a Pause from a Cancel: both reach its
|
|
10
|
+
* activity as the same Temporal cancellation (reason CANCELLED), and
|
|
11
|
+
* both RPCs signal the engine before they persist the phase
|
|
12
|
+
* (lifecycle.ts), so no stored fact distinguishes them in time. The
|
|
13
|
+
* invoke workflow knows which stop it is running, so it writes the row
|
|
14
|
+
* (workflows/invoke-agent-execution.ts) and the runner writes none.
|
|
15
|
+
* - A platform marker write is a status update whose messages are all
|
|
16
|
+
* MESSAGE_SYSTEM and do not extend the stored transcript: the
|
|
17
|
+
* workflow's stop rows and its broken-flow failure lines. A marker is
|
|
18
|
+
* appended, never substituted for the transcript. Every runner write
|
|
19
|
+
* is a superset of the stored transcript (a resumed runner seeds from
|
|
20
|
+
* it, runner/src/harness/turn-context.ts) and the runner only ever
|
|
21
|
+
* appends system rows, so a runner write never reads as a marker.
|
|
22
|
+
*
|
|
23
|
+
* Messages carry no id (agentexecution/v1/message.proto), so rows are
|
|
24
|
+
* identified by type and content: the stop copy is these exact
|
|
25
|
+
* constants, defined once here for the workflow and the merge, and never
|
|
26
|
+
* matched against copy some other component authors.
|
|
27
|
+
*
|
|
28
|
+
* Pure (protos only): the workflow bundle imports this module by direct
|
|
29
|
+
* path (src/temporal/README.md, workflow-bundle import discipline).
|
|
30
|
+
*/
|
|
31
|
+
import {
|
|
32
|
+
ExecutionPhase,
|
|
33
|
+
MessageType,
|
|
34
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
35
|
+
import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
36
|
+
|
|
37
|
+
/** The stop row of a paused execution. Byte-identical to the copy the runner wrote before stigmer#980 moved it here. */
|
|
38
|
+
export const PAUSE_STOP_ROW =
|
|
39
|
+
"Execution paused by user. Use resume to continue.";
|
|
40
|
+
|
|
41
|
+
/** The stop row of a cancelled execution: a quiet terminal state, never a failure (stigmer#282). */
|
|
42
|
+
export const CANCEL_STOP_ROW = "Execution was cancelled.";
|
|
43
|
+
|
|
44
|
+
/** The stop row a phase carries, or undefined for a phase the platform does not explain. */
|
|
45
|
+
export function stopRowForPhase(phase: ExecutionPhase): string | undefined {
|
|
46
|
+
switch (phase) {
|
|
47
|
+
case ExecutionPhase.EXECUTION_PAUSED:
|
|
48
|
+
return PAUSE_STOP_ROW;
|
|
49
|
+
case ExecutionPhase.EXECUTION_CANCELLED:
|
|
50
|
+
return CANCEL_STOP_ROW;
|
|
51
|
+
default:
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Whether two rows are the same row: messages carry no id, so identity is type and content. */
|
|
57
|
+
function sameRow(a: AgentMessage, b: AgentMessage): boolean {
|
|
58
|
+
return a.type === b.type && a.content === b.content;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether transcript ends with tail, row for row. */
|
|
62
|
+
export function endsWithRows(
|
|
63
|
+
transcript: readonly AgentMessage[],
|
|
64
|
+
tail: readonly AgentMessage[],
|
|
65
|
+
): boolean {
|
|
66
|
+
if (tail.length > transcript.length) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const offset = transcript.length - tail.length;
|
|
70
|
+
return tail.every((row, i) => sameRow(transcript[offset + i], row));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether incoming is a platform marker write against existing: every
|
|
75
|
+
* incoming row is MESSAGE_SYSTEM, and it does not extend a non-empty
|
|
76
|
+
* stored transcript (existing is not a row-for-row prefix of incoming).
|
|
77
|
+
* An all-system write that does extend it is the runner's own growth; a
|
|
78
|
+
* write onto an empty transcript has nothing to preserve.
|
|
79
|
+
*/
|
|
80
|
+
export function isPlatformMarkerWrite(
|
|
81
|
+
existing: readonly AgentMessage[],
|
|
82
|
+
incoming: readonly AgentMessage[],
|
|
83
|
+
): boolean {
|
|
84
|
+
if (existing.length === 0 || incoming.length === 0) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (!incoming.every((row) => row.type === MessageType.MESSAGE_SYSTEM)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const extendsExisting =
|
|
91
|
+
incoming.length >= existing.length &&
|
|
92
|
+
existing.every((row, i) => sameRow(row, incoming[i]));
|
|
93
|
+
return !extendsExisting;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Appends a marker write's rows to the transcript; a retry of a marker
|
|
98
|
+
* already at the transcript's end appends nothing, so the workflow's
|
|
99
|
+
* activity retries stay idempotent.
|
|
100
|
+
*/
|
|
101
|
+
export function appendPlatformMarker(
|
|
102
|
+
existing: readonly AgentMessage[],
|
|
103
|
+
marker: readonly AgentMessage[],
|
|
104
|
+
): AgentMessage[] {
|
|
105
|
+
if (endsWithRows(existing, marker)) {
|
|
106
|
+
return [...existing];
|
|
107
|
+
}
|
|
108
|
+
return [...existing, ...marker];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The stop row the merge holds last: the stored transcript's final row
|
|
113
|
+
* when it is the stored phase's own stop row. While the execution rests
|
|
114
|
+
* in that phase, the row is the platform's, not part of what the runner's
|
|
115
|
+
* later writes replace; once the phase moves on (a resume), it is
|
|
116
|
+
* ordinary history and nothing is held.
|
|
117
|
+
*/
|
|
118
|
+
export function heldStopRow(
|
|
119
|
+
phase: ExecutionPhase,
|
|
120
|
+
transcript: readonly AgentMessage[],
|
|
121
|
+
): AgentMessage | undefined {
|
|
122
|
+
const copy = stopRowForPhase(phase);
|
|
123
|
+
const last = transcript[transcript.length - 1];
|
|
124
|
+
if (
|
|
125
|
+
copy === undefined ||
|
|
126
|
+
last === undefined ||
|
|
127
|
+
last.type !== MessageType.MESSAGE_SYSTEM ||
|
|
128
|
+
last.content !== copy
|
|
129
|
+
) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return last;
|
|
133
|
+
}
|
|
@@ -46,10 +46,12 @@ import type { Logger } from "../../boot/logger.js";
|
|
|
46
46
|
import type { Authorizer } from "../../extensions/authorizer.js";
|
|
47
47
|
import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
|
|
48
48
|
import {
|
|
49
|
+
internalError,
|
|
49
50
|
invalidArgumentError,
|
|
50
51
|
notFoundError,
|
|
51
52
|
} from "../../pipeline/errors.js";
|
|
52
53
|
import { authorizeDirect } from "../../pipeline/steps/authorize.js";
|
|
54
|
+
import { ResourceNotFoundError } from "../../store/interface.js";
|
|
53
55
|
import type { Store } from "../../store/interface.js";
|
|
54
56
|
|
|
55
57
|
import { isTranscriptTerminalPhase } from "./phases.js";
|
|
@@ -97,9 +99,11 @@ export async function* subscribeExecution(
|
|
|
97
99
|
id,
|
|
98
100
|
AgentExecutionSchema,
|
|
99
101
|
);
|
|
100
|
-
} catch {
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof ResourceNotFoundError) {
|
|
104
|
+
throw notFoundError("AgentExecution", id);
|
|
105
|
+
}
|
|
106
|
+
throw internalError(error, "failed to load agent execution");
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
yield snapshot;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* UpdateStatus — ports controller/update_status.go: the runner's
|
|
3
3
|
* progressive status-update RPC and the domain's single merge chokepoint.
|
|
4
4
|
*
|
|
5
|
-
* Chain
|
|
6
|
-
* NotifyStatusObservers → BroadcastToStreams. The merge+persist is ONE
|
|
5
|
+
* Chain: Authorize → ValidateUpdateStatusInput → MergeAndPersistExecution
|
|
6
|
+
* → NotifyStatusObservers → BroadcastToStreams. The merge+persist is ONE
|
|
7
7
|
* atomic read-modify-write under the store's per-resource write lock
|
|
8
8
|
* (store.updateResource) — the same discipline SubmitApproval uses,
|
|
9
9
|
* load-bearing now that the append-only approval_event_stream is the
|
|
@@ -24,10 +24,14 @@
|
|
|
24
24
|
* wholesale with the runner's latest complete state, while server-owned
|
|
25
25
|
* fields (approval decisions, the approval_event_stream, the file_review
|
|
26
26
|
* ledger) are preserved/authored here because the runner never sends
|
|
27
|
-
* them.
|
|
27
|
+
* them. The transcript's platform rows are the same kind of server-owned
|
|
28
|
+
* state (platform-rows.ts, stigmer#980): a platform marker write is
|
|
29
|
+
* appended and never replaces the transcript, and a PAUSED or CANCELLED
|
|
30
|
+
* execution's stop row stays last across the runner's later writes,
|
|
31
|
+
* because the Pause and Cancel RPCs, the invoke workflow and the runner
|
|
32
|
+
* all write a stopped execution and nothing orders them.
|
|
28
33
|
*
|
|
29
|
-
*
|
|
30
|
-
* Publish steps (broadcast rides in-memory channels per ADR 011).
|
|
34
|
+
* Broadcast rides in-memory channels (ADR 011).
|
|
31
35
|
*/
|
|
32
36
|
import { create } from "@bufbuild/protobuf";
|
|
33
37
|
|
|
@@ -78,6 +82,12 @@ import {
|
|
|
78
82
|
isTerminalExecutionPhase,
|
|
79
83
|
isTranscriptTerminalPhase,
|
|
80
84
|
} from "./phases.js";
|
|
85
|
+
import {
|
|
86
|
+
appendPlatformMarker,
|
|
87
|
+
endsWithRows,
|
|
88
|
+
heldStopRow,
|
|
89
|
+
isPlatformMarkerWrite,
|
|
90
|
+
} from "./platform-rows.js";
|
|
81
91
|
import {
|
|
82
92
|
applyResponseDecorators,
|
|
83
93
|
notifyStatusObservers,
|
|
@@ -257,6 +267,54 @@ export function nonTerminalTranscriptRegression(
|
|
|
257
267
|
return { reject: false, reason: "" };
|
|
258
268
|
}
|
|
259
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The transcript the merge persists, or undefined to keep the stored one.
|
|
272
|
+
* The stored phase's stop row, when it closes the transcript, is held
|
|
273
|
+
* aside so the runner's settle write (which never carries it) is judged
|
|
274
|
+
* against the transcript beneath it, and re-appended after. A platform
|
|
275
|
+
* marker write is appended; anything else is the runner's write, replaced
|
|
276
|
+
* wholesale unless the regression guard rejects it.
|
|
277
|
+
*/
|
|
278
|
+
function mergeTranscript(
|
|
279
|
+
existingPhase: ExecutionPhase,
|
|
280
|
+
existing: AgentMessage[],
|
|
281
|
+
incoming: AgentMessage[],
|
|
282
|
+
executionId: string,
|
|
283
|
+
logger: Logger,
|
|
284
|
+
): AgentMessage[] | undefined {
|
|
285
|
+
const held = heldStopRow(existingPhase, existing);
|
|
286
|
+
const base = held === undefined ? existing : existing.slice(0, -1);
|
|
287
|
+
|
|
288
|
+
let next: AgentMessage[];
|
|
289
|
+
if (isPlatformMarkerWrite(base, incoming)) {
|
|
290
|
+
next = appendPlatformMarker(base, incoming);
|
|
291
|
+
} else {
|
|
292
|
+
const { reject, reason } = nonTerminalTranscriptRegression(
|
|
293
|
+
existingPhase,
|
|
294
|
+
base,
|
|
295
|
+
incoming,
|
|
296
|
+
);
|
|
297
|
+
if (reject) {
|
|
298
|
+
logger.warn(
|
|
299
|
+
"Rejected status update that would drop committed transcript history for a non-terminal execution; keeping existing messages",
|
|
300
|
+
{
|
|
301
|
+
executionId,
|
|
302
|
+
existingMessages: existing.length,
|
|
303
|
+
incomingMessages: incoming.length,
|
|
304
|
+
reason,
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
next = incoming;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (held !== undefined && !endsWithRows(next, [held])) {
|
|
313
|
+
next = [...next, held];
|
|
314
|
+
}
|
|
315
|
+
return next;
|
|
316
|
+
}
|
|
317
|
+
|
|
260
318
|
/**
|
|
261
319
|
* Merges an incoming status update into the execution in place — the
|
|
262
320
|
* runner-owns-the-transcript merge rules, exactly update_status.go
|
|
@@ -294,7 +352,8 @@ export function applyUpdateStatusMerge(
|
|
|
294
352
|
// any update that would drop committed transcript history for a
|
|
295
353
|
// non-terminal execution. The runner owns the transcript and only ever
|
|
296
354
|
// GROWS it in flight; two regressions are rejected at this single
|
|
297
|
-
// persistence chokepoint
|
|
355
|
+
// persistence chokepoint (the platform's own rows are handled first,
|
|
356
|
+
// see mergeTranscript):
|
|
298
357
|
// 1. A strictly SHORTER transcript — the classic partial write.
|
|
299
358
|
// 2. A transcript that DROPS a committed tool-call id while appending
|
|
300
359
|
// enough later turns to keep the count equal-or-greater
|
|
@@ -305,23 +364,15 @@ export function applyUpdateStatusMerge(
|
|
|
305
364
|
// post-denial narration redaction), so a content check would reject
|
|
306
365
|
// valid writes.
|
|
307
366
|
if (requestStatus.messages.length > 0) {
|
|
308
|
-
const
|
|
367
|
+
const merged = mergeTranscript(
|
|
309
368
|
existingPhase,
|
|
310
369
|
existingMessages,
|
|
311
370
|
requestStatus.messages,
|
|
371
|
+
input.executionId,
|
|
372
|
+
logger,
|
|
312
373
|
);
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
"Rejected status update that would drop committed transcript history for a non-terminal execution; keeping existing messages",
|
|
316
|
-
{
|
|
317
|
-
executionId: input.executionId,
|
|
318
|
-
existingMessages: existingMessages.length,
|
|
319
|
-
incomingMessages: requestStatus.messages.length,
|
|
320
|
-
reason,
|
|
321
|
-
},
|
|
322
|
-
);
|
|
323
|
-
} else {
|
|
324
|
-
status.messages = requestStatus.messages;
|
|
374
|
+
if (merged !== undefined) {
|
|
375
|
+
status.messages = merged;
|
|
325
376
|
}
|
|
326
377
|
}
|
|
327
378
|
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
* fail-open incident class). The absent arm fires the hook with the
|
|
15
15
|
* spec and no policy, so a composition can still delete a bare tuple
|
|
16
16
|
* written before the row mirror existed.
|
|
17
|
-
* - cleanupResource is bidirectional (as
|
|
18
|
-
* deduplicated, revoking each row
|
|
17
|
+
* - cleanupResource is bidirectional (as principal first, then as
|
|
18
|
+
* target with the owner rows last), deduplicated, revoking each row
|
|
19
|
+
* through the same revoke order; a fault stops it with the owners
|
|
20
|
+
* still holding, since an organization's delete runs it before the
|
|
21
|
+
* organization's row goes and its owner is the one who retries.
|
|
19
22
|
* - revokeOrgAccess revokes the account's direct rows on the
|
|
20
23
|
* organization; the inert org-column arm the cloud carried is gone
|
|
21
24
|
* (Q-OR-9).
|
|
@@ -378,17 +381,19 @@ describe("revoke: onPolicyRevoked before the row delete", () => {
|
|
|
378
381
|
});
|
|
379
382
|
|
|
380
383
|
describe("cleanupResource: bidirectional, deduplicated, each row through the revoke order", () => {
|
|
381
|
-
it("revokes rows where the ref is the
|
|
384
|
+
it("revokes rows where the ref is the principal, then where it is the resource, owners last", async () => {
|
|
382
385
|
const recorded: RecordedEvent[] = [];
|
|
383
386
|
const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
384
|
-
const
|
|
387
|
+
const owner = orgRole(ALICE, "owner", "acme");
|
|
388
|
+
const member = orgRole(BOB, "member", "acme");
|
|
385
389
|
const asPrincipal = triple(
|
|
386
390
|
{ kind: "organization", id: "acme" },
|
|
387
391
|
"organization",
|
|
388
392
|
{ kind: "agent", id: AGENT },
|
|
389
393
|
);
|
|
390
394
|
const unrelated = orgRole(ALICE, "member", "globex");
|
|
391
|
-
|
|
395
|
+
// Granted owner first, so the order below is the cleanup's, not the store's.
|
|
396
|
+
for (const spec of [owner, member, asPrincipal, unrelated]) {
|
|
392
397
|
await path.grant(spec, alice);
|
|
393
398
|
}
|
|
394
399
|
recorded.length = 0;
|
|
@@ -399,17 +404,45 @@ describe("cleanupResource: bidirectional, deduplicated, each row through the rev
|
|
|
399
404
|
|
|
400
405
|
expect([...policies.rows.keys()]).toEqual([policyIdFor(unrelated)]);
|
|
401
406
|
expect(recorded).toEqual([
|
|
402
|
-
{ kind: "revoked", id: policyIdFor(asResource), relation: "owner" },
|
|
403
|
-
{ kind: "row-delete", id: policyIdFor(asResource) },
|
|
404
407
|
{
|
|
405
408
|
kind: "revoked",
|
|
406
409
|
id: policyIdFor(asPrincipal),
|
|
407
410
|
relation: "organization",
|
|
408
411
|
},
|
|
409
412
|
{ kind: "row-delete", id: policyIdFor(asPrincipal) },
|
|
413
|
+
{ kind: "revoked", id: policyIdFor(member), relation: "member" },
|
|
414
|
+
{ kind: "row-delete", id: policyIdFor(member) },
|
|
415
|
+
{ kind: "revoked", id: policyIdFor(owner), relation: "owner" },
|
|
416
|
+
{ kind: "row-delete", id: policyIdFor(owner) },
|
|
410
417
|
]);
|
|
411
418
|
});
|
|
412
419
|
|
|
420
|
+
it("stops at the first fault with the owner rows still in place", async () => {
|
|
421
|
+
const recorded: RecordedEvent[] = [];
|
|
422
|
+
const lifecycle = recordingLifecycle(recorded);
|
|
423
|
+
const { policies, path } = pathOver(recorded, {
|
|
424
|
+
...lifecycle,
|
|
425
|
+
onPolicyRevoked: (event) =>
|
|
426
|
+
event.spec.relation === "member"
|
|
427
|
+
? Promise.reject(new Error("tuple delete refused"))
|
|
428
|
+
: lifecycle.onPolicyRevoked!(event),
|
|
429
|
+
});
|
|
430
|
+
const owner = orgRole(ALICE, "owner", "acme");
|
|
431
|
+
const member = orgRole(BOB, "member", "acme");
|
|
432
|
+
for (const spec of [owner, member]) {
|
|
433
|
+
await path.grant(spec, alice);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await expect(
|
|
437
|
+
path.cleanupResource(
|
|
438
|
+
create(ApiResourceRefSchema, { kind: "organization", id: "acme" }),
|
|
439
|
+
),
|
|
440
|
+
).rejects.toThrow("tuple delete refused");
|
|
441
|
+
|
|
442
|
+
expect(policies.rows.has(policyIdFor(owner))).toBe(true);
|
|
443
|
+
expect(policies.rows.has(policyIdFor(member))).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
|
|
413
446
|
it("a ref that is both principal and resource of one row revokes it once", async () => {
|
|
414
447
|
const recorded: RecordedEvent[] = [];
|
|
415
448
|
const { path } = pathOver(recorded, recordingLifecycle(recorded));
|
|
@@ -59,6 +59,7 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
|
|
|
59
59
|
import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
|
|
60
60
|
import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
61
61
|
import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
|
|
62
|
+
import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
|
|
62
63
|
import type {
|
|
63
64
|
ApiResourceRef,
|
|
64
65
|
IamPolicySpec,
|
|
@@ -78,6 +79,9 @@ import { DuplicatePolicyError } from "./store.js";
|
|
|
78
79
|
import type { IamPolicyStore } from "./store.js";
|
|
79
80
|
import { refsOf, requireWellFormedTriple } from "./wire-refusals.js";
|
|
80
81
|
|
|
82
|
+
/** The relation `cleanupResource` revokes last on the ref's own rows. */
|
|
83
|
+
const OWNER_RELATION = IamRole[IamRole.owner];
|
|
84
|
+
|
|
81
85
|
export interface GrantResult {
|
|
82
86
|
readonly policy: IamPolicy;
|
|
83
87
|
/** True when the triple was already held: no row was written, the hook still fired. */
|
|
@@ -99,9 +103,13 @@ export interface IamPolicyGrantPath {
|
|
|
99
103
|
*/
|
|
100
104
|
revokeBySpec(spec: IamPolicySpec): Promise<IamPolicy | undefined>;
|
|
101
105
|
/**
|
|
102
|
-
* Revoke every row naming the ref as its
|
|
103
|
-
* it as its
|
|
104
|
-
* is revoked once. Nothing to clean is a no-op.
|
|
106
|
+
* Revoke every row naming the ref as its principal, then every row naming
|
|
107
|
+
* it as its resource with the `owner` rows last, each through the revoke
|
|
108
|
+
* order; a row that is both is revoked once. Nothing to clean is a no-op.
|
|
109
|
+
* The order matters where the ref still exists: an organization's delete
|
|
110
|
+
* runs this before its row goes and fails on the first fault, so a
|
|
111
|
+
* cleanup that stops partway has not yet removed the owners who may
|
|
112
|
+
* retry it.
|
|
105
113
|
*/
|
|
106
114
|
cleanupResource(ref: ApiResourceRef): Promise<void>;
|
|
107
115
|
/** Revoke every relation the account holds directly on the organization. */
|
|
@@ -209,9 +217,15 @@ export function newIamPolicyGrantPath(
|
|
|
209
217
|
},
|
|
210
218
|
|
|
211
219
|
async cleanupResource(ref): Promise<void> {
|
|
212
|
-
const asResource = await policies.findByResource(ref.kind, ref.id);
|
|
213
220
|
const asPrincipal = await policies.findByPrincipal(ref.kind, ref.id);
|
|
214
|
-
|
|
221
|
+
const asResource = await policies.findByResource(ref.kind, ref.id);
|
|
222
|
+
const isOwner = (policy: IamPolicy): boolean =>
|
|
223
|
+
policy.spec?.relation === OWNER_RELATION;
|
|
224
|
+
for (const policy of distinctById([
|
|
225
|
+
...asPrincipal,
|
|
226
|
+
...asResource.filter((policy) => !isOwner(policy)),
|
|
227
|
+
...asResource.filter(isOwner),
|
|
228
|
+
])) {
|
|
215
229
|
await revokeRow(policy);
|
|
216
230
|
}
|
|
217
231
|
},
|