@checkstack/backend-api 0.0.2 → 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 +118 -0
- package/package.json +1 -1
- package/src/collector-registry.ts +44 -0
- package/src/collector-strategy.ts +83 -0
- package/src/core-services.ts +5 -4
- package/src/health-check.ts +74 -6
- package/src/index.ts +3 -0
- package/src/rpc.ts +226 -30
- package/src/test-utils.ts +15 -5
- package/src/transport-client.ts +2 -0
- package/src/types.ts +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,123 @@
|
|
|
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
|
+
|
|
82
|
+
## 0.1.0
|
|
83
|
+
|
|
84
|
+
### Minor Changes
|
|
85
|
+
|
|
86
|
+
- f5b1f49: Added collector registry lifecycle cleanup during plugin unloading.
|
|
87
|
+
|
|
88
|
+
- Added `unregisterByOwner(pluginId)` to remove collectors owned by unloading plugins
|
|
89
|
+
- Added `unregisterByMissingStrategies(loadedPluginIds)` for dependency-based pruning
|
|
90
|
+
- Integrated registry cleanup into `PluginManager.deregisterPlugin()`
|
|
91
|
+
- Updated `registerCoreServices` to return global registries for lifecycle management
|
|
92
|
+
|
|
93
|
+
### Patch Changes
|
|
94
|
+
|
|
95
|
+
- f5b1f49: Added JSONPath assertions for response body validation and fully qualified strategy IDs.
|
|
96
|
+
|
|
97
|
+
**JSONPath Assertions:**
|
|
98
|
+
|
|
99
|
+
- Added `healthResultJSONPath()` factory in healthcheck-common for fields supporting JSONPath queries
|
|
100
|
+
- Extended AssertionBuilder with jsonpath field type showing path input (e.g., `$.data.status`)
|
|
101
|
+
- Added `jsonPath` field to `CollectorAssertionSchema` for persistence
|
|
102
|
+
- HTTP Request collector body field now supports JSONPath assertions
|
|
103
|
+
|
|
104
|
+
**Fully Qualified Strategy IDs:**
|
|
105
|
+
|
|
106
|
+
- HealthCheckRegistry now uses scoped factories like CollectorRegistry
|
|
107
|
+
- Strategies are stored with `pluginId.strategyId` format
|
|
108
|
+
- Added `getStrategiesWithMeta()` method to HealthCheckRegistry interface
|
|
109
|
+
- Router returns qualified IDs so frontend can correctly fetch collectors
|
|
110
|
+
|
|
111
|
+
**UI Improvements:**
|
|
112
|
+
|
|
113
|
+
- Save button disabled when collector configs have invalid required fields
|
|
114
|
+
- Fixed nested button warning in CollectorList accordion
|
|
115
|
+
|
|
116
|
+
- Updated dependencies [f5b1f49]
|
|
117
|
+
- @checkstack/common@0.0.3
|
|
118
|
+
- @checkstack/queue-api@0.0.3
|
|
119
|
+
- @checkstack/signal-common@0.0.3
|
|
120
|
+
|
|
3
121
|
## 0.0.2
|
|
4
122
|
|
|
5
123
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
2
|
+
import type { CollectorStrategy } from "./collector-strategy";
|
|
3
|
+
import type { TransportClient } from "./transport-client";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A registered collector with its owning plugin metadata.
|
|
7
|
+
*/
|
|
8
|
+
export interface RegisteredCollector {
|
|
9
|
+
/** The fully-qualified collector ID (ownerPluginId.collectorId) */
|
|
10
|
+
qualifiedId: string;
|
|
11
|
+
/** The collector strategy */
|
|
12
|
+
collector: CollectorStrategy<TransportClient<unknown, unknown>>;
|
|
13
|
+
/** The plugin that registered this collector */
|
|
14
|
+
ownerPlugin: PluginMetadata;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scoped collector registry interface.
|
|
19
|
+
* The owning plugin metadata is automatically injected via factory.
|
|
20
|
+
*/
|
|
21
|
+
export interface CollectorRegistry {
|
|
22
|
+
/**
|
|
23
|
+
* Register a collector strategy.
|
|
24
|
+
* The owning plugin metadata is automatically captured from the scoped context.
|
|
25
|
+
*/
|
|
26
|
+
register(
|
|
27
|
+
collector: CollectorStrategy<TransportClient<unknown, unknown>>
|
|
28
|
+
): void;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get a collector by ID.
|
|
32
|
+
*/
|
|
33
|
+
getCollector(id: string): RegisteredCollector | undefined;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get all collectors that support a specific transport plugin.
|
|
37
|
+
*/
|
|
38
|
+
getCollectorsForPlugin(pluginMetadata: PluginMetadata): RegisteredCollector[];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get all registered collectors.
|
|
42
|
+
*/
|
|
43
|
+
getCollectors(): RegisteredCollector[];
|
|
44
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
2
|
+
import type { TransportClient } from "./transport-client";
|
|
3
|
+
import type { Versioned } from "./config-versioning";
|
|
4
|
+
import type { HealthCheckRunForAggregation } from "./health-check";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Result from a collector execution.
|
|
8
|
+
*/
|
|
9
|
+
export interface CollectorResult<TResult> {
|
|
10
|
+
/** Collector-specific result data */
|
|
11
|
+
result: TResult;
|
|
12
|
+
/** Optional error message if collection partially failed */
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generic collector strategy interface.
|
|
18
|
+
*
|
|
19
|
+
* Collectors extend health check strategies by providing additional metrics
|
|
20
|
+
* collection capabilities. They receive a connected transport client and
|
|
21
|
+
* produce typed results with chart metadata.
|
|
22
|
+
*
|
|
23
|
+
* @template TClient - Transport client type (e.g., SshTransportClient)
|
|
24
|
+
* @template TConfig - Collector configuration schema
|
|
25
|
+
* @template TResult - Per-execution result type
|
|
26
|
+
* @template TAggregated - Aggregated result for buckets
|
|
27
|
+
*/
|
|
28
|
+
export interface CollectorStrategy<
|
|
29
|
+
TClient extends TransportClient<unknown, unknown>,
|
|
30
|
+
TConfig = unknown,
|
|
31
|
+
TResult = Record<string, unknown>,
|
|
32
|
+
TAggregated = Record<string, unknown>
|
|
33
|
+
> {
|
|
34
|
+
/** Unique identifier for this collector */
|
|
35
|
+
id: string;
|
|
36
|
+
|
|
37
|
+
/** Human-readable name */
|
|
38
|
+
displayName: string;
|
|
39
|
+
|
|
40
|
+
/** Optional description */
|
|
41
|
+
description?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* PluginMetadata of transport strategies this collector supports.
|
|
45
|
+
* The registry uses this to match collectors to compatible strategies.
|
|
46
|
+
*/
|
|
47
|
+
supportedPlugins: PluginMetadata[];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether multiple instances of this collector can be added to one config.
|
|
51
|
+
* Default: false (one instance per collector type)
|
|
52
|
+
*/
|
|
53
|
+
allowMultiple?: boolean;
|
|
54
|
+
|
|
55
|
+
/** Collector configuration schema with versioning */
|
|
56
|
+
config: Versioned<TConfig>;
|
|
57
|
+
|
|
58
|
+
/** Per-execution result schema (with x-chart-* metadata) */
|
|
59
|
+
result: Versioned<TResult>;
|
|
60
|
+
|
|
61
|
+
/** Aggregated result schema for bucket storage */
|
|
62
|
+
aggregatedResult: Versioned<TAggregated>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Execute the collector using the provided transport client.
|
|
66
|
+
*
|
|
67
|
+
* @param params.config - Validated collector configuration
|
|
68
|
+
* @param params.client - Connected transport client
|
|
69
|
+
* @param params.pluginId - ID of the transport strategy invoking this collector
|
|
70
|
+
* @returns Collector result with typed metadata
|
|
71
|
+
*/
|
|
72
|
+
execute(params: {
|
|
73
|
+
config: TConfig;
|
|
74
|
+
client: TClient;
|
|
75
|
+
pluginId: string;
|
|
76
|
+
}): Promise<CollectorResult<TResult>>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Aggregate results from multiple runs into a summary.
|
|
80
|
+
* Called during retention processing.
|
|
81
|
+
*/
|
|
82
|
+
aggregateResult(runs: HealthCheckRunForAggregation<TResult>[]): TAggregated;
|
|
83
|
+
}
|
package/src/core-services.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { createServiceRef } from "./service-ref";
|
|
2
2
|
import type { RpcService } from "./rpc";
|
|
3
3
|
import type { HealthCheckRegistry } from "./health-check";
|
|
4
|
-
import type {
|
|
5
|
-
|
|
6
|
-
QueueManager,
|
|
7
|
-
} from "@checkstack/queue-api";
|
|
4
|
+
import type { CollectorRegistry } from "./collector-registry";
|
|
5
|
+
import type { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
8
6
|
import type { ConfigService } from "./config-service";
|
|
9
7
|
import type { SignalService } from "@checkstack/signal-common";
|
|
10
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
@@ -32,6 +30,9 @@ export const coreServices = {
|
|
|
32
30
|
healthCheckRegistry: createServiceRef<HealthCheckRegistry>(
|
|
33
31
|
"core.healthCheckRegistry"
|
|
34
32
|
),
|
|
33
|
+
collectorRegistry: createServiceRef<CollectorRegistry>(
|
|
34
|
+
"core.collectorRegistry"
|
|
35
|
+
),
|
|
35
36
|
pluginInstaller: createServiceRef<PluginInstaller>("core.pluginInstaller"),
|
|
36
37
|
rpc: createServiceRef<RpcService>("core.rpc"),
|
|
37
38
|
rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
|
package/src/health-check.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Versioned } from "./config-versioning";
|
|
2
|
+
import type { TransportClient } from "./transport-client";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Health check result with typed metadata.
|
|
@@ -23,13 +24,35 @@ export interface HealthCheckRunForAggregation<
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* Connected transport client with cleanup capability.
|
|
28
|
+
*/
|
|
29
|
+
export interface ConnectedClient<
|
|
30
|
+
TClient extends TransportClient<unknown, unknown>
|
|
31
|
+
> {
|
|
32
|
+
/** The connected transport client */
|
|
33
|
+
client: TClient;
|
|
34
|
+
/** Close the connection and release resources */
|
|
35
|
+
close(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Health check strategy definition with typed config and transport client.
|
|
40
|
+
*
|
|
41
|
+
* Strategies provide a `createClient` function that establishes a connection
|
|
42
|
+
* and returns a transport client. The platform executor handles running
|
|
43
|
+
* collectors and basic health check logic (connectivity test, latency measurement).
|
|
44
|
+
*
|
|
27
45
|
* @template TConfig - Configuration type for this strategy
|
|
28
|
-
* @template
|
|
46
|
+
* @template TClient - Transport client type (e.g., SshTransportClient)
|
|
47
|
+
* @template TResult - Per-run result type (for aggregation)
|
|
29
48
|
* @template TAggregatedResult - Aggregated result type for buckets
|
|
30
49
|
*/
|
|
31
50
|
export interface HealthCheckStrategy<
|
|
32
51
|
TConfig = unknown,
|
|
52
|
+
TClient extends TransportClient<unknown, unknown> = TransportClient<
|
|
53
|
+
unknown,
|
|
54
|
+
unknown
|
|
55
|
+
>,
|
|
33
56
|
TResult = Record<string, unknown>,
|
|
34
57
|
TAggregatedResult = Record<string, unknown>
|
|
35
58
|
> {
|
|
@@ -46,7 +69,15 @@ export interface HealthCheckStrategy<
|
|
|
46
69
|
/** Aggregated result schema for long-term bucket storage */
|
|
47
70
|
aggregatedResult: Versioned<TAggregatedResult>;
|
|
48
71
|
|
|
49
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Create a connected transport client from the configuration.
|
|
74
|
+
* The platform will use this client to execute collectors.
|
|
75
|
+
*
|
|
76
|
+
* @param config - Validated strategy configuration
|
|
77
|
+
* @returns Connected client wrapper with close() method
|
|
78
|
+
* @throws Error if connection fails (will be caught by executor)
|
|
79
|
+
*/
|
|
80
|
+
createClient(config: TConfig): Promise<ConnectedClient<TClient>>;
|
|
50
81
|
|
|
51
82
|
/**
|
|
52
83
|
* Aggregate results from multiple runs into a summary for bucket storage.
|
|
@@ -59,10 +90,47 @@ export interface HealthCheckStrategy<
|
|
|
59
90
|
): TAggregatedResult;
|
|
60
91
|
}
|
|
61
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A registered strategy with its owning plugin metadata and qualified ID.
|
|
95
|
+
*/
|
|
96
|
+
export interface RegisteredStrategy {
|
|
97
|
+
strategy: HealthCheckStrategy<
|
|
98
|
+
unknown,
|
|
99
|
+
TransportClient<unknown, unknown>,
|
|
100
|
+
unknown,
|
|
101
|
+
unknown
|
|
102
|
+
>;
|
|
103
|
+
ownerPluginId: string;
|
|
104
|
+
qualifiedId: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
62
107
|
export interface HealthCheckRegistry {
|
|
63
|
-
register(
|
|
108
|
+
register(
|
|
109
|
+
strategy: HealthCheckStrategy<
|
|
110
|
+
unknown,
|
|
111
|
+
TransportClient<unknown, unknown>,
|
|
112
|
+
unknown,
|
|
113
|
+
unknown
|
|
114
|
+
>
|
|
115
|
+
): void;
|
|
64
116
|
getStrategy(
|
|
65
117
|
id: string
|
|
66
|
-
):
|
|
67
|
-
|
|
118
|
+
):
|
|
119
|
+
| HealthCheckStrategy<
|
|
120
|
+
unknown,
|
|
121
|
+
TransportClient<unknown, unknown>,
|
|
122
|
+
unknown,
|
|
123
|
+
unknown
|
|
124
|
+
>
|
|
125
|
+
| undefined;
|
|
126
|
+
getStrategies(): HealthCheckStrategy<
|
|
127
|
+
unknown,
|
|
128
|
+
TransportClient<unknown, unknown>,
|
|
129
|
+
unknown,
|
|
130
|
+
unknown
|
|
131
|
+
>[];
|
|
132
|
+
/**
|
|
133
|
+
* Get all registered strategies with their metadata (qualified ID, owner plugin).
|
|
134
|
+
*/
|
|
135
|
+
getStrategiesWithMeta(): RegisteredStrategy[];
|
|
68
136
|
}
|
package/src/index.ts
CHANGED
package/src/rpc.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { os as baseOs, ORPCError, Router } from "@orpc/server";
|
|
2
2
|
import { AnyContractRouter } from "@orpc/contract";
|
|
3
3
|
import { HealthCheckRegistry } from "./health-check";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
QueueManager,
|
|
7
|
-
} from "@checkstack/queue-api";
|
|
4
|
+
import { CollectorRegistry } from "./collector-registry";
|
|
5
|
+
import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
8
6
|
import {
|
|
9
7
|
ProcedureMetadata,
|
|
10
8
|
qualifyPermissionId,
|
|
9
|
+
qualifyResourceType,
|
|
11
10
|
} from "@checkstack/common";
|
|
12
11
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
13
12
|
import {
|
|
@@ -43,6 +42,7 @@ export interface RpcContext {
|
|
|
43
42
|
auth: AuthService;
|
|
44
43
|
user?: AuthUser;
|
|
45
44
|
healthCheckRegistry: HealthCheckRegistry;
|
|
45
|
+
collectorRegistry: CollectorRegistry;
|
|
46
46
|
queuePluginRegistry: QueuePluginRegistry;
|
|
47
47
|
queueManager: QueueManager;
|
|
48
48
|
/** Emit a hook event for cross-plugin communication */
|
|
@@ -87,10 +87,11 @@ export type { ProcedureMetadata } from "@checkstack/common";
|
|
|
87
87
|
* Use this in backend routers: `implement(contract).$context<RpcContext>().use(autoAuthMiddleware)`
|
|
88
88
|
*/
|
|
89
89
|
export const autoAuthMiddleware = os.middleware(
|
|
90
|
-
async ({ next, context, procedure }) => {
|
|
90
|
+
async ({ next, context, procedure }, input: unknown) => {
|
|
91
91
|
const meta = procedure["~orpc"]?.meta as ProcedureMetadata | undefined;
|
|
92
92
|
const requiredUserType = meta?.userType || "authenticated";
|
|
93
93
|
const contractPermissions = meta?.permissions || [];
|
|
94
|
+
const resourceAccessConfigs = meta?.resourceAccess || [];
|
|
94
95
|
|
|
95
96
|
// Prefix contract permissions with pluginId to get fully-qualified permission IDs
|
|
96
97
|
// Contract defines: "catalog.read" -> Stored in DB as: "catalog.catalog.read"
|
|
@@ -98,30 +99,9 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
98
99
|
qualifyPermissionId(context.pluginMetadata, { id: p })
|
|
99
100
|
);
|
|
100
101
|
|
|
101
|
-
// Helper to wrap next() with error logging
|
|
102
|
-
const nextWithErrorLogging = async () => {
|
|
103
|
-
try {
|
|
104
|
-
return await next({});
|
|
105
|
-
} catch (error) {
|
|
106
|
-
// Log the full error before oRPC sanitizes it to a generic 500
|
|
107
|
-
if (error instanceof ORPCError) {
|
|
108
|
-
// ORPCError is intentional - log at debug level
|
|
109
|
-
context.logger.debug("RPC error response:", {
|
|
110
|
-
code: error.code,
|
|
111
|
-
message: error.message,
|
|
112
|
-
data: error.data,
|
|
113
|
-
});
|
|
114
|
-
} else {
|
|
115
|
-
// Unexpected error - log at error level with full stack trace
|
|
116
|
-
context.logger.error("Unexpected RPC error:", error);
|
|
117
|
-
}
|
|
118
|
-
throw error;
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
|
|
122
102
|
// 1. Handle anonymous endpoints - no auth required, no permission checks
|
|
123
103
|
if (requiredUserType === "anonymous") {
|
|
124
|
-
return
|
|
104
|
+
return next({});
|
|
125
105
|
}
|
|
126
106
|
|
|
127
107
|
// 2. Handle public endpoints - anyone can attempt, but permissions are checked
|
|
@@ -164,7 +144,7 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
164
144
|
}
|
|
165
145
|
}
|
|
166
146
|
}
|
|
167
|
-
return
|
|
147
|
+
return next({});
|
|
168
148
|
}
|
|
169
149
|
|
|
170
150
|
// 3. Enforce authentication for user/service/authenticated types
|
|
@@ -206,11 +186,227 @@ export const autoAuthMiddleware = os.middleware(
|
|
|
206
186
|
}
|
|
207
187
|
}
|
|
208
188
|
|
|
209
|
-
//
|
|
210
|
-
|
|
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;
|
|
211
311
|
}
|
|
212
312
|
);
|
|
213
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
|
+
|
|
214
410
|
// =============================================================================
|
|
215
411
|
// CONTRACT BUILDER
|
|
216
412
|
// =============================================================================
|
package/src/test-utils.ts
CHANGED
|
@@ -2,10 +2,8 @@ import { mock } from "bun:test";
|
|
|
2
2
|
import { RpcContext, EmitHookFn } from "./rpc";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { HealthCheckRegistry } from "./health-check";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
QueueManager,
|
|
8
|
-
} from "@checkstack/queue-api";
|
|
5
|
+
import { CollectorRegistry } from "./collector-registry";
|
|
6
|
+
import { QueuePluginRegistry, QueueManager } from "@checkstack/queue-api";
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* Creates a mocked oRPC context for testing.
|
|
@@ -37,12 +35,24 @@ export function createMockRpcContext(
|
|
|
37
35
|
authenticate: mock(),
|
|
38
36
|
getCredentials: mock().mockResolvedValue({ headers: {} }),
|
|
39
37
|
getAnonymousPermissions: mock().mockResolvedValue([]),
|
|
38
|
+
checkResourceTeamAccess: mock().mockResolvedValue({ hasAccess: true }),
|
|
39
|
+
getAccessibleResourceIds: mock().mockImplementation(
|
|
40
|
+
(params: { resourceIds: string[] }) =>
|
|
41
|
+
Promise.resolve(params.resourceIds)
|
|
42
|
+
),
|
|
40
43
|
},
|
|
41
44
|
healthCheckRegistry: {
|
|
42
|
-
|
|
45
|
+
register: mock(),
|
|
43
46
|
getStrategies: mock().mockReturnValue([]),
|
|
44
47
|
getStrategy: mock(),
|
|
48
|
+
getStrategiesWithMeta: mock().mockReturnValue([]),
|
|
45
49
|
} as unknown as HealthCheckRegistry,
|
|
50
|
+
collectorRegistry: {
|
|
51
|
+
register: mock(),
|
|
52
|
+
getCollector: mock(),
|
|
53
|
+
getCollectors: mock().mockReturnValue([]),
|
|
54
|
+
getCollectorsForPlugin: mock().mockReturnValue([]),
|
|
55
|
+
} as unknown as CollectorRegistry,
|
|
46
56
|
queuePluginRegistry: {
|
|
47
57
|
register: mock(),
|
|
48
58
|
getPlugin: 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
|
/**
|