@checkstack/backend-api 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,84 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e43507: # Teams and Resource-Level Access Control
8
+
9
+ This release introduces a comprehensive Teams system for organizing users and controlling access to resources at a granular level.
10
+
11
+ ## Features
12
+
13
+ ### Team Management
14
+
15
+ - Create, update, and delete teams with name and description
16
+ - Add/remove users from teams
17
+ - Designate team managers with elevated privileges
18
+ - View team membership and manager status
19
+
20
+ ### Resource-Level Access Control
21
+
22
+ - Grant teams access to specific resources (systems, health checks, incidents, maintenances)
23
+ - Configure read-only or manage permissions per team
24
+ - Resource-level "Team Only" mode that restricts access exclusively to team members
25
+ - Separate `resourceAccessSettings` table for resource-level settings (not per-grant)
26
+ - Automatic cleanup of grants when teams are deleted (database cascade)
27
+
28
+ ### Middleware Integration
29
+
30
+ - Extended `autoAuthMiddleware` to support resource access checks
31
+ - Single-resource pre-handler validation for detail endpoints
32
+ - Automatic list filtering for collection endpoints
33
+ - S2S endpoints for access verification
34
+
35
+ ### Frontend Components
36
+
37
+ - `TeamsTab` component for managing teams in Auth Settings
38
+ - `TeamAccessEditor` component for assigning team access to resources
39
+ - Resource-level "Team Only" toggle in `TeamAccessEditor`
40
+ - Integration into System, Health Check, Incident, and Maintenance editors
41
+
42
+ ## Breaking Changes
43
+
44
+ ### API Response Format Changes
45
+
46
+ List endpoints now return objects with named keys instead of arrays directly:
47
+
48
+ ```typescript
49
+ // Before
50
+ const systems = await catalogApi.getSystems();
51
+
52
+ // After
53
+ const { systems } = await catalogApi.getSystems();
54
+ ```
55
+
56
+ Affected endpoints:
57
+
58
+ - `catalog.getSystems` → `{ systems: [...] }`
59
+ - `healthcheck.getConfigurations` → `{ configurations: [...] }`
60
+ - `incident.listIncidents` → `{ incidents: [...] }`
61
+ - `maintenance.listMaintenances` → `{ maintenances: [...] }`
62
+
63
+ ### User Identity Enrichment
64
+
65
+ `RealUser` and `ApplicationUser` types now include `teamIds: string[]` field with team memberships.
66
+
67
+ ## Documentation
68
+
69
+ See `docs/backend/teams.md` for complete API reference and integration guide.
70
+
71
+ ### Patch Changes
72
+
73
+ - 97c5a6b: Fix collector lookup when health check is assigned to a system
74
+
75
+ Collectors are now stored in the registry with their fully-qualified ID format (ownerPluginId.collectorId) to match how they are referenced in health check configurations. Added `qualifiedId` field to `RegisteredCollector` interface to avoid re-constructing the ID at query time. This fixes the "Collector not found" warning that occurred when executing health checks with assigned systems.
76
+
77
+ - Updated dependencies [8e43507]
78
+ - @checkstack/common@0.1.0
79
+ - @checkstack/queue-api@0.0.4
80
+ - @checkstack/signal-common@0.0.4
81
+
3
82
  ## 0.1.0
4
83
 
5
84
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "scripts": {
@@ -6,6 +6,8 @@ import type { TransportClient } from "./transport-client";
6
6
  * A registered collector with its owning plugin metadata.
7
7
  */
8
8
  export interface RegisteredCollector {
9
+ /** The fully-qualified collector ID (ownerPluginId.collectorId) */
10
+ qualifiedId: string;
9
11
  /** The collector strategy */
10
12
  collector: CollectorStrategy<TransportClient<unknown, unknown>>;
11
13
  /** The plugin that registered this collector */
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
+ qualifyPermissionId,
9
+ qualifyResourceType,
10
+ } from "@checkstack/common";
7
11
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
8
12
  import {
9
13
  Logger,
@@ -83,10 +87,11 @@ export type { ProcedureMetadata } from "@checkstack/common";
83
87
  * Use this in backend routers: `implement(contract).$context<RpcContext>().use(autoAuthMiddleware)`
84
88
  */
85
89
  export const autoAuthMiddleware = os.middleware(
86
- async ({ next, context, procedure }) => {
90
+ async ({ next, context, procedure }, input: unknown) => {
87
91
  const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
88
92
  const requiredUserType = meta?.userType || "authenticated";
89
93
  const contractPermissions = meta?.permissions || [];
94
+ const resourceAccessConfigs = meta?.resourceAccess || [];
90
95
 
91
96
  // Prefix contract permissions with pluginId to get fully-qualified permission IDs
92
97
  // Contract defines: "catalog.read" -> Stored in DB as: "catalog.catalog.read"
@@ -94,30 +99,9 @@ export const autoAuthMiddleware = os.middleware(
94
99
  qualifyPermissionId(context.pluginMetadata, { id: p })
95
100
  );
96
101
 
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
- };
117
-
118
102
  // 1. Handle anonymous endpoints - no auth required, no permission checks
119
103
  if (requiredUserType === "anonymous") {
120
- return nextWithErrorLogging();
104
+ return next({});
121
105
  }
122
106
 
123
107
  // 2. Handle public endpoints - anyone can attempt, but permissions are checked
@@ -160,7 +144,7 @@ export const autoAuthMiddleware = os.middleware(
160
144
  }
161
145
  }
162
146
  }
163
- return nextWithErrorLogging();
147
+ return next({});
164
148
  }
165
149
 
166
150
  // 3. Enforce authentication for user/service/authenticated types
@@ -202,11 +186,227 @@ export const autoAuthMiddleware = os.middleware(
202
186
  }
203
187
  }
204
188
 
205
- // Pass through - services are trusted with all permissions
206
- return nextWithErrorLogging();
189
+ // 6. Resource-level access control
190
+ // Skip if no resource access configs or if user is a service
191
+ if (resourceAccessConfigs.length === 0 || user.type === "service") {
192
+ return next({});
193
+ }
194
+
195
+ const userId = user.id;
196
+ const userType = user.type;
197
+ const action = inferActionFromPermissions(contractPermissions);
198
+ const hasGlobalPermission =
199
+ user.permissions?.includes("*") ||
200
+ contractPermissions.some((p) =>
201
+ user.permissions?.includes(
202
+ qualifyPermissionId(context.pluginMetadata, { id: p })
203
+ )
204
+ );
205
+
206
+ // Separate single vs list configs
207
+ const singleConfigs = resourceAccessConfigs.filter(
208
+ (c) => c.filterMode !== "list"
209
+ );
210
+ const listConfigs = resourceAccessConfigs.filter(
211
+ (c) => c.filterMode === "list"
212
+ );
213
+
214
+ // Pre-check: Single resource access
215
+ for (const config of singleConfigs) {
216
+ if (!config.idParam) continue;
217
+ const resourceId = getNestedValue(input, config.idParam);
218
+ if (!resourceId) continue;
219
+
220
+ const qualifiedType = qualifyResourceType(
221
+ context.pluginMetadata.pluginId,
222
+ config.resourceType
223
+ );
224
+
225
+ const hasAccess = await checkResourceAccessViaS2S({
226
+ auth: context.auth,
227
+ userId,
228
+ userType,
229
+ resourceType: qualifiedType,
230
+ resourceId,
231
+ action,
232
+ hasGlobalPermission,
233
+ });
234
+
235
+ if (!hasAccess) {
236
+ throw new ORPCError("FORBIDDEN", {
237
+ message: `Access denied to resource ${config.resourceType}:${resourceId}`,
238
+ });
239
+ }
240
+ }
241
+
242
+ // Execute handler
243
+ const result = await next({});
244
+
245
+ // Post-filter: List endpoints
246
+ if (
247
+ listConfigs.length > 0 &&
248
+ result.output &&
249
+ typeof result.output === "object"
250
+ ) {
251
+ const mutableOutput = result.output as Record<string, unknown>;
252
+
253
+ for (const config of listConfigs) {
254
+ if (!config.outputKey) {
255
+ context.logger.error(
256
+ `resourceAccess: filterMode "list" requires outputKey`
257
+ );
258
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
259
+ message: "Invalid resource access configuration",
260
+ });
261
+ }
262
+
263
+ const items = mutableOutput[config.outputKey];
264
+
265
+ if (items === undefined) {
266
+ context.logger.error(
267
+ `resourceAccess: expected "${config.outputKey}" in response but not found`
268
+ );
269
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
270
+ message: "Invalid response shape for filtered endpoint",
271
+ });
272
+ }
273
+
274
+ if (!Array.isArray(items)) {
275
+ context.logger.error(
276
+ `resourceAccess: "${config.outputKey}" must be an array`
277
+ );
278
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
279
+ message: "Invalid response shape for filtered endpoint",
280
+ });
281
+ }
282
+
283
+ const qualifiedType = qualifyResourceType(
284
+ context.pluginMetadata.pluginId,
285
+ config.resourceType
286
+ );
287
+
288
+ const resourceIds = items
289
+ .map((item) => (item as { id?: string }).id)
290
+ .filter((id): id is string => typeof id === "string");
291
+
292
+ const accessibleIds = await getAccessibleResourceIdsViaS2S({
293
+ auth: context.auth,
294
+ userId,
295
+ userType,
296
+ resourceType: qualifiedType,
297
+ resourceIds,
298
+ action,
299
+ hasGlobalPermission,
300
+ });
301
+
302
+ const accessibleSet = new Set(accessibleIds);
303
+ mutableOutput[config.outputKey] = items.filter((item) => {
304
+ const id = (item as { id?: string }).id;
305
+ return id && accessibleSet.has(id);
306
+ });
307
+ }
308
+ }
309
+
310
+ return result;
207
311
  }
208
312
  );
209
313
 
314
+ /**
315
+ * Extract a nested value from an object using dot notation.
316
+ * E.g., getNestedValue({ params: { id: "123" } }, "params.id") => "123"
317
+ */
318
+ function getNestedValue(obj: unknown, path: string): string | undefined {
319
+ const parts = path.split(".");
320
+ let current: unknown = obj;
321
+ for (const part of parts) {
322
+ if (current === null || current === undefined) return undefined;
323
+ current = (current as Record<string, unknown>)[part];
324
+ }
325
+ return typeof current === "string" ? current : undefined;
326
+ }
327
+
328
+ /**
329
+ * Determine action from permission suffix (read vs manage).
330
+ */
331
+ function inferActionFromPermissions(permissions: string[]): "read" | "manage" {
332
+ const perm = permissions[0] || "";
333
+ if (perm.endsWith(".manage") || perm.endsWith("Manage")) return "manage";
334
+ return "read";
335
+ }
336
+
337
+ /**
338
+ * Check resource access via auth service S2S endpoint.
339
+ */
340
+ async function checkResourceAccessViaS2S({
341
+ auth,
342
+ userId,
343
+ userType,
344
+ resourceType,
345
+ resourceId,
346
+ action,
347
+ hasGlobalPermission,
348
+ }: {
349
+ auth: AuthService;
350
+ userId: string;
351
+ userType: "user" | "application";
352
+ resourceType: string;
353
+ resourceId: string;
354
+ action: "read" | "manage";
355
+ hasGlobalPermission: boolean;
356
+ }): Promise<boolean> {
357
+ try {
358
+ const result = await auth.checkResourceTeamAccess({
359
+ userId,
360
+ userType,
361
+ resourceType,
362
+ resourceId,
363
+ action,
364
+ hasGlobalPermission,
365
+ });
366
+ return result.hasAccess;
367
+ } catch {
368
+ // If team access check fails (e.g., service not available), fall back to global permission
369
+ return hasGlobalPermission;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Get accessible resource IDs via auth service S2S endpoint.
375
+ */
376
+ async function getAccessibleResourceIdsViaS2S({
377
+ auth,
378
+ userId,
379
+ userType,
380
+ resourceType,
381
+ resourceIds,
382
+ action,
383
+ hasGlobalPermission,
384
+ }: {
385
+ auth: AuthService;
386
+ userId: string;
387
+ userType: "user" | "application";
388
+ resourceType: string;
389
+ resourceIds: string[];
390
+ action: "read" | "manage";
391
+ hasGlobalPermission: boolean;
392
+ }): Promise<string[]> {
393
+ if (resourceIds.length === 0) return [];
394
+
395
+ try {
396
+ return await auth.getAccessibleResourceIds({
397
+ userId,
398
+ userType,
399
+ resourceType,
400
+ resourceIds,
401
+ action,
402
+ hasGlobalPermission,
403
+ });
404
+ } catch {
405
+ // If team access check fails, fall back to global permission behavior
406
+ return hasGlobalPermission ? resourceIds : [];
407
+ }
408
+ }
409
+
210
410
  // =============================================================================
211
411
  // CONTRACT BUILDER
212
412
  // =============================================================================
package/src/test-utils.ts CHANGED
@@ -35,6 +35,11 @@ export function createMockRpcContext(
35
35
  authenticate: mock(),
36
36
  getCredentials: mock().mockResolvedValue({ headers: {} }),
37
37
  getAnonymousPermissions: 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
@@ -31,6 +31,7 @@ export interface RealUser {
31
31
  name?: string;
32
32
  permissions?: string[];
33
33
  roles?: string[];
34
+ teamIds?: string[];
34
35
  [key: string]: unknown;
35
36
  }
36
37
 
@@ -53,6 +54,7 @@ export interface ApplicationUser {
53
54
  name: string;
54
55
  permissions?: string[];
55
56
  roles?: string[];
57
+ teamIds?: string[];
56
58
  }
57
59
 
58
60
  /**
@@ -70,6 +72,29 @@ export interface AuthService {
70
72
  * users on "public" userType endpoints.
71
73
  */
72
74
  getAnonymousPermissions(): 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
+ hasGlobalPermission: 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
+ hasGlobalPermission: boolean;
97
+ }): Promise<string[]>;
73
98
  }
74
99
 
75
100
  /**