@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/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 { ProcedureMetadata, qualifyPermissionId } from "@checkstack/common";
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 permission checks
77
- * - "public": Anyone can attempt, but permissions are checked (anonymous role for guests)
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. Permissions (from meta.permissions, only for real users or public anonymous)
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 contractPermissions = meta?.permissions || [];
99
+ const accessRules = meta?.access || [];
90
100
 
91
- // Prefix contract permissions with pluginId to get fully-qualified permission IDs
92
- // Contract defines: "catalog.read" -> Stored in DB as: "catalog.catalog.read"
93
- const requiredPermissions = contractPermissions.map((p: string) =>
94
- qualifyPermissionId(context.pluginMetadata, { id: p })
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
- // Helper to wrap next() with error logging
98
- const nextWithErrorLogging = async () => {
99
- try {
100
- return await next({});
101
- } catch (error) {
102
- // Log the full error before oRPC sanitizes it to a generic 500
103
- if (error instanceof ORPCError) {
104
- // ORPCError is intentional - log at debug level
105
- context.logger.debug("RPC error response:", {
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 permission checks
121
+ // 1. Handle anonymous endpoints - no auth required, no access checks
119
122
  if (requiredUserType === "anonymous") {
120
- return nextWithErrorLogging();
123
+ return next({});
121
124
  }
122
125
 
123
- // 2. Handle public endpoints - anyone can attempt, but permissions are checked
126
+ // 2. Handle public endpoints - anyone can attempt
124
127
  if (requiredUserType === "public") {
125
- if (context.user) {
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
- if (!hasPermission) {
139
- throw new ORPCError("FORBIDDEN", {
140
- message: `Missing permission: ${requiredPermissions.join(
141
- " or "
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
- // Services are trusted with all permissions
147
- } else {
148
- // Anonymous user - check anonymous role permissions
149
- if (requiredPermissions.length > 0) {
150
- const anonymousPermissions =
151
- await context.auth.getAnonymousPermissions();
152
- const hasPermission = requiredPermissions.some((p: string) =>
153
- anonymousPermissions.includes(p)
154
- );
155
-
156
- if (!hasPermission) {
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
- return nextWithErrorLogging();
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 (requiredUserType === "user" && user.type !== "user") {
177
- throw new ORPCError("FORBIDDEN", {
178
- message: "This endpoint is for users only",
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
- if (requiredUserType === "service" && user.type !== "service") {
182
- throw new ORPCError("FORBIDDEN", {
183
- message: "This endpoint is for services only",
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
- // 5. Enforce permissions (for real users and applications)
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
- (user.type === "user" || user.type === "application") &&
190
- requiredPermissions.length > 0
261
+ listResourceRules.length > 0 &&
262
+ result.output &&
263
+ typeof result.output === "object"
191
264
  ) {
192
- const userPermissions = user.permissions || [];
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
- if (!hasPermission) {
199
- throw new ORPCError("FORBIDDEN", {
200
- message: `Missing permission: ${requiredPermissions.join(" or ")}`,
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
- // Pass through - services are trusted with all permissions
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. Permissions are enforced based on meta.permissions
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 { permissions } from "./permissions";
439
+ * import { access } from "./access";
225
440
  *
226
441
  * const myContract = {
227
- * // User-only endpoint with specific permission
442
+ * // User-only endpoint with specific access rule
228
443
  * getItems: baseContractBuilder
229
- * .meta({ userType: "user", permissions: [permissions.myPluginRead.id] })
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
- getAnonymousPermissions: mock().mockResolvedValue([]),
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 permissions and roles from the RBAC system.
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
- permissions?: string[];
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 permissions/roles needed.
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 permissions and roles from the RBAC system like RealUser.
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
- permissions?: string[];
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 permissions assigned to the anonymous role.
69
- * Used by autoAuthMiddleware to check permissions for unauthenticated
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
- getAnonymousPermissions(): Promise<string[]>;
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
- permission?: string | string[];
116
+ accessRule?: string | string[];
92
117
  schema?: ZodSchema;
93
118
  }
94
119