@checkstack/backend-api 0.4.0 → 0.5.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/CHANGELOG.md +31 -0
- package/package.json +1 -1
- package/src/core-services.ts +6 -6
- package/src/plugin-system.ts +17 -5
- package/src/rpc.test.ts +183 -22
- package/src/rpc.ts +31 -15
- package/src/test-utils.ts +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @checkstack/backend-api
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 66a3963: Add `SafeDatabase` type to prevent Drizzle relational query API usage at compile-time
|
|
8
|
+
|
|
9
|
+
- Added `SafeDatabase<S>` type that omits the `query` field from Drizzle's `NodePgDatabase`
|
|
10
|
+
- Updated `DatabaseDeps` to use `SafeDatabase` for all plugin database injection
|
|
11
|
+
- Updated `RpcContext.db` and `coreServices.database` to use the safe type
|
|
12
|
+
- Updated test utilities to use `SafeDatabase`
|
|
13
|
+
|
|
14
|
+
This change prevents accidental usage of the relational query API (`db.query`) which is blocked at runtime by the scoped database proxy.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [2c0822d]
|
|
19
|
+
- @checkstack/queue-api@0.2.0
|
|
20
|
+
|
|
21
|
+
## 0.4.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 8a87cd4: Fixed anonymous user access to public endpoints with instance-level access rules
|
|
26
|
+
|
|
27
|
+
The RPC middleware now correctly checks if anonymous users have global access via the anonymous role before denying access to single-resource public endpoints. Also added support for contract-level `instanceAccess` override allowing bulk endpoints to share the same access rule as single endpoints.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [8a87cd4]
|
|
30
|
+
- @checkstack/common@0.5.0
|
|
31
|
+
- @checkstack/queue-api@0.1.3
|
|
32
|
+
- @checkstack/signal-common@0.1.3
|
|
33
|
+
|
|
3
34
|
## 0.4.0
|
|
4
35
|
|
|
5
36
|
### Minor Changes
|
package/package.json
CHANGED
package/src/core-services.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { CollectorRegistry } from "./collector-registry";
|
|
|
5
5
|
import type { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
6
6
|
import type { ConfigService } from "./config-service";
|
|
7
7
|
import type { SignalService } from "@checkstack/signal-common";
|
|
8
|
-
import {
|
|
8
|
+
import { SafeDatabase } from "./plugin-system";
|
|
9
9
|
import {
|
|
10
10
|
Logger,
|
|
11
11
|
Fetch,
|
|
@@ -18,26 +18,26 @@ import type { EventBus } from "./event-bus-types";
|
|
|
18
18
|
export * from "./types";
|
|
19
19
|
|
|
20
20
|
export const authenticationStrategyServiceRef = createServiceRef<unknown>(
|
|
21
|
-
"internal.authenticationStrategy"
|
|
21
|
+
"internal.authenticationStrategy",
|
|
22
22
|
);
|
|
23
23
|
|
|
24
24
|
export const coreServices = {
|
|
25
25
|
database:
|
|
26
|
-
createServiceRef<
|
|
26
|
+
createServiceRef<SafeDatabase<Record<string, unknown>>>("core.database"),
|
|
27
27
|
logger: createServiceRef<Logger>("core.logger"),
|
|
28
28
|
fetch: createServiceRef<Fetch>("core.fetch"),
|
|
29
29
|
auth: createServiceRef<AuthService>("core.auth"),
|
|
30
30
|
healthCheckRegistry: createServiceRef<HealthCheckRegistry>(
|
|
31
|
-
"core.healthCheckRegistry"
|
|
31
|
+
"core.healthCheckRegistry",
|
|
32
32
|
),
|
|
33
33
|
collectorRegistry: createServiceRef<CollectorRegistry>(
|
|
34
|
-
"core.collectorRegistry"
|
|
34
|
+
"core.collectorRegistry",
|
|
35
35
|
),
|
|
36
36
|
pluginInstaller: createServiceRef<PluginInstaller>("core.pluginInstaller"),
|
|
37
37
|
rpc: createServiceRef<RpcService>("core.rpc"),
|
|
38
38
|
rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
|
|
39
39
|
queuePluginRegistry: createServiceRef<QueuePluginRegistry>(
|
|
40
|
-
"core.queuePluginRegistry"
|
|
40
|
+
"core.queuePluginRegistry",
|
|
41
41
|
),
|
|
42
42
|
queueManager: createServiceRef<QueueManager>("core.queueManager"),
|
|
43
43
|
config: createServiceRef<ConfigService>("core.config"),
|
package/src/plugin-system.ts
CHANGED
|
@@ -14,12 +14,24 @@ export type ResolvedDeps<T extends Deps> = {
|
|
|
14
14
|
[K in keyof T]: T[K]["T"];
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Safe database type that omits Drizzle's relational query API.
|
|
19
|
+
* The relational query API bypasses schema isolation and is blocked
|
|
20
|
+
* at runtime by the scoped database proxy. This type prevents
|
|
21
|
+
* accidental usage at compile-time.
|
|
22
|
+
*/
|
|
23
|
+
export type SafeDatabase<S extends Record<string, unknown>> = Omit<
|
|
24
|
+
NodePgDatabase<S>,
|
|
25
|
+
"query"
|
|
26
|
+
>;
|
|
27
|
+
|
|
17
28
|
/**
|
|
18
29
|
* Helper type for database dependency injection.
|
|
19
30
|
* If schema S is provided, adds typed database; otherwise adds nothing.
|
|
31
|
+
* Uses SafeDatabase to prevent relational query API usage.
|
|
20
32
|
*/
|
|
21
33
|
export type DatabaseDeps<S extends Record<string, unknown> | undefined> =
|
|
22
|
-
S extends undefined ? unknown : { database:
|
|
34
|
+
S extends undefined ? unknown : { database: SafeDatabase<NonNullable<S>> };
|
|
23
35
|
|
|
24
36
|
export type PluginContext = {
|
|
25
37
|
pluginId: string;
|
|
@@ -37,7 +49,7 @@ export type AfterPluginsReadyContext = {
|
|
|
37
49
|
onHook: <T>(
|
|
38
50
|
hook: Hook<T>,
|
|
39
51
|
listener: (payload: T) => Promise<void>,
|
|
40
|
-
options?: HookSubscribeOptions
|
|
52
|
+
options?: HookSubscribeOptions,
|
|
41
53
|
) => HookUnsubscribe;
|
|
42
54
|
/**
|
|
43
55
|
* Emit a hook event. Only available in afterPluginsReady phase.
|
|
@@ -48,7 +60,7 @@ export type AfterPluginsReadyContext = {
|
|
|
48
60
|
export type BackendPluginRegistry = {
|
|
49
61
|
registerInit: <
|
|
50
62
|
D extends Deps,
|
|
51
|
-
S extends Record<string, unknown> | undefined = undefined
|
|
63
|
+
S extends Record<string, unknown> | undefined = undefined,
|
|
52
64
|
>(args: {
|
|
53
65
|
deps: D;
|
|
54
66
|
schema?: S;
|
|
@@ -64,7 +76,7 @@ export type BackendPluginRegistry = {
|
|
|
64
76
|
* Receives the same deps as init, plus onHook and emitHook.
|
|
65
77
|
*/
|
|
66
78
|
afterPluginsReady?: (
|
|
67
|
-
deps: ResolvedDeps<D> & DatabaseDeps<S> & AfterPluginsReadyContext
|
|
79
|
+
deps: ResolvedDeps<D> & DatabaseDeps<S> & AfterPluginsReadyContext,
|
|
68
80
|
) => Promise<void>;
|
|
69
81
|
}) => void;
|
|
70
82
|
registerService: <S>(ref: ServiceRef<S>, impl: S) => void;
|
|
@@ -80,7 +92,7 @@ export type BackendPluginRegistry = {
|
|
|
80
92
|
*/
|
|
81
93
|
registerRouter: <C extends AnyContractRouter>(
|
|
82
94
|
router: Router<C, RpcContext>,
|
|
83
|
-
contract: C
|
|
95
|
+
contract: C,
|
|
84
96
|
) => void;
|
|
85
97
|
/**
|
|
86
98
|
* Register cleanup logic to be called when the plugin is deregistered.
|
package/src/rpc.test.ts
CHANGED
|
@@ -35,14 +35,17 @@ const testContracts = {
|
|
|
35
35
|
access: [
|
|
36
36
|
accessPair(
|
|
37
37
|
"system",
|
|
38
|
-
{
|
|
39
|
-
|
|
38
|
+
{
|
|
39
|
+
read: { description: "View systems", isPublic: true },
|
|
40
|
+
manage: { description: "Manage systems" },
|
|
41
|
+
},
|
|
42
|
+
{ listKey: "systems" },
|
|
40
43
|
).read,
|
|
41
44
|
],
|
|
42
45
|
}).output(
|
|
43
46
|
z.object({
|
|
44
47
|
systems: z.array(z.object({ id: z.string(), name: z.string() })),
|
|
45
|
-
})
|
|
48
|
+
}),
|
|
46
49
|
),
|
|
47
50
|
|
|
48
51
|
// Authenticated endpoint
|
|
@@ -73,8 +76,11 @@ const testContracts = {
|
|
|
73
76
|
access: [
|
|
74
77
|
accessPair(
|
|
75
78
|
"system",
|
|
76
|
-
{
|
|
77
|
-
|
|
79
|
+
{
|
|
80
|
+
read: { description: "View systems", isPublic: true },
|
|
81
|
+
manage: { description: "Manage systems" },
|
|
82
|
+
},
|
|
83
|
+
{ idParam: "systemId" },
|
|
78
84
|
).read,
|
|
79
85
|
],
|
|
80
86
|
})
|
|
@@ -96,7 +102,7 @@ const testContracts = {
|
|
|
96
102
|
.output(
|
|
97
103
|
z.object({
|
|
98
104
|
statuses: z.record(z.string(), z.object({ status: z.string() })),
|
|
99
|
-
})
|
|
105
|
+
}),
|
|
100
106
|
),
|
|
101
107
|
|
|
102
108
|
// Mutation endpoint
|
|
@@ -107,6 +113,33 @@ const testContracts = {
|
|
|
107
113
|
})
|
|
108
114
|
.input(z.object({ name: z.string() }))
|
|
109
115
|
.output(z.object({ id: z.string() })),
|
|
116
|
+
|
|
117
|
+
// Bulk record endpoint using instanceAccess OVERRIDE at contract level
|
|
118
|
+
// This tests the pattern where bulk endpoints share the same access rule
|
|
119
|
+
// as single endpoints but use recordKey instead of idParam
|
|
120
|
+
bulkRecordWithOverride: proc({
|
|
121
|
+
userType: "public",
|
|
122
|
+
operationType: "query",
|
|
123
|
+
// Uses same access rule as singleResourceEndpoint (has idParam)
|
|
124
|
+
// but overrides instanceAccess to use recordKey
|
|
125
|
+
access: [
|
|
126
|
+
accessPair(
|
|
127
|
+
"system",
|
|
128
|
+
{
|
|
129
|
+
read: { description: "View systems", isPublic: true },
|
|
130
|
+
manage: { description: "Manage systems" },
|
|
131
|
+
},
|
|
132
|
+
{ idParam: "systemId" },
|
|
133
|
+
).read,
|
|
134
|
+
],
|
|
135
|
+
instanceAccess: { recordKey: "statuses" },
|
|
136
|
+
})
|
|
137
|
+
.input(z.object({ systemIds: z.array(z.string()) }))
|
|
138
|
+
.output(
|
|
139
|
+
z.object({
|
|
140
|
+
statuses: z.record(z.string(), z.object({ status: z.string() })),
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
110
143
|
};
|
|
111
144
|
|
|
112
145
|
// =============================================================================
|
|
@@ -121,7 +154,7 @@ const testImplementations = {
|
|
|
121
154
|
publicGlobalEndpoint: implement(testContracts.publicGlobalEndpoint).handler(
|
|
122
155
|
() => ({
|
|
123
156
|
message: "Hello from public global",
|
|
124
|
-
})
|
|
157
|
+
}),
|
|
125
158
|
),
|
|
126
159
|
|
|
127
160
|
publicListEndpoint: implement(testContracts.publicListEndpoint).handler(
|
|
@@ -131,13 +164,13 @@ const testImplementations = {
|
|
|
131
164
|
{ id: "system-2", name: "System 2" },
|
|
132
165
|
{ id: "system-3", name: "System 3" },
|
|
133
166
|
],
|
|
134
|
-
})
|
|
167
|
+
}),
|
|
135
168
|
),
|
|
136
169
|
|
|
137
170
|
authenticatedEndpoint: implement(testContracts.authenticatedEndpoint).handler(
|
|
138
171
|
() => ({
|
|
139
172
|
message: "Hello from authenticated",
|
|
140
|
-
})
|
|
173
|
+
}),
|
|
141
174
|
),
|
|
142
175
|
|
|
143
176
|
userOnlyEndpoint: implement(testContracts.userOnlyEndpoint).handler(() => ({
|
|
@@ -147,11 +180,11 @@ const testImplementations = {
|
|
|
147
180
|
serviceOnlyEndpoint: implement(testContracts.serviceOnlyEndpoint).handler(
|
|
148
181
|
() => ({
|
|
149
182
|
message: "Hello from service",
|
|
150
|
-
})
|
|
183
|
+
}),
|
|
151
184
|
),
|
|
152
185
|
|
|
153
186
|
singleResourceEndpoint: implement(
|
|
154
|
-
testContracts.singleResourceEndpoint
|
|
187
|
+
testContracts.singleResourceEndpoint,
|
|
155
188
|
).handler(({ input }) => ({
|
|
156
189
|
system: { id: input.systemId },
|
|
157
190
|
})),
|
|
@@ -159,14 +192,22 @@ const testImplementations = {
|
|
|
159
192
|
recordEndpoint: implement(testContracts.recordEndpoint).handler(
|
|
160
193
|
({ input }) => ({
|
|
161
194
|
statuses: Object.fromEntries(
|
|
162
|
-
input.systemIds.map((id) => [id, { status: "ok" }])
|
|
195
|
+
input.systemIds.map((id) => [id, { status: "ok" }]),
|
|
163
196
|
),
|
|
164
|
-
})
|
|
197
|
+
}),
|
|
165
198
|
),
|
|
166
199
|
|
|
167
200
|
mutationEndpoint: implement(testContracts.mutationEndpoint).handler(() => ({
|
|
168
201
|
id: "new-id",
|
|
169
202
|
})),
|
|
203
|
+
|
|
204
|
+
bulkRecordWithOverride: implement(
|
|
205
|
+
testContracts.bulkRecordWithOverride,
|
|
206
|
+
).handler(({ input }) => ({
|
|
207
|
+
statuses: Object.fromEntries(
|
|
208
|
+
input.systemIds.map((id) => [id, { status: "ok" }]),
|
|
209
|
+
),
|
|
210
|
+
})),
|
|
170
211
|
};
|
|
171
212
|
|
|
172
213
|
// =============================================================================
|
|
@@ -229,7 +270,7 @@ describe("autoAuthMiddleware", () => {
|
|
|
229
270
|
.handler(() => ({ message: "success" }));
|
|
230
271
|
|
|
231
272
|
expect(
|
|
232
|
-
call(procedure, undefined, { context: contextWithNoAccess })
|
|
273
|
+
call(procedure, undefined, { context: contextWithNoAccess }),
|
|
233
274
|
).rejects.toThrow();
|
|
234
275
|
});
|
|
235
276
|
|
|
@@ -283,7 +324,7 @@ describe("autoAuthMiddleware", () => {
|
|
|
283
324
|
expect(
|
|
284
325
|
call(procedure, undefined, {
|
|
285
326
|
context: { ...mockContext, user: undefined },
|
|
286
|
-
})
|
|
327
|
+
}),
|
|
287
328
|
).rejects.toThrow("Authentication required");
|
|
288
329
|
});
|
|
289
330
|
});
|
|
@@ -316,7 +357,7 @@ describe("autoAuthMiddleware", () => {
|
|
|
316
357
|
...mockContext,
|
|
317
358
|
user: { type: "service" as const, pluginId: "test-service" },
|
|
318
359
|
},
|
|
319
|
-
})
|
|
360
|
+
}),
|
|
320
361
|
).rejects.toThrow("This endpoint is for users only");
|
|
321
362
|
});
|
|
322
363
|
});
|
|
@@ -349,7 +390,7 @@ describe("autoAuthMiddleware", () => {
|
|
|
349
390
|
.handler(() => ({ message: "success" }));
|
|
350
391
|
|
|
351
392
|
expect(
|
|
352
|
-
call(procedure, undefined, { context: mockContext })
|
|
393
|
+
call(procedure, undefined, { context: mockContext }),
|
|
353
394
|
).rejects.toThrow("This endpoint is for services only");
|
|
354
395
|
});
|
|
355
396
|
});
|
|
@@ -368,7 +409,7 @@ describe("autoAuthMiddleware", () => {
|
|
|
368
409
|
const result = await call(
|
|
369
410
|
procedure,
|
|
370
411
|
{ systemId: "test-123" },
|
|
371
|
-
{ context: mockContext }
|
|
412
|
+
{ context: mockContext },
|
|
372
413
|
);
|
|
373
414
|
|
|
374
415
|
expect(result).toEqual({ system: { id: "test-123" } });
|
|
@@ -394,10 +435,62 @@ describe("autoAuthMiddleware", () => {
|
|
|
394
435
|
call(
|
|
395
436
|
procedure,
|
|
396
437
|
{ systemId: "forbidden-id" },
|
|
397
|
-
{ context: contextWithNoAccess }
|
|
398
|
-
)
|
|
438
|
+
{ context: contextWithNoAccess },
|
|
439
|
+
),
|
|
399
440
|
).rejects.toThrow();
|
|
400
441
|
});
|
|
442
|
+
|
|
443
|
+
// Regression tests for anonymous user access to single resources (issue: 403 for public endpoints)
|
|
444
|
+
it("should allow anonymous users with global access to single resource endpoints", async () => {
|
|
445
|
+
// Mock anonymous access rules to include the required access
|
|
446
|
+
const contextWithAnonymousAccess = {
|
|
447
|
+
...mockContext,
|
|
448
|
+
user: undefined,
|
|
449
|
+
auth: {
|
|
450
|
+
...mockContext.auth,
|
|
451
|
+
getAnonymousAccessRules: () =>
|
|
452
|
+
Promise.resolve(["test-plugin.system.read"]),
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const procedure = implement(testContracts.singleResourceEndpoint)
|
|
457
|
+
.$context<RpcContext>()
|
|
458
|
+
.use(autoAuthMiddleware)
|
|
459
|
+
.handler(({ input }) => ({ system: { id: input.systemId } }));
|
|
460
|
+
|
|
461
|
+
const result = await call(
|
|
462
|
+
procedure,
|
|
463
|
+
{ systemId: "system-123" },
|
|
464
|
+
{ context: contextWithAnonymousAccess },
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
expect(result).toEqual({ system: { id: "system-123" } });
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("should deny anonymous users without global access to single resource endpoints", async () => {
|
|
471
|
+
// Mock anonymous access rules to NOT include the required access
|
|
472
|
+
const contextWithoutAnonymousAccess = {
|
|
473
|
+
...mockContext,
|
|
474
|
+
user: undefined,
|
|
475
|
+
auth: {
|
|
476
|
+
...mockContext.auth,
|
|
477
|
+
getAnonymousAccessRules: () => Promise.resolve([]),
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const procedure = implement(testContracts.singleResourceEndpoint)
|
|
482
|
+
.$context<RpcContext>()
|
|
483
|
+
.use(autoAuthMiddleware)
|
|
484
|
+
.handler(({ input }) => ({ system: { id: input.systemId } }));
|
|
485
|
+
|
|
486
|
+
expect(
|
|
487
|
+
call(
|
|
488
|
+
procedure,
|
|
489
|
+
{ systemId: "system-123" },
|
|
490
|
+
{ context: contextWithoutAnonymousAccess },
|
|
491
|
+
),
|
|
492
|
+
).rejects.toThrow("Authentication required to access");
|
|
493
|
+
});
|
|
401
494
|
});
|
|
402
495
|
|
|
403
496
|
// ---------------------------------------------------------------------------
|
|
@@ -433,17 +526,85 @@ describe("autoAuthMiddleware", () => {
|
|
|
433
526
|
.use(autoAuthMiddleware)
|
|
434
527
|
.handler(({ input }) => ({
|
|
435
528
|
statuses: Object.fromEntries(
|
|
436
|
-
input.systemIds.map((id) => [id, { status: "ok" }])
|
|
529
|
+
input.systemIds.map((id) => [id, { status: "ok" }]),
|
|
437
530
|
),
|
|
438
531
|
}));
|
|
439
532
|
|
|
440
533
|
const result = await call(
|
|
441
534
|
procedure,
|
|
442
535
|
{ systemIds: ["sys-1", "sys-2"] },
|
|
443
|
-
{ context: mockContext }
|
|
536
|
+
{ context: mockContext },
|
|
444
537
|
);
|
|
445
538
|
|
|
446
539
|
expect(Object.keys(result.statuses)).toHaveLength(2);
|
|
447
540
|
});
|
|
448
541
|
});
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Contract-level instanceAccess Override
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
describe("instanceAccess override at contract level", () => {
|
|
548
|
+
it("should use contract-level instanceAccess instead of access rule instanceAccess", async () => {
|
|
549
|
+
// This test verifies that bulk endpoints can share the same access rule
|
|
550
|
+
// as single endpoints, using instanceAccess override at contract level
|
|
551
|
+
const contextWithAnonymousAccess = {
|
|
552
|
+
...mockContext,
|
|
553
|
+
user: undefined,
|
|
554
|
+
auth: {
|
|
555
|
+
...mockContext.auth,
|
|
556
|
+
getAnonymousAccessRules: () =>
|
|
557
|
+
Promise.resolve(["test-plugin.system.read"]),
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const procedure = implement(testContracts.bulkRecordWithOverride)
|
|
562
|
+
.$context<RpcContext>()
|
|
563
|
+
.use(autoAuthMiddleware)
|
|
564
|
+
.handler(({ input }) => ({
|
|
565
|
+
statuses: Object.fromEntries(
|
|
566
|
+
input.systemIds.map((id) => [id, { status: "ok" }]),
|
|
567
|
+
),
|
|
568
|
+
}));
|
|
569
|
+
|
|
570
|
+
// Should use recordKey filtering (from contract override),
|
|
571
|
+
// not idParam check (from access rule)
|
|
572
|
+
const result = await call(
|
|
573
|
+
procedure,
|
|
574
|
+
{ systemIds: ["sys-1", "sys-2"] },
|
|
575
|
+
{ context: contextWithAnonymousAccess },
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
expect(Object.keys(result.statuses)).toHaveLength(2);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("should deny anonymous users without access on override endpoints", async () => {
|
|
582
|
+
const contextWithoutAccess = {
|
|
583
|
+
...mockContext,
|
|
584
|
+
user: undefined,
|
|
585
|
+
auth: {
|
|
586
|
+
...mockContext.auth,
|
|
587
|
+
getAnonymousAccessRules: () => Promise.resolve([]),
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const procedure = implement(testContracts.bulkRecordWithOverride)
|
|
592
|
+
.$context<RpcContext>()
|
|
593
|
+
.use(autoAuthMiddleware)
|
|
594
|
+
.handler(({ input }) => ({
|
|
595
|
+
statuses: Object.fromEntries(
|
|
596
|
+
input.systemIds.map((id) => [id, { status: "ok" }]),
|
|
597
|
+
),
|
|
598
|
+
}));
|
|
599
|
+
|
|
600
|
+
// Without access, should return empty record (filtered out)
|
|
601
|
+
const result = await call(
|
|
602
|
+
procedure,
|
|
603
|
+
{ systemIds: ["sys-1", "sys-2"] },
|
|
604
|
+
{ context: contextWithoutAccess },
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
expect(Object.keys(result.statuses)).toHaveLength(0);
|
|
608
|
+
});
|
|
609
|
+
});
|
|
449
610
|
});
|
package/src/rpc.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
qualifyAccessRuleId,
|
|
9
9
|
qualifyResourceType,
|
|
10
10
|
} from "@checkstack/common";
|
|
11
|
-
import {
|
|
11
|
+
import { SafeDatabase } from "./plugin-system";
|
|
12
12
|
import {
|
|
13
13
|
Logger,
|
|
14
14
|
Fetch,
|
|
@@ -36,7 +36,7 @@ export interface RpcContext {
|
|
|
36
36
|
*/
|
|
37
37
|
pluginMetadata: PluginMetadata;
|
|
38
38
|
|
|
39
|
-
db:
|
|
39
|
+
db: SafeDatabase<Record<string, unknown>>;
|
|
40
40
|
logger: Logger;
|
|
41
41
|
fetch: Fetch;
|
|
42
42
|
auth: AuthService;
|
|
@@ -97,14 +97,19 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
97
97
|
const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
|
|
98
98
|
const requiredUserType = meta?.userType || "authenticated";
|
|
99
99
|
const accessRules = meta?.access || [];
|
|
100
|
+
// Contract-level instanceAccess override (used for bulk endpoints)
|
|
101
|
+
const instanceAccessOverride = meta?.instanceAccess;
|
|
100
102
|
|
|
101
103
|
// Build qualified access rule IDs for each access rule
|
|
104
|
+
// If contract has instanceAccess override, apply it to ALL rules
|
|
102
105
|
const qualifiedRules = accessRules.map((rule) => ({
|
|
103
106
|
...rule,
|
|
107
|
+
// Use contract-level override if provided, otherwise use rule's config
|
|
108
|
+
instanceAccess: instanceAccessOverride ?? rule.instanceAccess,
|
|
104
109
|
qualifiedId: qualifyAccessRuleId(context.pluginMetadata, rule),
|
|
105
110
|
qualifiedResourceType: qualifyResourceType(
|
|
106
111
|
context.pluginMetadata.pluginId,
|
|
107
|
-
rule.resource
|
|
112
|
+
rule.resource,
|
|
108
113
|
),
|
|
109
114
|
}));
|
|
110
115
|
|
|
@@ -115,13 +120,13 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
115
120
|
(r) =>
|
|
116
121
|
r.instanceAccess?.idParam &&
|
|
117
122
|
!r.instanceAccess?.listKey &&
|
|
118
|
-
!r.instanceAccess?.recordKey
|
|
123
|
+
!r.instanceAccess?.recordKey,
|
|
119
124
|
);
|
|
120
125
|
const listResourceRules = instanceRules.filter(
|
|
121
|
-
(r) => r.instanceAccess?.listKey
|
|
126
|
+
(r) => r.instanceAccess?.listKey,
|
|
122
127
|
);
|
|
123
128
|
const recordResourceRules = instanceRules.filter(
|
|
124
|
-
(r) => r.instanceAccess?.recordKey
|
|
129
|
+
(r) => r.instanceAccess?.recordKey,
|
|
125
130
|
);
|
|
126
131
|
|
|
127
132
|
// 1. Handle anonymous endpoints - no auth required, no access checks
|
|
@@ -229,9 +234,20 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
229
234
|
const resourceId = getNestedValue(input, rule.instanceAccess!.idParam!);
|
|
230
235
|
if (!resourceId) continue;
|
|
231
236
|
|
|
232
|
-
// If no user (anonymous on public endpoint),
|
|
233
|
-
// (they can't have team grants)
|
|
237
|
+
// If no user (anonymous on public endpoint), check if anonymous role has global access
|
|
234
238
|
if (!userId || !userType) {
|
|
239
|
+
const anonymousAccessRules =
|
|
240
|
+
await context.auth.getAnonymousAccessRules();
|
|
241
|
+
const hasGlobalAccess =
|
|
242
|
+
anonymousAccessRules.includes("*") ||
|
|
243
|
+
anonymousAccessRules.includes(rule.qualifiedId);
|
|
244
|
+
|
|
245
|
+
if (hasGlobalAccess) {
|
|
246
|
+
// Anonymous user has global access - allow access to this resource
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// No global access and can't have team grants - deny access
|
|
235
251
|
throw new ORPCError("FORBIDDEN", {
|
|
236
252
|
message: `Authentication required to access ${rule.resource}:${resourceId}`,
|
|
237
253
|
});
|
|
@@ -276,7 +292,7 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
276
292
|
|
|
277
293
|
if (items === undefined) {
|
|
278
294
|
context.logger.error(
|
|
279
|
-
`resourceAccess: expected "${outputKey}" in response but not found
|
|
295
|
+
`resourceAccess: expected "${outputKey}" in response but not found`,
|
|
280
296
|
);
|
|
281
297
|
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
282
298
|
message: "Invalid response shape for filtered endpoint",
|
|
@@ -285,7 +301,7 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
285
301
|
|
|
286
302
|
if (!Array.isArray(items)) {
|
|
287
303
|
context.logger.error(
|
|
288
|
-
`resourceAccess: "${outputKey}" must be an array
|
|
304
|
+
`resourceAccess: "${outputKey}" must be an array`,
|
|
289
305
|
);
|
|
290
306
|
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
291
307
|
message: "Invalid response shape for filtered endpoint",
|
|
@@ -352,7 +368,7 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
352
368
|
|
|
353
369
|
if (record === undefined) {
|
|
354
370
|
context.logger.error(
|
|
355
|
-
`resourceAccess: expected "${outputKey}" in response but not found
|
|
371
|
+
`resourceAccess: expected "${outputKey}" in response but not found`,
|
|
356
372
|
);
|
|
357
373
|
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
358
374
|
message: "Invalid response shape for filtered endpoint",
|
|
@@ -365,7 +381,7 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
365
381
|
Array.isArray(record)
|
|
366
382
|
) {
|
|
367
383
|
context.logger.error(
|
|
368
|
-
`resourceAccess: "${outputKey}" must be an object (record)
|
|
384
|
+
`resourceAccess: "${outputKey}" must be an object (record)`,
|
|
369
385
|
);
|
|
370
386
|
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
371
387
|
message: "Invalid response shape for filtered endpoint",
|
|
@@ -419,7 +435,7 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
419
435
|
}
|
|
420
436
|
|
|
421
437
|
return result;
|
|
422
|
-
}
|
|
438
|
+
},
|
|
423
439
|
);
|
|
424
440
|
|
|
425
441
|
/**
|
|
@@ -562,7 +578,7 @@ export interface RpcService {
|
|
|
562
578
|
*/
|
|
563
579
|
registerRouter<C extends AnyContractRouter>(
|
|
564
580
|
router: Router<C, RpcContext>,
|
|
565
|
-
contract: C
|
|
581
|
+
contract: C,
|
|
566
582
|
): void;
|
|
567
583
|
|
|
568
584
|
/**
|
|
@@ -574,7 +590,7 @@ export interface RpcService {
|
|
|
574
590
|
*/
|
|
575
591
|
registerHttpHandler(
|
|
576
592
|
handler: (req: Request) => Promise<Response>,
|
|
577
|
-
path?: string
|
|
593
|
+
path?: string,
|
|
578
594
|
): void;
|
|
579
595
|
}
|
|
580
596
|
|
package/src/test-utils.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mock } from "bun:test";
|
|
2
2
|
import { RpcContext, EmitHookFn } from "./rpc";
|
|
3
|
-
import {
|
|
3
|
+
import { SafeDatabase } from "./plugin-system";
|
|
4
4
|
import { HealthCheckRegistry } from "./health-check";
|
|
5
5
|
import { CollectorRegistry } from "./collector-registry";
|
|
6
6
|
import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
@@ -10,11 +10,11 @@ import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
|
10
10
|
* By default provides an authenticated user with wildcard access.
|
|
11
11
|
*/
|
|
12
12
|
export function createMockRpcContext(
|
|
13
|
-
overrides: Partial<RpcContext> = {}
|
|
13
|
+
overrides: Partial<RpcContext> = {},
|
|
14
14
|
): RpcContext {
|
|
15
15
|
return {
|
|
16
16
|
pluginMetadata: { pluginId: "test-plugin" },
|
|
17
|
-
db: mock() as unknown as
|
|
17
|
+
db: mock() as unknown as SafeDatabase<Record<string, unknown>>,
|
|
18
18
|
logger: {
|
|
19
19
|
info: mock(),
|
|
20
20
|
error: mock(),
|
|
@@ -39,7 +39,7 @@ export function createMockRpcContext(
|
|
|
39
39
|
checkResourceTeamAccess: mock().mockResolvedValue({ hasAccess: true }),
|
|
40
40
|
getAccessibleResourceIds: mock().mockImplementation(
|
|
41
41
|
(params: { resourceIds: string[] }) =>
|
|
42
|
-
Promise.resolve(params.resourceIds)
|
|
42
|
+
Promise.resolve(params.resourceIds),
|
|
43
43
|
),
|
|
44
44
|
},
|
|
45
45
|
healthCheckRegistry: {
|