@cosmicdrift/kumiko-framework 0.263.0 → 0.264.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/package.json +3 -3
- package/src/changes.json +7 -0
- package/src/engine/__tests__/extends-registrar.test.ts +76 -5
- package/src/engine/__tests__/hook-identity-switch-grant.test.ts +105 -0
- package/src/engine/__tests__/hook-phases.test.ts +51 -11
- package/src/engine/__tests__/post-query-hook.test.ts +21 -6
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +26 -0
- package/src/engine/boot-validator/access-declarations.ts +7 -5
- package/src/engine/feature-entity-handlers.ts +3 -0
- package/src/engine/feature-ui-extensions.ts +38 -5
- package/src/engine/registry-validate.ts +37 -6
- package/src/errors/i18n/de.yaml +13 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +4 -0
- package/src/jobs/__tests__/job-write-as.integration.test.ts +47 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +8 -2
- package/src/pipeline/__tests__/system-identity-switch.integration.test.ts +352 -0
- package/src/pipeline/__tests__/system-identity-switch.test.ts +195 -0
- package/src/pipeline/dispatch-shared.ts +28 -16
- package/src/pipeline/system-identity-switch.ts +123 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
|
+
import { bindHookIdentitySwitchGrant } from "../pipeline/system-identity-switch";
|
|
2
3
|
import { resolveName } from "./handler-helpers";
|
|
3
4
|
import type {
|
|
4
5
|
RegistryState,
|
|
@@ -14,7 +15,16 @@ import {
|
|
|
14
15
|
SOFT_DELETE_GRACE_DAYS_KEY,
|
|
15
16
|
softDeleteGraceDaysConfig,
|
|
16
17
|
} from "./soft-delete-cleanup";
|
|
17
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
EventPiiFields,
|
|
20
|
+
EventUpcastFn,
|
|
21
|
+
FeatureDefinition,
|
|
22
|
+
PostDeleteHookFn,
|
|
23
|
+
PostSaveHookFn,
|
|
24
|
+
PreDeleteHookFn,
|
|
25
|
+
PreSaveHookFn,
|
|
26
|
+
ReferenceFieldDef,
|
|
27
|
+
} from "./types";
|
|
18
28
|
import { HookPhases } from "./types";
|
|
19
29
|
|
|
20
30
|
function allHandlerQns(state: RegistryState): ReadonlySet<string> {
|
|
@@ -115,29 +125,46 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
115
125
|
// to also be gated, store the registering-feature on
|
|
116
126
|
// RegistrarExtensionRegistration and use that here.
|
|
117
127
|
const extOwner = "*";
|
|
128
|
+
// Extensions have no declaration site for escapeHatch, so SYSTEM is always denied
|
|
129
|
+
// (system-identity-switch.ts) — bindHookIdentitySwitchGrant(..., undefined) below.
|
|
118
130
|
if (ext.hooks) {
|
|
119
131
|
if (ext.hooks.postSave) {
|
|
132
|
+
const wrapped = bindHookIdentitySwitchGrant(
|
|
133
|
+
ext.hooks.postSave,
|
|
134
|
+
`postSave hook of extension "${usage.extensionName}"`,
|
|
135
|
+
undefined,
|
|
136
|
+
) as PostSaveHookFn; // @cast-boundary engine-bridge
|
|
120
137
|
const existing = state.entityPostSaveHooks.get(usage.entityName) ?? [];
|
|
121
138
|
existing.push({
|
|
122
|
-
fn:
|
|
139
|
+
fn: wrapped,
|
|
123
140
|
phase: HookPhases.afterCommit,
|
|
124
141
|
featureName: extOwner,
|
|
125
142
|
});
|
|
126
143
|
state.entityPostSaveHooks.set(usage.entityName, existing);
|
|
127
144
|
}
|
|
128
145
|
if (ext.hooks.preDelete) {
|
|
146
|
+
const wrapped = bindHookIdentitySwitchGrant(
|
|
147
|
+
ext.hooks.preDelete,
|
|
148
|
+
`preDelete hook of extension "${usage.extensionName}"`,
|
|
149
|
+
undefined,
|
|
150
|
+
) as PreDeleteHookFn; // @cast-boundary engine-bridge
|
|
129
151
|
const existing = state.entityPreDeleteHooks.get(usage.entityName) ?? [];
|
|
130
152
|
existing.push({
|
|
131
|
-
fn:
|
|
153
|
+
fn: wrapped,
|
|
132
154
|
phase: HookPhases.afterCommit,
|
|
133
155
|
featureName: extOwner,
|
|
134
156
|
});
|
|
135
157
|
state.entityPreDeleteHooks.set(usage.entityName, existing);
|
|
136
158
|
}
|
|
137
159
|
if (ext.hooks.postDelete) {
|
|
160
|
+
const wrapped = bindHookIdentitySwitchGrant(
|
|
161
|
+
ext.hooks.postDelete,
|
|
162
|
+
`postDelete hook of extension "${usage.extensionName}"`,
|
|
163
|
+
undefined,
|
|
164
|
+
) as PostDeleteHookFn; // @cast-boundary engine-bridge
|
|
138
165
|
const existing = state.entityPostDeleteHooks.get(usage.entityName) ?? [];
|
|
139
166
|
existing.push({
|
|
140
|
-
fn:
|
|
167
|
+
fn: wrapped,
|
|
141
168
|
phase: HookPhases.afterCommit,
|
|
142
169
|
featureName: extOwner,
|
|
143
170
|
});
|
|
@@ -145,11 +172,15 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
145
172
|
}
|
|
146
173
|
// preSave on extensions: store as handler hook for all CRUD handlers of this entity
|
|
147
174
|
if (ext.hooks.preSave) {
|
|
148
|
-
|
|
175
|
+
const wrapped = bindHookIdentitySwitchGrant(
|
|
176
|
+
ext.hooks.preSave,
|
|
177
|
+
`preSave hook of extension "${usage.extensionName}"`,
|
|
178
|
+
undefined,
|
|
179
|
+
) as PreSaveHookFn; // @cast-boundary engine-bridge
|
|
149
180
|
for (const qualifiedHandler of state.writeHandlerMap.keys()) {
|
|
150
181
|
if (state.handlerEntityMap.get(qualifiedHandler) === usage.entityName) {
|
|
151
182
|
const existing = state.preSaveHooks.get(qualifiedHandler) ?? [];
|
|
152
|
-
existing.push({ fn:
|
|
183
|
+
existing.push({ fn: wrapped, featureName: extOwner });
|
|
153
184
|
state.preSaveHooks.set(qualifiedHandler, existing);
|
|
154
185
|
}
|
|
155
186
|
}
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -84,6 +84,19 @@ feature_disabled:
|
|
|
84
84
|
Aktiviere das Feature via Feature-Toggle oder Routing-Config (siehe
|
|
85
85
|
[Feature-Toggles](/en/feature-reference/feature-toggles/)).
|
|
86
86
|
|
|
87
|
+
system_identity_switch_denied:
|
|
88
|
+
endUser: |
|
|
89
|
+
Diese Aktion ist nicht erlaubt.
|
|
90
|
+
Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
|
|
91
|
+
developer: |
|
|
92
|
+
`AccessDeniedError`: ein Handler oder Hook hat `ctx.queryAs`/`ctx.writeAs`
|
|
93
|
+
mit einer SYSTEM-Identität aufgerufen, ohne `r.systemScope()`-Handler, Job
|
|
94
|
+
zu sein oder `escapeHatch: { reason }` zu deklarieren.
|
|
95
|
+
|
|
96
|
+
Deklariere `escapeHatch: { reason: "..." }` am Write- oder Query-Handler
|
|
97
|
+
(oder am `r.hook(...)`-Aufruf, wenn der Wechsel in einem Hook passiert),
|
|
98
|
+
oder verlagere die Logik in ein `r.systemScope()`-Feature oder einen Job.
|
|
99
|
+
|
|
87
100
|
agent.tool_not_allowed:
|
|
88
101
|
endUser: |
|
|
89
102
|
Dieses Tool ist im aktuellen Modus nicht verfügbar.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -81,6 +81,19 @@ feature_disabled:
|
|
|
81
81
|
handler. Enable the feature via feature toggle or routing config (see
|
|
82
82
|
[Feature Toggles](/en/feature-reference/feature-toggles/)).
|
|
83
83
|
|
|
84
|
+
system_identity_switch_denied:
|
|
85
|
+
endUser: |
|
|
86
|
+
This action isn't allowed.
|
|
87
|
+
Please contact an administrator if you believe this is a mistake.
|
|
88
|
+
developer: |
|
|
89
|
+
`AccessDeniedError`: a handler or hook called `ctx.queryAs`/`ctx.writeAs`
|
|
90
|
+
with a SYSTEM identity without being an `r.systemScope()` handler, a job,
|
|
91
|
+
or declaring `escapeHatch: { reason }`.
|
|
92
|
+
|
|
93
|
+
Declare `escapeHatch: { reason: "..." }` on the write or query handler
|
|
94
|
+
(or on the `r.hook(...)` call, if the switch happens inside a hook), or
|
|
95
|
+
move the logic into an `r.systemScope()` feature or a job.
|
|
96
|
+
|
|
84
97
|
agent.tool_not_allowed:
|
|
85
98
|
endUser: |
|
|
86
99
|
This tool is not available in the current mode.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -38,6 +38,10 @@ export const FrameworkReasons = {
|
|
|
38
38
|
// Distinct from access-denied — clients should surface "feature X is
|
|
39
39
|
// currently unavailable" not "you don't have permission".
|
|
40
40
|
featureDisabled: "feature_disabled",
|
|
41
|
+
|
|
42
|
+
// AccessDeniedError: a handler/hook called ctx.queryAs/ctx.writeAs with a
|
|
43
|
+
// SYSTEM identity without an r.systemScope() feature or a declared escapeHatch.
|
|
44
|
+
systemIdentitySwitchDenied: "system_identity_switch_denied",
|
|
41
45
|
} as const;
|
|
42
46
|
|
|
43
47
|
export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
|
|
@@ -13,7 +13,13 @@ import { z } from "zod";
|
|
|
13
13
|
import { ROLES } from "../../auth/roles";
|
|
14
14
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
15
15
|
import { asRawClient } from "../../db/query";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createRegistry,
|
|
18
|
+
createSystemUser,
|
|
19
|
+
defineFeature,
|
|
20
|
+
type SessionUser,
|
|
21
|
+
type WriteResult,
|
|
22
|
+
} from "../../engine";
|
|
17
23
|
import { createWorkerEntrypoint } from "../../entrypoint";
|
|
18
24
|
import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
19
25
|
import { createEventConsumerStateTable } from "../../pipeline";
|
|
@@ -28,6 +34,7 @@ type ProbeOutcome = { readonly isSuccess: boolean; readonly errorCode: string |
|
|
|
28
34
|
const writeOutcomes: ProbeOutcome[] = [];
|
|
29
35
|
const writeAsOutcomes: ProbeOutcome[] = [];
|
|
30
36
|
const writeAsFailures: string[] = [];
|
|
37
|
+
const jobQueryAsSystemResults: Array<{ readonly roles: readonly string[] }> = [];
|
|
31
38
|
|
|
32
39
|
function recordOutcome(sink: ProbeOutcome[], result: WriteResult): void {
|
|
33
40
|
sink.push({
|
|
@@ -57,6 +64,10 @@ const writeAsProbeFeature = defineFeature("writeAsProbe", (r) => {
|
|
|
57
64
|
{ access: { roles: [ROLES.TenantAdmin] } },
|
|
58
65
|
);
|
|
59
66
|
|
|
67
|
+
r.queryHandler("whoami", z.object({}), async (query) => ({ roles: query.user.roles }), {
|
|
68
|
+
access: { roles: ["system"] },
|
|
69
|
+
});
|
|
70
|
+
|
|
60
71
|
r.job("writeAsSystem", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
61
72
|
const result = await ctx.write("write-as-probe:write:admin-note", {
|
|
62
73
|
note: payload["note"] as string, // @cast-boundary dynamic-key
|
|
@@ -64,6 +75,16 @@ const writeAsProbeFeature = defineFeature("writeAsProbe", (r) => {
|
|
|
64
75
|
recordOutcome(writeOutcomes, result);
|
|
65
76
|
});
|
|
66
77
|
|
|
78
|
+
// fw#2859 — JobContext.queryAs stays UNgated by the SYSTEM identity-switch gate.
|
|
79
|
+
r.job("queryAsSystemWhoami", { trigger: { manual: true }, retries: 0 }, async (_payload, ctx) => {
|
|
80
|
+
const result = (await ctx.queryAs(
|
|
81
|
+
createSystemUser(ctx.systemUser.tenantId),
|
|
82
|
+
"write-as-probe:query:whoami",
|
|
83
|
+
{},
|
|
84
|
+
)) as { roles: readonly string[] }; // @cast-boundary engine-payload
|
|
85
|
+
jobQueryAsSystemResults.push(result);
|
|
86
|
+
});
|
|
87
|
+
|
|
67
88
|
r.job("writeAsActor", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
68
89
|
const actor: SessionUser = {
|
|
69
90
|
id: payload["actorId"] as string, // @cast-boundary dynamic-key
|
|
@@ -204,3 +225,28 @@ describe("JobContext.writeAs before attachDispatcher()", () => {
|
|
|
204
225
|
}
|
|
205
226
|
});
|
|
206
227
|
});
|
|
228
|
+
|
|
229
|
+
describe("JobContext.queryAs stays ungated by the SYSTEM identity-switch gate (framework#2859)", () => {
|
|
230
|
+
test("ctx.queryAs(createSystemUser(...), whoami) succeeds from inside a job", async () => {
|
|
231
|
+
jobQueryAsSystemResults.length = 0;
|
|
232
|
+
const worker = createWorkerEntrypoint({
|
|
233
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
234
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
235
|
+
jwtSecret: JWT,
|
|
236
|
+
redisUrl: redisUrl(),
|
|
237
|
+
queueNamePrefix: uniquePrefix("job-query-as-system"),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await worker.start();
|
|
241
|
+
try {
|
|
242
|
+
await worker.jobRunner.dispatch("write-as-probe:job:query-as-system-whoami", {});
|
|
243
|
+
|
|
244
|
+
await waitFor(() => {
|
|
245
|
+
expect(jobQueryAsSystemResults.length).toBe(1);
|
|
246
|
+
});
|
|
247
|
+
expect(jobQueryAsSystemResults[0]?.roles).toContain("system");
|
|
248
|
+
} finally {
|
|
249
|
+
await worker.stop();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
@@ -135,7 +135,10 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
135
135
|
|
|
136
136
|
return created;
|
|
137
137
|
},
|
|
138
|
-
{
|
|
138
|
+
{
|
|
139
|
+
access: { roles: ["Admin"] },
|
|
140
|
+
escapeHatch: { reason: "test fixture: writeAs(SYSTEM) creates the secret for the caller" },
|
|
141
|
+
},
|
|
139
142
|
);
|
|
140
143
|
|
|
141
144
|
// Two inserts, one via ctx.db (tx-bound) and one via ctx.dbOutsideTransaction
|
|
@@ -168,7 +171,10 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
168
171
|
owner: query.payload.owner,
|
|
169
172
|
});
|
|
170
173
|
},
|
|
171
|
-
{
|
|
174
|
+
{
|
|
175
|
+
access: { roles: ["Admin"] },
|
|
176
|
+
escapeHatch: { reason: "test fixture: queryAs(SYSTEM) reads the privileged token field" },
|
|
177
|
+
},
|
|
172
178
|
);
|
|
173
179
|
|
|
174
180
|
// Records whether ctx.db threw (should, once the request signal is
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// fw#2859 — ctx.queryAs/ctx.writeAs to SYSTEM needs r.systemScope() or { escapeHatch }; a
|
|
2
|
+
// non-SYSTEM switch is ungated. Real HTTP calls + setupTestStack — never createTestDispatcher.
|
|
3
|
+
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
7
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
8
|
+
import {
|
|
9
|
+
createEntity,
|
|
10
|
+
createSystemUser,
|
|
11
|
+
createTextField,
|
|
12
|
+
defineFeature,
|
|
13
|
+
HookPhases,
|
|
14
|
+
} from "../../engine";
|
|
15
|
+
import type { HandlerContext, SessionUser } from "../../engine/types";
|
|
16
|
+
import {
|
|
17
|
+
createTestUser,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "../../stack";
|
|
23
|
+
|
|
24
|
+
const user = TestUsers.user;
|
|
25
|
+
const otherUserNoRole: SessionUser = createTestUser({
|
|
26
|
+
id: 50,
|
|
27
|
+
tenantId: user.tenantId,
|
|
28
|
+
roles: ["User"],
|
|
29
|
+
});
|
|
30
|
+
const otherUserWithAdminRole: SessionUser = createTestUser({
|
|
31
|
+
id: 51,
|
|
32
|
+
tenantId: user.tenantId,
|
|
33
|
+
roles: ["Admin"],
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const hookThingEntity = createEntity({
|
|
37
|
+
table: "idswitch_hook_things",
|
|
38
|
+
fields: {
|
|
39
|
+
label: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const hookThingTable = buildEntityTable("hookThing", hookThingEntity);
|
|
43
|
+
const hookThingExecutor = createEventStoreExecutor(hookThingTable, hookThingEntity, {
|
|
44
|
+
entityName: "hookThing",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const hookNoEscapeHatchOutcomes: Array<{ readonly threw: boolean }> = [];
|
|
48
|
+
const hookWithEscapeHatchOutcomes: Array<{ readonly ok: boolean }> = [];
|
|
49
|
+
|
|
50
|
+
const probeFeature = defineFeature("idswitch-probe", (r) => {
|
|
51
|
+
r.entity("hookThing", hookThingEntity);
|
|
52
|
+
|
|
53
|
+
// --- Targets ---
|
|
54
|
+
r.queryHandler(
|
|
55
|
+
"whoami",
|
|
56
|
+
z.object({}),
|
|
57
|
+
async (query) => ({
|
|
58
|
+
roles: query.user.roles,
|
|
59
|
+
tenantId: query.user.tenantId,
|
|
60
|
+
}),
|
|
61
|
+
{ access: { roles: ["system", "User"] } },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
r.writeHandler(
|
|
65
|
+
"whoami-write",
|
|
66
|
+
z.object({}),
|
|
67
|
+
async (event) => ({
|
|
68
|
+
isSuccess: true as const,
|
|
69
|
+
data: { roles: event.user.roles, tenantId: event.user.tenantId },
|
|
70
|
+
}),
|
|
71
|
+
{ access: { roles: ["system", "User"] } },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
r.queryHandler("admin-only", z.object({}), async () => ({ ok: true }), {
|
|
75
|
+
access: { roles: ["Admin"] },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// --- Direct SYSTEM switch: gated by the CALLING handler's own escapeHatch ---
|
|
79
|
+
r.writeHandler(
|
|
80
|
+
"write-as-system-no-hatch",
|
|
81
|
+
z.object({}),
|
|
82
|
+
async (event, ctx) =>
|
|
83
|
+
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
84
|
+
{ access: { roles: ["User"] } },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
r.writeHandler(
|
|
88
|
+
"write-as-system-with-hatch",
|
|
89
|
+
z.object({}),
|
|
90
|
+
async (event, ctx) =>
|
|
91
|
+
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
92
|
+
{
|
|
93
|
+
access: { roles: ["User"] },
|
|
94
|
+
escapeHatch: { reason: "test: needs SYSTEM to look itself up" },
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
r.queryHandler(
|
|
99
|
+
"query-as-system-no-hatch",
|
|
100
|
+
z.object({}),
|
|
101
|
+
async (query, ctx) =>
|
|
102
|
+
ctx.queryAs(createSystemUser(query.user.tenantId), "idswitch-probe:query:whoami", {}),
|
|
103
|
+
{ access: { roles: ["User"] } },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
r.queryHandler(
|
|
107
|
+
"query-as-system-with-hatch",
|
|
108
|
+
z.object({}),
|
|
109
|
+
async (query, ctx) =>
|
|
110
|
+
ctx.queryAs(createSystemUser(query.user.tenantId), "idswitch-probe:query:whoami", {}),
|
|
111
|
+
{
|
|
112
|
+
access: { roles: ["User"] },
|
|
113
|
+
escapeHatch: { reason: "test: needs SYSTEM to look itself up" },
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// --- Non-SYSTEM switch: never gated by this feature, only the target's own access rule ---
|
|
118
|
+
r.queryHandler(
|
|
119
|
+
"query-as-normal-user",
|
|
120
|
+
z.object({ granted: z.boolean() }),
|
|
121
|
+
async (query, ctx) =>
|
|
122
|
+
ctx.queryAs(
|
|
123
|
+
query.payload.granted ? otherUserWithAdminRole : otherUserNoRole,
|
|
124
|
+
"idswitch-probe:query:admin-only",
|
|
125
|
+
{},
|
|
126
|
+
),
|
|
127
|
+
{ access: { roles: ["User"] } },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// --- Nested: no transitive grant ---
|
|
131
|
+
r.writeHandler(
|
|
132
|
+
"inner-no-hatch",
|
|
133
|
+
z.object({}),
|
|
134
|
+
async (event, ctx) =>
|
|
135
|
+
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
136
|
+
{ access: { roles: ["system"] } },
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
r.writeHandler(
|
|
140
|
+
"inner-with-hatch",
|
|
141
|
+
z.object({}),
|
|
142
|
+
async (event, ctx) =>
|
|
143
|
+
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
144
|
+
{ access: { roles: ["system"] }, escapeHatch: { reason: "test: inner needs SYSTEM" } },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
r.writeHandler(
|
|
148
|
+
"outer-with-hatch",
|
|
149
|
+
z.object({ innerHasHatch: z.boolean() }),
|
|
150
|
+
async (event, ctx) =>
|
|
151
|
+
ctx.writeAs(
|
|
152
|
+
createSystemUser(event.user.tenantId),
|
|
153
|
+
event.payload.innerHasHatch
|
|
154
|
+
? "idswitch-probe:write:inner-with-hatch"
|
|
155
|
+
: "idswitch-probe:write:inner-no-hatch",
|
|
156
|
+
{},
|
|
157
|
+
),
|
|
158
|
+
{ access: { roles: ["User"] }, escapeHatch: { reason: "test: outer needs SYSTEM" } },
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// --- Hooks: gated independently of the handler they fire on ---
|
|
162
|
+
r.writeHandler(
|
|
163
|
+
"hook-target-with-hatch",
|
|
164
|
+
z.object({ label: z.string() }),
|
|
165
|
+
async (event, ctx) => hookThingExecutor.create(event.payload, event.user, ctx.db),
|
|
166
|
+
{ access: { roles: ["User"] }, escapeHatch: { reason: "test: handler needs SYSTEM" } },
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
r.writeHandler(
|
|
170
|
+
"hook-target-without-hatch",
|
|
171
|
+
z.object({ label: z.string() }),
|
|
172
|
+
async (event, ctx) => hookThingExecutor.create(event.payload, event.user, ctx.db),
|
|
173
|
+
{ access: { roles: ["User"] } },
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Hook on the WITH-hatch handler, but the hook itself declares NO
|
|
177
|
+
// escapeHatch — must NOT inherit the handler's grant.
|
|
178
|
+
r.hook(
|
|
179
|
+
"postSave",
|
|
180
|
+
"hook-target-with-hatch",
|
|
181
|
+
async (_result, ctx) => {
|
|
182
|
+
const handlerCtx = ctx as unknown as HandlerContext;
|
|
183
|
+
try {
|
|
184
|
+
await handlerCtx.writeAs(
|
|
185
|
+
createSystemUser(user.tenantId),
|
|
186
|
+
"idswitch-probe:write:whoami-write",
|
|
187
|
+
{},
|
|
188
|
+
);
|
|
189
|
+
hookNoEscapeHatchOutcomes.push({ threw: false });
|
|
190
|
+
} catch (err) {
|
|
191
|
+
hookNoEscapeHatchOutcomes.push({ threw: true });
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{ phase: HookPhases.inTransaction },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Hook on the WITHOUT-hatch handler, but the hook itself DOES declare
|
|
199
|
+
// escapeHatch — must reach SYSTEM even though the handler can't.
|
|
200
|
+
r.hook(
|
|
201
|
+
"postSave",
|
|
202
|
+
"hook-target-without-hatch",
|
|
203
|
+
async (_result, ctx) => {
|
|
204
|
+
const handlerCtx = ctx as unknown as HandlerContext;
|
|
205
|
+
const res = await handlerCtx.writeAs(
|
|
206
|
+
createSystemUser(user.tenantId),
|
|
207
|
+
"idswitch-probe:write:whoami-write",
|
|
208
|
+
{},
|
|
209
|
+
);
|
|
210
|
+
hookWithEscapeHatchOutcomes.push({ ok: res.isSuccess });
|
|
211
|
+
},
|
|
212
|
+
{ phase: HookPhases.inTransaction, escapeHatch: { reason: "test: hook needs SYSTEM" } },
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const systemScopeFeature = defineFeature("idswitch-probe-system", (r) => {
|
|
217
|
+
r.systemScope();
|
|
218
|
+
|
|
219
|
+
r.writeHandler(
|
|
220
|
+
"write-as-system",
|
|
221
|
+
z.object({}),
|
|
222
|
+
async (event, ctx) =>
|
|
223
|
+
ctx.writeAs(createSystemUser(event.user.tenantId), "idswitch-probe:write:whoami-write", {}),
|
|
224
|
+
{ access: { roles: ["Admin"] } },
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
let stack: TestStack;
|
|
229
|
+
|
|
230
|
+
beforeAll(async () => {
|
|
231
|
+
stack = await setupTestStack({ features: [probeFeature, systemScopeFeature] });
|
|
232
|
+
await unsafeCreateEntityTable(stack.db, hookThingEntity);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
afterAll(async () => {
|
|
236
|
+
await stack.cleanup();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("ctx.writeAs(SYSTEM, ...) — gated by the calling handler's escapeHatch", () => {
|
|
240
|
+
test("WITHOUT escapeHatch: fails with access_denied", async () => {
|
|
241
|
+
const err = await stack.http.writeErr(
|
|
242
|
+
"idswitch-probe:write:write-as-system-no-hatch",
|
|
243
|
+
{},
|
|
244
|
+
user,
|
|
245
|
+
);
|
|
246
|
+
expect(err.code).toBe("access_denied");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("WITH escapeHatch: succeeds", async () => {
|
|
250
|
+
const result = await stack.http.writeOk<{ roles: readonly string[] }>(
|
|
251
|
+
"idswitch-probe:write:write-as-system-with-hatch",
|
|
252
|
+
{},
|
|
253
|
+
user,
|
|
254
|
+
);
|
|
255
|
+
expect(result.roles).toContain("system");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("ctx.queryAs(SYSTEM, ...) — gated by the calling handler's escapeHatch", () => {
|
|
260
|
+
test("WITHOUT escapeHatch: fails with access_denied", async () => {
|
|
261
|
+
const err = await stack.http.queryErr(
|
|
262
|
+
"idswitch-probe:query:query-as-system-no-hatch",
|
|
263
|
+
{},
|
|
264
|
+
user,
|
|
265
|
+
);
|
|
266
|
+
expect(err.code).toBe("access_denied");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("WITH escapeHatch: succeeds", async () => {
|
|
270
|
+
const result = await stack.http.queryOk<{ roles: readonly string[] }>(
|
|
271
|
+
"idswitch-probe:query:query-as-system-with-hatch",
|
|
272
|
+
{},
|
|
273
|
+
user,
|
|
274
|
+
);
|
|
275
|
+
expect(result.roles).toContain("system");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("ctx.queryAs(nonSystemUser, ...) — never gated by this feature", () => {
|
|
280
|
+
test("delegates to the target's own access check: denied when the target user lacks the role", async () => {
|
|
281
|
+
const err = await stack.http.queryErr(
|
|
282
|
+
"idswitch-probe:query:query-as-normal-user",
|
|
283
|
+
{ granted: false },
|
|
284
|
+
user,
|
|
285
|
+
);
|
|
286
|
+
expect(err.code).toBe("access_denied");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("delegates to the target's own access check: allowed when the target user has the role", async () => {
|
|
290
|
+
const result = await stack.http.queryOk<{ ok: boolean }>(
|
|
291
|
+
"idswitch-probe:query:query-as-normal-user",
|
|
292
|
+
{ granted: true },
|
|
293
|
+
user,
|
|
294
|
+
);
|
|
295
|
+
expect(result.ok).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("r.systemScope() feature — no escapeHatch needed", () => {
|
|
300
|
+
test("writeAs(SYSTEM) succeeds from a systemScope handler", async () => {
|
|
301
|
+
const result = await stack.http.writeOk<{ roles: readonly string[] }>(
|
|
302
|
+
"idswitch-probe-system:write:write-as-system",
|
|
303
|
+
{},
|
|
304
|
+
TestUsers.admin,
|
|
305
|
+
);
|
|
306
|
+
expect(result.roles).toContain("system");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("nested identity switches — no transitive grant", () => {
|
|
311
|
+
test("outer-with-hatch -> inner-no-hatch -> target: overall fails with access_denied", async () => {
|
|
312
|
+
const err = await stack.http.writeErr(
|
|
313
|
+
"idswitch-probe:write:outer-with-hatch",
|
|
314
|
+
{ innerHasHatch: false },
|
|
315
|
+
user,
|
|
316
|
+
);
|
|
317
|
+
expect(err.code).toBe("access_denied");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("outer-with-hatch -> inner-with-hatch -> target: succeeds (control)", async () => {
|
|
321
|
+
const result = await stack.http.writeOk<{ roles: readonly string[] }>(
|
|
322
|
+
"idswitch-probe:write:outer-with-hatch",
|
|
323
|
+
{ innerHasHatch: true },
|
|
324
|
+
user,
|
|
325
|
+
);
|
|
326
|
+
expect(result.roles).toContain("system");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe("hooks are gated independently of the handler they fire on", () => {
|
|
331
|
+
test("hook WITHOUT escapeHatch does not inherit the handler's grant: overall write fails", async () => {
|
|
332
|
+
hookNoEscapeHatchOutcomes.length = 0;
|
|
333
|
+
const err = await stack.http.writeErr(
|
|
334
|
+
"idswitch-probe:write:hook-target-with-hatch",
|
|
335
|
+
{ label: "should-roll-back" },
|
|
336
|
+
user,
|
|
337
|
+
);
|
|
338
|
+
expect(err.code).toBe("access_denied");
|
|
339
|
+
expect(hookNoEscapeHatchOutcomes).toEqual([{ threw: true }]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("hook WITH escapeHatch reaches SYSTEM even when the handler itself has none", async () => {
|
|
343
|
+
hookWithEscapeHatchOutcomes.length = 0;
|
|
344
|
+
const result = await stack.http.writeOk<{ id: string }>(
|
|
345
|
+
"idswitch-probe:write:hook-target-without-hatch",
|
|
346
|
+
{ label: "should-commit" },
|
|
347
|
+
user,
|
|
348
|
+
);
|
|
349
|
+
expect(result.id).toBeDefined();
|
|
350
|
+
expect(hookWithEscapeHatchOutcomes).toEqual([{ ok: true }]);
|
|
351
|
+
});
|
|
352
|
+
});
|