@checkstack/backend-api 0.1.0 → 0.3.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 +189 -0
- package/package.json +1 -1
- package/src/assertions.test.ts +128 -0
- package/src/assertions.ts +77 -2
- package/src/chart-metadata.ts +1 -24
- package/src/collector-registry.ts +2 -0
- package/src/hooks.ts +6 -6
- package/src/notification-strategy.ts +5 -5
- package/src/plugin-admin-contract.ts +13 -15
- package/src/plugin-system.ts +6 -3
- package/src/rpc.test.ts +530 -0
- package/src/rpc.ts +308 -93
- package/src/test-utils.ts +6 -1
- package/src/types.ts +34 -9
package/src/rpc.ts
CHANGED
|
@@ -3,7 +3,11 @@ import { AnyContractRouter } from "@orpc/contract";
|
|
|
3
3
|
import { HealthCheckRegistry } from "./health-check";
|
|
4
4
|
import { CollectorRegistry } from "./collector-registry";
|
|
5
5
|
import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ProcedureMetadata,
|
|
8
|
+
qualifyAccessRuleId,
|
|
9
|
+
qualifyResourceType,
|
|
10
|
+
} from "@checkstack/common";
|
|
7
11
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
8
12
|
import {
|
|
9
13
|
Logger,
|
|
@@ -73,98 +77,101 @@ export type { ProcedureMetadata } from "@checkstack/common";
|
|
|
73
77
|
*
|
|
74
78
|
* Automatically enforces based on contract metadata:
|
|
75
79
|
* 1. User type (from meta.userType):
|
|
76
|
-
* - "anonymous": No authentication required, no
|
|
77
|
-
* - "public": Anyone can attempt,
|
|
80
|
+
* - "anonymous": No authentication required, no access checks
|
|
81
|
+
* - "public": Anyone can attempt, access checked based on user type
|
|
78
82
|
* - "user": Only real users (frontend authenticated)
|
|
79
83
|
* - "service": Only services (backend-to-backend)
|
|
80
84
|
* - "authenticated": Either users or services, but must be authenticated (default)
|
|
81
|
-
* 2.
|
|
85
|
+
* 2. Access rules (from meta.access): unified access rules + resource-level access control
|
|
86
|
+
*
|
|
87
|
+
* Access Control Logic:
|
|
88
|
+
* - Rules WITHOUT instanceAccess: require global access
|
|
89
|
+
* - Rules WITH instanceAccess: S2S call to auth-backend decides based on:
|
|
90
|
+
* - Global access OR team grants (when resource is NOT teamOnly)
|
|
91
|
+
* - Team grants only (when resource IS teamOnly)
|
|
82
92
|
*
|
|
83
93
|
* Use this in backend routers: `implement(contract).$context<RpcContext>().use(autoAuthMiddleware)`
|
|
84
94
|
*/
|
|
85
95
|
export const autoAuthMiddleware = os.middleware(
|
|
86
|
-
async ({ next, context, procedure }) => {
|
|
96
|
+
async ({ next, context, procedure }, input: unknown) => {
|
|
87
97
|
const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
|
|
88
98
|
const requiredUserType = meta?.userType || "authenticated";
|
|
89
|
-
const
|
|
99
|
+
const accessRules = meta?.access || [];
|
|
90
100
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// Build qualified access rule IDs for each access rule
|
|
102
|
+
const qualifiedRules = accessRules.map((rule) => ({
|
|
103
|
+
...rule,
|
|
104
|
+
qualifiedId: qualifyAccessRuleId(context.pluginMetadata, rule),
|
|
105
|
+
qualifiedResourceType: qualifyResourceType(
|
|
106
|
+
context.pluginMetadata.pluginId,
|
|
107
|
+
rule.resource
|
|
108
|
+
),
|
|
109
|
+
}));
|
|
96
110
|
|
|
97
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
code: error.code,
|
|
107
|
-
message: error.message,
|
|
108
|
-
data: error.data,
|
|
109
|
-
});
|
|
110
|
-
} else {
|
|
111
|
-
// Unexpected error - log at error level with full stack trace
|
|
112
|
-
context.logger.error("Unexpected RPC error:", error);
|
|
113
|
-
}
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
};
|
|
111
|
+
// Separate rules by type
|
|
112
|
+
const globalOnlyRules = qualifiedRules.filter((r) => !r.instanceAccess);
|
|
113
|
+
const instanceRules = qualifiedRules.filter((r) => r.instanceAccess);
|
|
114
|
+
const singleResourceRules = instanceRules.filter(
|
|
115
|
+
(r) => r.instanceAccess?.idParam
|
|
116
|
+
);
|
|
117
|
+
const listResourceRules = instanceRules.filter(
|
|
118
|
+
(r) => r.instanceAccess?.listKey
|
|
119
|
+
);
|
|
117
120
|
|
|
118
|
-
// 1. Handle anonymous endpoints - no auth required, no
|
|
121
|
+
// 1. Handle anonymous endpoints - no auth required, no access checks
|
|
119
122
|
if (requiredUserType === "anonymous") {
|
|
120
|
-
return
|
|
123
|
+
return next({});
|
|
121
124
|
}
|
|
122
125
|
|
|
123
|
-
// 2. Handle public endpoints - anyone can attempt
|
|
126
|
+
// 2. Handle public endpoints - anyone can attempt
|
|
124
127
|
if (requiredUserType === "public") {
|
|
125
|
-
|
|
126
|
-
// Authenticated user or application - check their permissions
|
|
127
|
-
if (
|
|
128
|
-
(context.user.type === "user" ||
|
|
129
|
-
context.user.type === "application") &&
|
|
130
|
-
requiredPermissions.length > 0
|
|
131
|
-
) {
|
|
132
|
-
const userPermissions = context.user.permissions || [];
|
|
133
|
-
const hasPermission = requiredPermissions.some(
|
|
134
|
-
(p: string) =>
|
|
135
|
-
userPermissions.includes("*") || userPermissions.includes(p)
|
|
136
|
-
);
|
|
128
|
+
const user = context.user;
|
|
137
129
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
130
|
+
// Check global-only rules based on user status
|
|
131
|
+
if (globalOnlyRules.length > 0) {
|
|
132
|
+
if (user && (user.type === "user" || user.type === "application")) {
|
|
133
|
+
// Authenticated user - check their global accesss
|
|
134
|
+
const userAccessRules = user.accessRules || [];
|
|
135
|
+
for (const rule of globalOnlyRules) {
|
|
136
|
+
const hasAccess =
|
|
137
|
+
userAccessRules.includes("*") ||
|
|
138
|
+
userAccessRules.includes(rule.qualifiedId);
|
|
139
|
+
if (!hasAccess) {
|
|
140
|
+
throw new ORPCError("FORBIDDEN", {
|
|
141
|
+
message: `Missing access: ${rule.qualifiedId}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
144
|
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
throw new ORPCError("FORBIDDEN", {
|
|
158
|
-
message: `Anonymous access not permitted for this resource`,
|
|
159
|
-
});
|
|
145
|
+
} else if (user && user.type === "service") {
|
|
146
|
+
// Services are trusted
|
|
147
|
+
} else {
|
|
148
|
+
// Anonymous - check anonymous role
|
|
149
|
+
const anonymousAccessRules =
|
|
150
|
+
await context.auth.getAnonymousAccessRules();
|
|
151
|
+
for (const rule of globalOnlyRules) {
|
|
152
|
+
if (!anonymousAccessRules.includes(rule.qualifiedId)) {
|
|
153
|
+
throw new ORPCError("FORBIDDEN", {
|
|
154
|
+
message: `Anonymous access not permitted for this resource`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
160
157
|
}
|
|
161
158
|
}
|
|
162
159
|
}
|
|
163
|
-
|
|
160
|
+
|
|
161
|
+
// For rules WITH instanceAccess on public endpoints:
|
|
162
|
+
// - If user is authenticated, check via S2S (step 6)
|
|
163
|
+
// - If anonymous, they get empty results from list filtering
|
|
164
|
+
// (since they have no team grants - S2S will filter everything)
|
|
165
|
+
|
|
166
|
+
// If there are no instance rules, we can return early for public endpoints
|
|
167
|
+
if (instanceRules.length === 0) {
|
|
168
|
+
return next({});
|
|
169
|
+
}
|
|
170
|
+
// Otherwise, fall through to step 6 for instance-level checks
|
|
164
171
|
}
|
|
165
172
|
|
|
166
173
|
// 3. Enforce authentication for user/service/authenticated types
|
|
167
|
-
if (!context.user) {
|
|
174
|
+
if (requiredUserType !== "public" && !context.user) {
|
|
168
175
|
throw new ORPCError("UNAUTHORIZED", {
|
|
169
176
|
message: "Authentication required",
|
|
170
177
|
});
|
|
@@ -173,40 +180,248 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
173
180
|
const user = context.user;
|
|
174
181
|
|
|
175
182
|
// 4. Enforce user type
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
if (user) {
|
|
184
|
+
if (requiredUserType === "user" && user.type !== "user") {
|
|
185
|
+
throw new ORPCError("FORBIDDEN", {
|
|
186
|
+
message: "This endpoint is for users only",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (requiredUserType === "service" && user.type !== "service") {
|
|
190
|
+
throw new ORPCError("FORBIDDEN", {
|
|
191
|
+
message: "This endpoint is for services only",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 5. Skip remaining checks for services - they are trusted
|
|
197
|
+
if (user?.type === "service") {
|
|
198
|
+
return next({});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 6. Check access rules (for real users, applications, and anonymous on public endpoints)
|
|
202
|
+
const userId = user?.id;
|
|
203
|
+
const userType = user?.type as "user" | "application" | undefined;
|
|
204
|
+
const userAccessRules = user?.accessRules || [];
|
|
205
|
+
|
|
206
|
+
// Check global-only rules (for non-public endpoints - public already handled above)
|
|
207
|
+
if (requiredUserType !== "public") {
|
|
208
|
+
for (const rule of globalOnlyRules) {
|
|
209
|
+
const hasAccess =
|
|
210
|
+
userAccessRules.includes("*") ||
|
|
211
|
+
userAccessRules.includes(rule.qualifiedId);
|
|
212
|
+
if (!hasAccess) {
|
|
213
|
+
throw new ORPCError("FORBIDDEN", {
|
|
214
|
+
message: `Missing access: ${rule.qualifiedId}`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
180
218
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
|
|
220
|
+
// Pre-check: Single resource access rules
|
|
221
|
+
// For these, user MUST have either global access OR team grant
|
|
222
|
+
for (const rule of singleResourceRules) {
|
|
223
|
+
const resourceId = getNestedValue(input, rule.instanceAccess!.idParam!);
|
|
224
|
+
if (!resourceId) continue;
|
|
225
|
+
|
|
226
|
+
// If no user (anonymous on public endpoint), deny access to single resources
|
|
227
|
+
// (they can't have team grants)
|
|
228
|
+
if (!userId || !userType) {
|
|
229
|
+
throw new ORPCError("FORBIDDEN", {
|
|
230
|
+
message: `Authentication required to access ${rule.resource}:${resourceId}`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const hasGlobalAccess =
|
|
235
|
+
userAccessRules.includes("*") ||
|
|
236
|
+
userAccessRules.includes(rule.qualifiedId);
|
|
237
|
+
|
|
238
|
+
const hasAccess = await checkResourceAccessViaS2S({
|
|
239
|
+
auth: context.auth,
|
|
240
|
+
userId,
|
|
241
|
+
userType,
|
|
242
|
+
resourceType: rule.qualifiedResourceType,
|
|
243
|
+
resourceId,
|
|
244
|
+
action: rule.level,
|
|
245
|
+
hasGlobalAccess,
|
|
184
246
|
});
|
|
247
|
+
|
|
248
|
+
if (!hasAccess) {
|
|
249
|
+
throw new ORPCError("FORBIDDEN", {
|
|
250
|
+
message: `Access denied to resource ${rule.resource}:${resourceId}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
185
253
|
}
|
|
186
254
|
|
|
187
|
-
//
|
|
255
|
+
// Execute handler
|
|
256
|
+
const result = await next({});
|
|
257
|
+
|
|
258
|
+
// Post-filter: List endpoints
|
|
259
|
+
// For these, return only resources user has access to (via global perm OR team grant)
|
|
188
260
|
if (
|
|
189
|
-
|
|
190
|
-
|
|
261
|
+
listResourceRules.length > 0 &&
|
|
262
|
+
result.output &&
|
|
263
|
+
typeof result.output === "object"
|
|
191
264
|
) {
|
|
192
|
-
const
|
|
193
|
-
const hasPermission = requiredPermissions.some(
|
|
194
|
-
(p: string) =>
|
|
195
|
-
userPermissions.includes("*") || userPermissions.includes(p)
|
|
196
|
-
);
|
|
265
|
+
const mutableOutput = result.output as Record<string, unknown>;
|
|
197
266
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
267
|
+
for (const rule of listResourceRules) {
|
|
268
|
+
const outputKey = rule.instanceAccess!.listKey!;
|
|
269
|
+
const items = mutableOutput[outputKey];
|
|
270
|
+
|
|
271
|
+
if (items === undefined) {
|
|
272
|
+
context.logger.error(
|
|
273
|
+
`resourceAccess: expected "${outputKey}" in response but not found`
|
|
274
|
+
);
|
|
275
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
276
|
+
message: "Invalid response shape for filtered endpoint",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!Array.isArray(items)) {
|
|
281
|
+
context.logger.error(
|
|
282
|
+
`resourceAccess: "${outputKey}" must be an array`
|
|
283
|
+
);
|
|
284
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
285
|
+
message: "Invalid response shape for filtered endpoint",
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// If no user (anonymous), check if they have global access via anonymous role
|
|
290
|
+
if (!userId || !userType) {
|
|
291
|
+
// Anonymous users can't have team grants, but may have global access
|
|
292
|
+
const anonymousAccessRules =
|
|
293
|
+
await context.auth.getAnonymousAccessRules();
|
|
294
|
+
const hasGlobalAccess =
|
|
295
|
+
anonymousAccessRules.includes("*") ||
|
|
296
|
+
anonymousAccessRules.includes(rule.qualifiedId);
|
|
297
|
+
|
|
298
|
+
if (hasGlobalAccess) {
|
|
299
|
+
// Anonymous user has global access - return all items
|
|
300
|
+
continue;
|
|
301
|
+
} else {
|
|
302
|
+
// No global access and can't have team grants - return empty
|
|
303
|
+
mutableOutput[outputKey] = [];
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const resourceIds = items
|
|
309
|
+
.map((item) => (item as { id?: string }).id)
|
|
310
|
+
.filter((id): id is string => typeof id === "string");
|
|
311
|
+
|
|
312
|
+
const hasGlobalAccess =
|
|
313
|
+
userAccessRules.includes("*") ||
|
|
314
|
+
userAccessRules.includes(rule.qualifiedId);
|
|
315
|
+
|
|
316
|
+
const accessibleIds = await getAccessibleResourceIdsViaS2S({
|
|
317
|
+
auth: context.auth,
|
|
318
|
+
userId,
|
|
319
|
+
userType,
|
|
320
|
+
resourceType: rule.qualifiedResourceType,
|
|
321
|
+
resourceIds,
|
|
322
|
+
action: rule.level,
|
|
323
|
+
hasGlobalAccess,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const accessibleSet = new Set(accessibleIds);
|
|
327
|
+
mutableOutput[outputKey] = items.filter((item) => {
|
|
328
|
+
const id = (item as { id?: string }).id;
|
|
329
|
+
return id && accessibleSet.has(id);
|
|
201
330
|
});
|
|
202
331
|
}
|
|
203
332
|
}
|
|
204
333
|
|
|
205
|
-
|
|
206
|
-
return nextWithErrorLogging();
|
|
334
|
+
return result;
|
|
207
335
|
}
|
|
208
336
|
);
|
|
209
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Extract a nested value from an object using dot notation.
|
|
340
|
+
* E.g., getNestedValue({ params: { id: "123" } }, "params.id") => "123"
|
|
341
|
+
*/
|
|
342
|
+
function getNestedValue(obj: unknown, path: string): string | undefined {
|
|
343
|
+
const parts = path.split(".");
|
|
344
|
+
let current: unknown = obj;
|
|
345
|
+
for (const part of parts) {
|
|
346
|
+
if (current === null || current === undefined) return undefined;
|
|
347
|
+
current = (current as Record<string, unknown>)[part];
|
|
348
|
+
}
|
|
349
|
+
return typeof current === "string" ? current : undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check resource access via auth service S2S endpoint.
|
|
354
|
+
*/
|
|
355
|
+
async function checkResourceAccessViaS2S({
|
|
356
|
+
auth,
|
|
357
|
+
userId,
|
|
358
|
+
userType,
|
|
359
|
+
resourceType,
|
|
360
|
+
resourceId,
|
|
361
|
+
action,
|
|
362
|
+
hasGlobalAccess,
|
|
363
|
+
}: {
|
|
364
|
+
auth: AuthService;
|
|
365
|
+
userId: string;
|
|
366
|
+
userType: "user" | "application";
|
|
367
|
+
resourceType: string;
|
|
368
|
+
resourceId: string;
|
|
369
|
+
action: "read" | "manage";
|
|
370
|
+
hasGlobalAccess: boolean;
|
|
371
|
+
}): Promise<boolean> {
|
|
372
|
+
try {
|
|
373
|
+
const result = await auth.checkResourceTeamAccess({
|
|
374
|
+
userId,
|
|
375
|
+
userType,
|
|
376
|
+
resourceType,
|
|
377
|
+
resourceId,
|
|
378
|
+
action,
|
|
379
|
+
hasGlobalAccess,
|
|
380
|
+
});
|
|
381
|
+
return result.hasAccess;
|
|
382
|
+
} catch {
|
|
383
|
+
// If team access check fails (e.g., service not available), fall back to global access
|
|
384
|
+
return hasGlobalAccess;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Get accessible resource IDs via auth service S2S endpoint.
|
|
390
|
+
*/
|
|
391
|
+
async function getAccessibleResourceIdsViaS2S({
|
|
392
|
+
auth,
|
|
393
|
+
userId,
|
|
394
|
+
userType,
|
|
395
|
+
resourceType,
|
|
396
|
+
resourceIds,
|
|
397
|
+
action,
|
|
398
|
+
hasGlobalAccess,
|
|
399
|
+
}: {
|
|
400
|
+
auth: AuthService;
|
|
401
|
+
userId: string;
|
|
402
|
+
userType: "user" | "application";
|
|
403
|
+
resourceType: string;
|
|
404
|
+
resourceIds: string[];
|
|
405
|
+
action: "read" | "manage";
|
|
406
|
+
hasGlobalAccess: boolean;
|
|
407
|
+
}): Promise<string[]> {
|
|
408
|
+
if (resourceIds.length === 0) return [];
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
return await auth.getAccessibleResourceIds({
|
|
412
|
+
userId,
|
|
413
|
+
userType,
|
|
414
|
+
resourceType,
|
|
415
|
+
resourceIds,
|
|
416
|
+
action,
|
|
417
|
+
hasGlobalAccess,
|
|
418
|
+
});
|
|
419
|
+
} catch {
|
|
420
|
+
// If team access check fails, fall back to global access behavior
|
|
421
|
+
return hasGlobalAccess ? resourceIds : [];
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
210
425
|
// =============================================================================
|
|
211
426
|
// CONTRACT BUILDER
|
|
212
427
|
// =============================================================================
|
|
@@ -217,16 +432,16 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
217
432
|
* All plugin contracts should use this builder. It ensures that:
|
|
218
433
|
* 1. All procedures are authenticated by default
|
|
219
434
|
* 2. User type is enforced based on meta.userType
|
|
220
|
-
* 3.
|
|
435
|
+
* 3. Access rules are enforced based on meta.access
|
|
221
436
|
*
|
|
222
437
|
* @example
|
|
223
438
|
* import { baseContractBuilder } from "@checkstack/backend-api";
|
|
224
|
-
* import {
|
|
439
|
+
* import { access } from "./access";
|
|
225
440
|
*
|
|
226
441
|
* const myContract = {
|
|
227
|
-
* // User-only endpoint with specific
|
|
442
|
+
* // User-only endpoint with specific access rule
|
|
228
443
|
* getItems: baseContractBuilder
|
|
229
|
-
* .meta({ userType: "user",
|
|
444
|
+
* .meta({ userType: "user", accessRules: [access.myPluginRead.id] })
|
|
230
445
|
* .output(z.array(ItemSchema)),
|
|
231
446
|
*
|
|
232
447
|
* // Service-only endpoint (backend-to-backend)
|
package/src/test-utils.ts
CHANGED
|
@@ -34,7 +34,12 @@ export function createMockRpcContext(
|
|
|
34
34
|
auth: {
|
|
35
35
|
authenticate: mock(),
|
|
36
36
|
getCredentials: mock().mockResolvedValue({ headers: {} }),
|
|
37
|
-
|
|
37
|
+
getAnonymousAccessRules: mock().mockResolvedValue([]),
|
|
38
|
+
checkResourceTeamAccess: mock().mockResolvedValue({ hasAccess: true }),
|
|
39
|
+
getAccessibleResourceIds: mock().mockImplementation(
|
|
40
|
+
(params: { resourceIds: string[] }) =>
|
|
41
|
+
Promise.resolve(params.resourceIds)
|
|
42
|
+
),
|
|
38
43
|
},
|
|
39
44
|
healthCheckRegistry: {
|
|
40
45
|
register: mock(),
|
package/src/types.ts
CHANGED
|
@@ -22,21 +22,22 @@ export interface Fetch {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Real user authenticated via session/token (human users).
|
|
25
|
-
* Has
|
|
25
|
+
* Has access rules and roles from the RBAC system.
|
|
26
26
|
*/
|
|
27
27
|
export interface RealUser {
|
|
28
28
|
type: "user";
|
|
29
29
|
id: string;
|
|
30
30
|
email?: string;
|
|
31
31
|
name?: string;
|
|
32
|
-
|
|
32
|
+
accessRules?: string[];
|
|
33
33
|
roles?: string[];
|
|
34
|
+
teamIds?: string[];
|
|
34
35
|
[key: string]: unknown;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Service user for backend-to-backend calls.
|
|
39
|
-
* Trusted implicitly - no
|
|
40
|
+
* Trusted implicitly - no accesss/roles needed.
|
|
40
41
|
*/
|
|
41
42
|
export interface ServiceUser {
|
|
42
43
|
type: "service";
|
|
@@ -45,14 +46,15 @@ export interface ServiceUser {
|
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* External application authenticated via API key.
|
|
48
|
-
* Has
|
|
49
|
+
* Has access rules and roles from the RBAC system like RealUser.
|
|
49
50
|
*/
|
|
50
51
|
export interface ApplicationUser {
|
|
51
52
|
type: "application";
|
|
52
53
|
id: string;
|
|
53
54
|
name: string;
|
|
54
|
-
|
|
55
|
+
accessRules?: string[];
|
|
55
56
|
roles?: string[];
|
|
57
|
+
teamIds?: string[];
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/**
|
|
@@ -65,11 +67,34 @@ export interface AuthService {
|
|
|
65
67
|
authenticate(request: Request): Promise<AuthUser | undefined>;
|
|
66
68
|
getCredentials(): Promise<{ headers: Record<string, string> }>;
|
|
67
69
|
/**
|
|
68
|
-
* Get
|
|
69
|
-
* Used by autoAuthMiddleware to check
|
|
70
|
+
* Get access rules assigned to the anonymous role.
|
|
71
|
+
* Used by autoAuthMiddleware to check accesss for unauthenticated
|
|
70
72
|
* users on "public" userType endpoints.
|
|
71
73
|
*/
|
|
72
|
-
|
|
74
|
+
getAnonymousAccessRules(): Promise<string[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Check if a user has access to a specific resource via team grants.
|
|
77
|
+
*/
|
|
78
|
+
checkResourceTeamAccess(params: {
|
|
79
|
+
userId: string;
|
|
80
|
+
userType: "user" | "application";
|
|
81
|
+
resourceType: string;
|
|
82
|
+
resourceId: string;
|
|
83
|
+
action: "read" | "manage";
|
|
84
|
+
hasGlobalAccess: boolean;
|
|
85
|
+
}): Promise<{ hasAccess: boolean }>;
|
|
86
|
+
/**
|
|
87
|
+
* Get IDs of resources the user can access from a given list.
|
|
88
|
+
* Used for bulk filtering of list endpoints.
|
|
89
|
+
*/
|
|
90
|
+
getAccessibleResourceIds(params: {
|
|
91
|
+
userId: string;
|
|
92
|
+
userType: "user" | "application";
|
|
93
|
+
resourceType: string;
|
|
94
|
+
resourceIds: string[];
|
|
95
|
+
action: "read" | "manage";
|
|
96
|
+
hasGlobalAccess: boolean;
|
|
97
|
+
}): Promise<string[]>;
|
|
73
98
|
}
|
|
74
99
|
|
|
75
100
|
/**
|
|
@@ -88,7 +113,7 @@ export interface PluginInstaller {
|
|
|
88
113
|
* Options for declarative route definitions (Deprecated, will be replaced by oRPC procedures).
|
|
89
114
|
*/
|
|
90
115
|
export interface RouteOptions {
|
|
91
|
-
|
|
116
|
+
accessRule?: string | string[];
|
|
92
117
|
schema?: ZodSchema;
|
|
93
118
|
}
|
|
94
119
|
|