@checkstack/backend 0.2.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 +82 -0
- package/package.json +1 -1
- package/src/index.ts +4 -8
- package/src/integration/event-bus.integration.test.ts +23 -23
- package/src/openapi-router.ts +15 -15
- package/src/plugin-lifecycle.test.ts +10 -10
- package/src/plugin-manager/core-services.ts +14 -14
- package/src/plugin-manager/plugin-loader.ts +21 -28
- package/src/plugin-manager.test.ts +45 -23
- package/src/plugin-manager.ts +21 -26
- package/src/rpc-rest-compat.test.ts +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,87 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9faec1f: # Unified AccessRule Terminology Refactoring
|
|
8
|
+
|
|
9
|
+
This release completes a comprehensive terminology refactoring from "permission" to "accessRule" across the entire codebase, establishing a consistent and modern access control vocabulary.
|
|
10
|
+
|
|
11
|
+
## Changes
|
|
12
|
+
|
|
13
|
+
### Core Infrastructure (`@checkstack/common`)
|
|
14
|
+
|
|
15
|
+
- Introduced `AccessRule` interface as the primary access control type
|
|
16
|
+
- Added `accessPair()` helper for creating read/manage access rule pairs
|
|
17
|
+
- Added `access()` builder for individual access rules
|
|
18
|
+
- Replaced `Permission` type with `AccessRule` throughout
|
|
19
|
+
|
|
20
|
+
### API Changes
|
|
21
|
+
|
|
22
|
+
- `env.registerPermissions()` → `env.registerAccessRules()`
|
|
23
|
+
- `meta.permissions` → `meta.access` in RPC contracts
|
|
24
|
+
- `usePermission()` → `useAccess()` in frontend hooks
|
|
25
|
+
- Route `permission:` field → `accessRule:` field
|
|
26
|
+
|
|
27
|
+
### UI Changes
|
|
28
|
+
|
|
29
|
+
- "Roles & Permissions" tab → "Roles & Access Rules"
|
|
30
|
+
- "You don't have permission..." → "You don't have access..."
|
|
31
|
+
- All permission-related UI text updated
|
|
32
|
+
|
|
33
|
+
### Documentation & Templates
|
|
34
|
+
|
|
35
|
+
- Updated 18 documentation files with AccessRule terminology
|
|
36
|
+
- Updated 7 scaffolding templates with `accessPair()` pattern
|
|
37
|
+
- All code examples use new AccessRule API
|
|
38
|
+
|
|
39
|
+
## Migration Guide
|
|
40
|
+
|
|
41
|
+
### Backend Plugins
|
|
42
|
+
|
|
43
|
+
```diff
|
|
44
|
+
- import { permissionList } from "./permissions";
|
|
45
|
+
- env.registerPermissions(permissionList);
|
|
46
|
+
+ import { accessRules } from "./access";
|
|
47
|
+
+ env.registerAccessRules(accessRules);
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### RPC Contracts
|
|
51
|
+
|
|
52
|
+
```diff
|
|
53
|
+
- .meta({ userType: "user", permissions: [permissions.read.id] })
|
|
54
|
+
+ .meta({ userType: "user", access: [access.read] })
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Frontend Hooks
|
|
58
|
+
|
|
59
|
+
```diff
|
|
60
|
+
- const canRead = accessApi.usePermission(permissions.read.id);
|
|
61
|
+
+ const canRead = accessApi.useAccess(access.read);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Routes
|
|
65
|
+
|
|
66
|
+
```diff
|
|
67
|
+
- permission: permissions.entityRead.id,
|
|
68
|
+
+ accessRule: access.read,
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Patch Changes
|
|
72
|
+
|
|
73
|
+
- Updated dependencies [9faec1f]
|
|
74
|
+
- Updated dependencies [827b286]
|
|
75
|
+
- Updated dependencies [f533141]
|
|
76
|
+
- Updated dependencies [aa4a8ab]
|
|
77
|
+
- @checkstack/api-docs-common@0.1.0
|
|
78
|
+
- @checkstack/auth-common@0.2.0
|
|
79
|
+
- @checkstack/backend-api@0.3.0
|
|
80
|
+
- @checkstack/common@0.2.0
|
|
81
|
+
- @checkstack/signal-backend@0.1.0
|
|
82
|
+
- @checkstack/signal-common@0.1.0
|
|
83
|
+
- @checkstack/queue-api@0.0.5
|
|
84
|
+
|
|
3
85
|
## 0.2.0
|
|
4
86
|
|
|
5
87
|
### Minor Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -25,9 +25,8 @@ import {
|
|
|
25
25
|
import { createPluginAdminRouter } from "./plugin-manager/plugin-admin-router";
|
|
26
26
|
import {
|
|
27
27
|
pluginMetadata as apiDocsMetadata,
|
|
28
|
-
|
|
28
|
+
apiDocsAccess,
|
|
29
29
|
} from "@checkstack/api-docs-common";
|
|
30
|
-
import { qualifyPermissionId } from "@checkstack/common";
|
|
31
30
|
|
|
32
31
|
import { cors } from "hono/cors";
|
|
33
32
|
|
|
@@ -241,10 +240,7 @@ const init = async () => {
|
|
|
241
240
|
pluginManager,
|
|
242
241
|
authService,
|
|
243
242
|
baseUrl,
|
|
244
|
-
|
|
245
|
-
apiDocsMetadata,
|
|
246
|
-
apiDocsPermissions.apiDocsView
|
|
247
|
-
),
|
|
243
|
+
requiredAccessRule: `${apiDocsMetadata.pluginId}.${apiDocsAccess.view.id}`,
|
|
248
244
|
});
|
|
249
245
|
app.get("/api/openapi.json", async (c) => {
|
|
250
246
|
const response = await openApiHandler(c.req.raw);
|
|
@@ -260,7 +256,7 @@ const init = async () => {
|
|
|
260
256
|
// 3. Load Plugins
|
|
261
257
|
await pluginManager.loadPlugins(app);
|
|
262
258
|
|
|
263
|
-
// 4. Wire up auth client for
|
|
259
|
+
// 4. Wire up auth client for access-based signal filtering
|
|
264
260
|
// This must happen AFTER plugins load so auth-backend is available
|
|
265
261
|
const rpcClient = await pluginManager.getService(coreServices.rpcClient);
|
|
266
262
|
if (rpcClient) {
|
|
@@ -268,7 +264,7 @@ const init = async () => {
|
|
|
268
264
|
const authClient = rpcClient.forPlugin(AuthApi);
|
|
269
265
|
signalService.setAuthClient(authClient);
|
|
270
266
|
rootLogger.debug(
|
|
271
|
-
"SignalService: Auth client configured for
|
|
267
|
+
"SignalService: Auth client configured for access filtering"
|
|
272
268
|
);
|
|
273
269
|
} else {
|
|
274
270
|
rootLogger.warn(
|
|
@@ -19,43 +19,43 @@ describe("EventBus Integration Tests", () => {
|
|
|
19
19
|
eventBus = new EventBus(mockQueueManager, mockLogger);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
describe("
|
|
23
|
-
it("should sync
|
|
24
|
-
// Simulate the
|
|
25
|
-
const
|
|
22
|
+
describe("Access Rule Sync Scenario", () => {
|
|
23
|
+
it("should sync access rules across plugins using work-queue mode", async () => {
|
|
24
|
+
// Simulate the accessRulesRegistered hook
|
|
25
|
+
const accessRulesRegistered = createHook<{
|
|
26
26
|
pluginId: string;
|
|
27
|
-
|
|
28
|
-
}>("core.
|
|
27
|
+
accessRules: Array<{ id: string; description?: string }>;
|
|
28
|
+
}>("core.accessRulesRegistered");
|
|
29
29
|
|
|
30
|
-
const
|
|
30
|
+
const syncedAccessRules: Array<{ id: string; description?: string }> = [];
|
|
31
31
|
|
|
32
|
-
// Auth-backend subscribes to sync
|
|
32
|
+
// Auth-backend subscribes to sync access rules (work-queue mode)
|
|
33
33
|
await eventBus.subscribe(
|
|
34
34
|
"auth-backend",
|
|
35
|
-
|
|
36
|
-
async ({
|
|
35
|
+
accessRulesRegistered,
|
|
36
|
+
async ({ accessRules }) => {
|
|
37
37
|
// Simulate DB sync
|
|
38
|
-
|
|
38
|
+
syncedAccessRules.push(...accessRules);
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
mode: "work-queue",
|
|
42
|
-
workerGroup: "
|
|
42
|
+
workerGroup: "access-rule-db-sync",
|
|
43
43
|
maxRetries: 3,
|
|
44
44
|
}
|
|
45
45
|
);
|
|
46
46
|
|
|
47
|
-
// Emit
|
|
48
|
-
await eventBus.emit(
|
|
47
|
+
// Emit access rule registration events from different plugins
|
|
48
|
+
await eventBus.emit(accessRulesRegistered, {
|
|
49
49
|
pluginId: "catalog",
|
|
50
|
-
|
|
50
|
+
accessRules: [
|
|
51
51
|
{ id: "catalog-backend.read", description: "Read catalog" },
|
|
52
52
|
{ id: "catalog-backend.manage", description: "Manage catalog" },
|
|
53
53
|
],
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
await eventBus.emit(
|
|
56
|
+
await eventBus.emit(accessRulesRegistered, {
|
|
57
57
|
pluginId: "queue",
|
|
58
|
-
|
|
58
|
+
accessRules: [
|
|
59
59
|
{ id: "queue-backend.read", description: "Read queue" },
|
|
60
60
|
{ id: "queue-backend.manage", description: "Manage queue" },
|
|
61
61
|
],
|
|
@@ -64,18 +64,18 @@ describe("EventBus Integration Tests", () => {
|
|
|
64
64
|
// Wait for async processing
|
|
65
65
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
66
66
|
|
|
67
|
-
// All
|
|
68
|
-
expect(
|
|
69
|
-
expect(
|
|
67
|
+
// All access rules should be synced
|
|
68
|
+
expect(syncedAccessRules.length).toBe(4);
|
|
69
|
+
expect(syncedAccessRules.map((p) => p.id)).toContain(
|
|
70
70
|
"catalog-backend.read"
|
|
71
71
|
);
|
|
72
|
-
expect(
|
|
72
|
+
expect(syncedAccessRules.map((p) => p.id)).toContain(
|
|
73
73
|
"catalog-backend.manage"
|
|
74
74
|
);
|
|
75
|
-
expect(
|
|
75
|
+
expect(syncedAccessRules.map((p) => p.id)).toContain(
|
|
76
76
|
"queue-backend.read"
|
|
77
77
|
);
|
|
78
|
-
expect(
|
|
78
|
+
expect(syncedAccessRules.map((p) => p.id)).toContain(
|
|
79
79
|
"queue-backend.manage"
|
|
80
80
|
);
|
|
81
81
|
});
|
package/src/openapi-router.ts
CHANGED
|
@@ -12,16 +12,16 @@ import type { PluginManager } from "./plugin-manager";
|
|
|
12
12
|
import type { AuthService } from "@checkstack/backend-api";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Check if a user has a specific
|
|
15
|
+
* Check if a user has a specific access rule.
|
|
16
16
|
* Supports wildcard (*) for admin access.
|
|
17
17
|
*/
|
|
18
|
-
function
|
|
19
|
-
user: {
|
|
20
|
-
|
|
18
|
+
function hasAccess(
|
|
19
|
+
user: { accessRules?: string[] },
|
|
20
|
+
accessRule: string
|
|
21
21
|
): boolean {
|
|
22
|
-
if (!user.
|
|
22
|
+
if (!user.accessRules) return false;
|
|
23
23
|
return (
|
|
24
|
-
user.
|
|
24
|
+
user.accessRules.includes("*") || user.accessRules.includes(accessRule)
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -30,9 +30,9 @@ function hasPermission(
|
|
|
30
30
|
*/
|
|
31
31
|
function extractProcedureMetadata(
|
|
32
32
|
contract: unknown
|
|
33
|
-
): { userType?: string;
|
|
33
|
+
): { userType?: string; accessRules?: string[] } | undefined {
|
|
34
34
|
const orpcData = (contract as Record<string, unknown>)?.["~orpc"] as
|
|
35
|
-
| { meta?: { userType?: string;
|
|
35
|
+
| { meta?: { userType?: string; accessRules?: string[] } }
|
|
36
36
|
| undefined;
|
|
37
37
|
return orpcData?.meta;
|
|
38
38
|
}
|
|
@@ -43,10 +43,10 @@ function extractProcedureMetadata(
|
|
|
43
43
|
*/
|
|
44
44
|
function buildMetadataLookup(
|
|
45
45
|
contracts: Map<string, AnyContractRouter>
|
|
46
|
-
): Map<string, { userType?: string;
|
|
46
|
+
): Map<string, { userType?: string; accessRules?: string[] }> {
|
|
47
47
|
const lookup = new Map<
|
|
48
48
|
string,
|
|
49
|
-
{ userType?: string;
|
|
49
|
+
{ userType?: string; accessRules?: string[] }
|
|
50
50
|
>();
|
|
51
51
|
|
|
52
52
|
for (const [pluginId, contract] of contracts) {
|
|
@@ -141,12 +141,12 @@ export function createOpenApiHandler({
|
|
|
141
141
|
pluginManager,
|
|
142
142
|
authService,
|
|
143
143
|
baseUrl,
|
|
144
|
-
|
|
144
|
+
requiredAccessRule,
|
|
145
145
|
}: {
|
|
146
146
|
pluginManager: PluginManager;
|
|
147
147
|
authService: AuthService;
|
|
148
148
|
baseUrl: string;
|
|
149
|
-
|
|
149
|
+
requiredAccessRule: string;
|
|
150
150
|
}): (req: Request) => Promise<Response> {
|
|
151
151
|
return async (req: Request) => {
|
|
152
152
|
// Authenticate request
|
|
@@ -156,9 +156,9 @@ export function createOpenApiHandler({
|
|
|
156
156
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// Check
|
|
160
|
-
// Services don't have
|
|
161
|
-
if (user.type === "service" || !
|
|
159
|
+
// Check access rule (applications.manage from auth plugin)
|
|
160
|
+
// Services don't have accesss, so deny them access to docs
|
|
161
|
+
if (user.type === "service" || !hasAccess(user, requiredAccessRule)) {
|
|
162
162
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
163
163
|
}
|
|
164
164
|
|
|
@@ -233,18 +233,18 @@ describe("Plugin Lifecycle", () => {
|
|
|
233
233
|
expect(pluginRpcRouters.has("test-plugin")).toBe(false);
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
it("should clear
|
|
237
|
-
const
|
|
238
|
-
"
|
|
236
|
+
it("should clear access rules for plugin", async () => {
|
|
237
|
+
const registeredAccessRules = (pluginManager as never)[
|
|
238
|
+
"registeredAccessRules"
|
|
239
239
|
] as { pluginId: string; id: string }[];
|
|
240
240
|
|
|
241
|
-
// Clear existing
|
|
242
|
-
while (
|
|
243
|
-
|
|
241
|
+
// Clear existing access rules first
|
|
242
|
+
while (registeredAccessRules.length > 0) {
|
|
243
|
+
registeredAccessRules.pop();
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
// Add test
|
|
247
|
-
|
|
246
|
+
// Add test access rules
|
|
247
|
+
registeredAccessRules.push(
|
|
248
248
|
{ pluginId: "test-plugin", id: "test-plugin.perm1" },
|
|
249
249
|
{ pluginId: "test-plugin", id: "test-plugin.perm2" },
|
|
250
250
|
{ pluginId: "other-plugin", id: "other-plugin.perm1" }
|
|
@@ -254,8 +254,8 @@ describe("Plugin Lifecycle", () => {
|
|
|
254
254
|
deleteSchema: false,
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
-
// Use
|
|
258
|
-
const remaining = pluginManager.
|
|
257
|
+
// Use getAllAccessRules() which returns the current array
|
|
258
|
+
const remaining = pluginManager.getAllAccessRules();
|
|
259
259
|
expect(remaining).toHaveLength(1);
|
|
260
260
|
expect(remaining[0].id).toBe("other-plugin.perm1");
|
|
261
261
|
});
|
|
@@ -100,8 +100,8 @@ export function registerCoreServices({
|
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
// 3. Auth Factory (Scoped)
|
|
103
|
-
// Cache for anonymous
|
|
104
|
-
let
|
|
103
|
+
// Cache for anonymous access rules to avoid repeated DB queries
|
|
104
|
+
let anonymousAccessRulesCache: string[] | undefined;
|
|
105
105
|
let anonymousCacheTime = 0;
|
|
106
106
|
const CACHE_TTL_MS = 60_000; // 1 minute cache
|
|
107
107
|
|
|
@@ -146,33 +146,33 @@ export function registerCoreServices({
|
|
|
146
146
|
return { headers: { Authorization: `Bearer ${token}` } };
|
|
147
147
|
},
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
getAnonymousAccessRules: async (): Promise<string[]> => {
|
|
150
150
|
const now = Date.now();
|
|
151
151
|
// Return cached value if still valid
|
|
152
152
|
if (
|
|
153
|
-
|
|
153
|
+
anonymousAccessRulesCache !== undefined &&
|
|
154
154
|
now - anonymousCacheTime < CACHE_TTL_MS
|
|
155
155
|
) {
|
|
156
|
-
return
|
|
156
|
+
return anonymousAccessRulesCache;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// Use RPC client to call auth-backend's
|
|
159
|
+
// Use RPC client to call auth-backend's getAnonymousAccessRules endpoint
|
|
160
160
|
try {
|
|
161
161
|
const rpcClient = await registry.get(coreServices.rpcClient, {
|
|
162
162
|
pluginId: "core",
|
|
163
163
|
});
|
|
164
164
|
const authClient = rpcClient.forPlugin(AuthApi);
|
|
165
|
-
const
|
|
165
|
+
const accessRulesResult = await authClient.getAnonymousAccessRules();
|
|
166
166
|
|
|
167
167
|
// Update cache
|
|
168
|
-
|
|
168
|
+
anonymousAccessRulesCache = accessRulesResult;
|
|
169
169
|
anonymousCacheTime = now;
|
|
170
170
|
|
|
171
|
-
return
|
|
171
|
+
return accessRulesResult;
|
|
172
172
|
} catch (error) {
|
|
173
173
|
// RPC client not available yet (during startup), return empty
|
|
174
174
|
rootLogger.warn(
|
|
175
|
-
`[auth]
|
|
175
|
+
`[auth] getAnonymousAccessRules: RPC failed, returning empty array. Error: ${error}`
|
|
176
176
|
);
|
|
177
177
|
return [];
|
|
178
178
|
}
|
|
@@ -186,8 +186,8 @@ export function registerCoreServices({
|
|
|
186
186
|
const authClient = rpcClient.forPlugin(AuthApi);
|
|
187
187
|
return await authClient.checkResourceTeamAccess(params);
|
|
188
188
|
} catch {
|
|
189
|
-
// Fall back to global
|
|
190
|
-
return { hasAccess: params.
|
|
189
|
+
// Fall back to global access on error
|
|
190
|
+
return { hasAccess: params.hasGlobalAccess };
|
|
191
191
|
}
|
|
192
192
|
},
|
|
193
193
|
|
|
@@ -199,8 +199,8 @@ export function registerCoreServices({
|
|
|
199
199
|
const authClient = rpcClient.forPlugin(AuthApi);
|
|
200
200
|
return await authClient.getAccessibleResourceIds(params);
|
|
201
201
|
} catch {
|
|
202
|
-
// Fall back to global
|
|
203
|
-
return params.
|
|
202
|
+
// Fall back to global access on error
|
|
203
|
+
return params.hasGlobalAccess ? params.resourceIds : [];
|
|
204
204
|
}
|
|
205
205
|
},
|
|
206
206
|
};
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
HookSubscribeOptions,
|
|
19
19
|
RpcContext,
|
|
20
20
|
} from "@checkstack/backend-api";
|
|
21
|
-
import type {
|
|
21
|
+
import type { AccessRule } from "@checkstack/common";
|
|
22
22
|
import { getPluginSchemaName } from "@checkstack/drizzle-helper";
|
|
23
23
|
import { rootLogger } from "../logger";
|
|
24
24
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
@@ -41,8 +41,8 @@ export interface PluginLoaderDeps {
|
|
|
41
41
|
pluginRpcRouters: Map<string, unknown>;
|
|
42
42
|
pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
|
|
43
43
|
extensionPointManager: ExtensionPointManager;
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
registeredAccessRules: (AccessRule & { pluginId: string })[];
|
|
45
|
+
getAllAccessRules: () => AccessRule[];
|
|
46
46
|
db: NodePgDatabase<Record<string, unknown>>;
|
|
47
47
|
/**
|
|
48
48
|
* Map of pluginId -> PluginMetadata for request-time context injection.
|
|
@@ -121,18 +121,16 @@ export function registerPlugin({
|
|
|
121
121
|
getExtensionPoint: (ref) => {
|
|
122
122
|
return deps.extensionPointManager.getExtensionPoint(ref);
|
|
123
123
|
},
|
|
124
|
-
|
|
125
|
-
// Store
|
|
126
|
-
const prefixed =
|
|
124
|
+
registerAccessRules: (accessRules: AccessRule[]) => {
|
|
125
|
+
// Store access rules with pluginId prefix to namespace them
|
|
126
|
+
const prefixed = accessRules.map((rule) => ({
|
|
127
|
+
...rule,
|
|
127
128
|
pluginId: pluginId,
|
|
128
|
-
id: `${pluginId}.${
|
|
129
|
-
description: p.description,
|
|
130
|
-
isAuthenticatedDefault: p.isAuthenticatedDefault,
|
|
131
|
-
isPublicDefault: p.isPublicDefault,
|
|
129
|
+
id: `${pluginId}.${rule.id}`,
|
|
132
130
|
}));
|
|
133
|
-
deps.
|
|
131
|
+
deps.registeredAccessRules.push(...prefixed);
|
|
134
132
|
rootLogger.debug(
|
|
135
|
-
` -> Registered ${prefixed.length}
|
|
133
|
+
` -> Registered ${prefixed.length} access rules for ${pluginId}`
|
|
136
134
|
);
|
|
137
135
|
},
|
|
138
136
|
registerRouter: (
|
|
@@ -150,7 +148,7 @@ export function registerPlugin({
|
|
|
150
148
|
rootLogger.debug(` -> Registered cleanup handler for ${pluginId}`);
|
|
151
149
|
},
|
|
152
150
|
pluginManager: {
|
|
153
|
-
|
|
151
|
+
getAllAccessRules: () => deps.getAllAccessRules(),
|
|
154
152
|
},
|
|
155
153
|
});
|
|
156
154
|
}
|
|
@@ -374,29 +372,24 @@ export async function loadPlugins({
|
|
|
374
372
|
// Phase 3: Run afterPluginsReady callbacks
|
|
375
373
|
rootLogger.debug("🔄 Running afterPluginsReady callbacks...");
|
|
376
374
|
|
|
377
|
-
// Emit
|
|
375
|
+
// Emit access rule registration hooks at start of Phase 3
|
|
378
376
|
// (EventBus already retrieved above, all plugins can receive notifications)
|
|
379
|
-
const
|
|
380
|
-
for (const
|
|
381
|
-
if (!
|
|
382
|
-
|
|
377
|
+
const accessRulesByPlugin = new Map<string, AccessRule[]>();
|
|
378
|
+
for (const { pluginId, ...rule } of deps.registeredAccessRules) {
|
|
379
|
+
if (!accessRulesByPlugin.has(pluginId)) {
|
|
380
|
+
accessRulesByPlugin.set(pluginId, []);
|
|
383
381
|
}
|
|
384
|
-
|
|
385
|
-
id: perm.id,
|
|
386
|
-
description: perm.description,
|
|
387
|
-
isAuthenticatedDefault: perm.isAuthenticatedDefault,
|
|
388
|
-
isPublicDefault: perm.isPublicDefault,
|
|
389
|
-
});
|
|
382
|
+
accessRulesByPlugin.get(pluginId)!.push(rule);
|
|
390
383
|
}
|
|
391
|
-
for (const [pluginId,
|
|
384
|
+
for (const [pluginId, accessRules] of accessRulesByPlugin) {
|
|
392
385
|
try {
|
|
393
|
-
await eventBus.emit(coreHooks.
|
|
386
|
+
await eventBus.emit(coreHooks.accessRulesRegistered, {
|
|
394
387
|
pluginId,
|
|
395
|
-
|
|
388
|
+
accessRules,
|
|
396
389
|
});
|
|
397
390
|
} catch (error) {
|
|
398
391
|
rootLogger.error(
|
|
399
|
-
`Failed to emit
|
|
392
|
+
`Failed to emit accessRulesRegistered hook for ${pluginId}:`,
|
|
400
393
|
error
|
|
401
394
|
);
|
|
402
395
|
}
|
|
@@ -375,54 +375,76 @@ describe("PluginManager", () => {
|
|
|
375
375
|
});
|
|
376
376
|
});
|
|
377
377
|
|
|
378
|
-
describe("
|
|
379
|
-
it("should store
|
|
380
|
-
//
|
|
378
|
+
describe("Access Rule Registration", () => {
|
|
379
|
+
it("should store access rules in the registry", () => {
|
|
380
|
+
// Access rules are now stored directly via the registeredAccessRules array
|
|
381
381
|
// and hooks are emitted in Phase 3 (afterPluginsReady)
|
|
382
382
|
const perms = (
|
|
383
383
|
pluginManager as unknown as {
|
|
384
|
-
|
|
384
|
+
registeredAccessRules: {
|
|
385
385
|
pluginId: string;
|
|
386
386
|
id: string;
|
|
387
|
-
|
|
387
|
+
resource: string;
|
|
388
|
+
level: string;
|
|
389
|
+
description: string;
|
|
388
390
|
}[];
|
|
389
391
|
}
|
|
390
|
-
).
|
|
392
|
+
).registeredAccessRules;
|
|
391
393
|
|
|
392
|
-
// Add
|
|
394
|
+
// Add access rules directly (simulating what plugin-loader does)
|
|
393
395
|
perms.push({
|
|
394
396
|
pluginId: "test-plugin",
|
|
395
|
-
id: "test-plugin.test.
|
|
396
|
-
|
|
397
|
+
id: "test-plugin.test.accessRule",
|
|
398
|
+
resource: "test",
|
|
399
|
+
level: "read",
|
|
400
|
+
description: "Test access rule",
|
|
397
401
|
});
|
|
398
402
|
|
|
399
|
-
//
|
|
400
|
-
const all = pluginManager.
|
|
403
|
+
// getAllAccessRules should return them (without pluginId in the output)
|
|
404
|
+
const all = pluginManager.getAllAccessRules();
|
|
401
405
|
expect(all.length).toBe(1);
|
|
402
|
-
expect(all[0]).
|
|
403
|
-
|
|
404
|
-
description: "Test permission",
|
|
405
|
-
});
|
|
406
|
+
expect(all[0].id).toBe("test-plugin.test.accessRule");
|
|
407
|
+
expect(all[0].description).toBe("Test access rule");
|
|
406
408
|
});
|
|
407
409
|
|
|
408
|
-
it("should aggregate
|
|
410
|
+
it("should aggregate access rules from multiple plugins", () => {
|
|
409
411
|
const perms = (
|
|
410
412
|
pluginManager as unknown as {
|
|
411
|
-
|
|
413
|
+
registeredAccessRules: {
|
|
412
414
|
pluginId: string;
|
|
413
415
|
id: string;
|
|
414
|
-
|
|
416
|
+
resource: string;
|
|
417
|
+
level: string;
|
|
418
|
+
description: string;
|
|
415
419
|
}[];
|
|
416
420
|
}
|
|
417
|
-
).
|
|
421
|
+
).registeredAccessRules;
|
|
418
422
|
|
|
419
423
|
perms.push(
|
|
420
|
-
{
|
|
421
|
-
|
|
422
|
-
|
|
424
|
+
{
|
|
425
|
+
pluginId: "plugin-1",
|
|
426
|
+
id: "plugin-1.perm.1",
|
|
427
|
+
resource: "perm",
|
|
428
|
+
level: "read",
|
|
429
|
+
description: "Access Rule 1",
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
pluginId: "plugin-1",
|
|
433
|
+
id: "plugin-1.perm.2",
|
|
434
|
+
resource: "perm",
|
|
435
|
+
level: "manage",
|
|
436
|
+
description: "Access Rule 2",
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
pluginId: "plugin-2",
|
|
440
|
+
id: "plugin-2.perm.3",
|
|
441
|
+
resource: "perm",
|
|
442
|
+
level: "read",
|
|
443
|
+
description: "Access Rule 3",
|
|
444
|
+
}
|
|
423
445
|
);
|
|
424
446
|
|
|
425
|
-
const all = pluginManager.
|
|
447
|
+
const all = pluginManager.getAllAccessRules();
|
|
426
448
|
expect(all.length).toBe(3);
|
|
427
449
|
});
|
|
428
450
|
});
|
package/src/plugin-manager.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
HookUnsubscribe,
|
|
12
12
|
} from "@checkstack/backend-api";
|
|
13
13
|
import type { AnyContractRouter } from "@orpc/contract";
|
|
14
|
-
import type {
|
|
14
|
+
import type { AccessRule, PluginMetadata } from "@checkstack/common";
|
|
15
15
|
|
|
16
16
|
// Extracted modules
|
|
17
17
|
import { registerCoreServices } from "./plugin-manager/core-services";
|
|
@@ -32,8 +32,8 @@ export class PluginManager {
|
|
|
32
32
|
>();
|
|
33
33
|
private extensionPointManager = createExtensionPointManager();
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
private
|
|
35
|
+
// Access rule registry - stores all registered access rules with pluginId for hook emission
|
|
36
|
+
private registeredAccessRules: (AccessRule & { pluginId: string })[] = [];
|
|
37
37
|
|
|
38
38
|
// Plugin metadata registry - stores PluginMetadata for request-time context injection
|
|
39
39
|
private pluginMetadataRegistry = new Map<string, PluginMetadata>();
|
|
@@ -77,14 +77,9 @@ export class PluginManager {
|
|
|
77
77
|
this.pluginRpcRouters.set(routerId, router);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
return this.
|
|
82
|
-
({
|
|
83
|
-
id,
|
|
84
|
-
description,
|
|
85
|
-
isAuthenticatedDefault,
|
|
86
|
-
isPublicDefault,
|
|
87
|
-
})
|
|
80
|
+
getAllAccessRules(): AccessRule[] {
|
|
81
|
+
return this.registeredAccessRules.map(
|
|
82
|
+
({ pluginId: _pluginId, ...rule }) => rule
|
|
88
83
|
);
|
|
89
84
|
}
|
|
90
85
|
|
|
@@ -110,8 +105,8 @@ export class PluginManager {
|
|
|
110
105
|
pluginRpcRouters: this.pluginRpcRouters,
|
|
111
106
|
pluginHttpHandlers: this.pluginHttpHandlers,
|
|
112
107
|
extensionPointManager: this.extensionPointManager,
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
registeredAccessRules: this.registeredAccessRules,
|
|
109
|
+
getAllAccessRules: () => this.getAllAccessRules(),
|
|
115
110
|
db,
|
|
116
111
|
pluginMetadataRegistry: this.pluginMetadataRegistry,
|
|
117
112
|
cleanupHandlers: this.cleanupHandlers,
|
|
@@ -178,15 +173,15 @@ export class PluginManager {
|
|
|
178
173
|
);
|
|
179
174
|
this.collectorRegistry.unregisterByMissingStrategies(loadedPluginIds);
|
|
180
175
|
|
|
181
|
-
// 5. Remove
|
|
182
|
-
const beforeCount = this.
|
|
183
|
-
this.
|
|
176
|
+
// 5. Remove access rules from registry
|
|
177
|
+
const beforeCount = this.registeredAccessRules.length;
|
|
178
|
+
this.registeredAccessRules = this.registeredAccessRules.filter(
|
|
184
179
|
(p) => p.pluginId !== pluginId
|
|
185
180
|
);
|
|
186
181
|
rootLogger.debug(
|
|
187
182
|
` -> Removed ${
|
|
188
|
-
beforeCount - this.
|
|
189
|
-
}
|
|
183
|
+
beforeCount - this.registeredAccessRules.length
|
|
184
|
+
} access rules`
|
|
190
185
|
);
|
|
191
186
|
|
|
192
187
|
// 6. Drop schema if requested
|
|
@@ -200,7 +195,7 @@ export class PluginManager {
|
|
|
200
195
|
}
|
|
201
196
|
}
|
|
202
197
|
|
|
203
|
-
// 7. Emit pluginDeregistered hook (for
|
|
198
|
+
// 7. Emit pluginDeregistered hook (for access rule cleanup in auth-backend)
|
|
204
199
|
await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
|
|
205
200
|
|
|
206
201
|
rootLogger.info(`✅ Plugin deregistered: ${pluginId}`);
|
|
@@ -390,18 +385,18 @@ export class PluginManager {
|
|
|
390
385
|
},
|
|
391
386
|
});
|
|
392
387
|
},
|
|
393
|
-
|
|
394
|
-
const prefixed =
|
|
388
|
+
registerAccessRules: (accessRules) => {
|
|
389
|
+
const prefixed = accessRules.map((p) => ({
|
|
395
390
|
...p,
|
|
396
391
|
id: `${metaPluginId}.${p.id}`,
|
|
397
392
|
pluginId: metaPluginId,
|
|
398
393
|
}));
|
|
399
|
-
this.
|
|
394
|
+
this.registeredAccessRules.push(...prefixed);
|
|
400
395
|
|
|
401
|
-
// Emit
|
|
402
|
-
eventBus.emit(coreHooks.
|
|
396
|
+
// Emit access rule hook
|
|
397
|
+
eventBus.emit(coreHooks.accessRulesRegistered, {
|
|
403
398
|
pluginId: metaPluginId,
|
|
404
|
-
|
|
399
|
+
accessRules: prefixed,
|
|
405
400
|
});
|
|
406
401
|
},
|
|
407
402
|
registerService: (ref, impl) => {
|
|
@@ -422,7 +417,7 @@ export class PluginManager {
|
|
|
422
417
|
this.pluginContractRegistry.set(metaPluginId, contract);
|
|
423
418
|
},
|
|
424
419
|
pluginManager: {
|
|
425
|
-
|
|
420
|
+
getAllAccessRules: () => this.getAllAccessRules(),
|
|
426
421
|
},
|
|
427
422
|
});
|
|
428
423
|
|
|
@@ -19,11 +19,11 @@ describe("RPC REST Compatibility", () => {
|
|
|
19
19
|
app = new Hono();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
it("should handle GET /api/auth/
|
|
22
|
+
it("should handle GET /api/auth/accessRules via oRPC router", async () => {
|
|
23
23
|
// 1. Setup a mock auth router
|
|
24
24
|
const authRouter = os.router({
|
|
25
|
-
|
|
26
|
-
return {
|
|
25
|
+
accessRules: os.handler(async () => {
|
|
26
|
+
return { accessRules: ["test-perm"] };
|
|
27
27
|
}),
|
|
28
28
|
});
|
|
29
29
|
|
|
@@ -33,11 +33,11 @@ describe("RPC REST Compatibility", () => {
|
|
|
33
33
|
// The new API auto-prefixes based on pluginId, but for test we need to manually set the map key
|
|
34
34
|
// Since we're testing the router handler directly, we use the derived name "auth"
|
|
35
35
|
// Second argument is the contract (for OpenAPI generation) - using a mock object for test
|
|
36
|
-
rpcService?.registerRouter(authRouter, {
|
|
36
|
+
rpcService?.registerRouter(authRouter, { accessRules: {} });
|
|
37
37
|
|
|
38
38
|
// 3. Mock the auth service to skip real authentication
|
|
39
39
|
const mockAuth: any = {
|
|
40
|
-
authenticate: mock(async () => ({ id: "user-1",
|
|
40
|
+
authenticate: mock(async () => ({ id: "user-1", accessRules: ["*"] })),
|
|
41
41
|
};
|
|
42
42
|
pluginManager.registerService(coreServices.auth, mockAuth);
|
|
43
43
|
|
|
@@ -63,7 +63,7 @@ describe("RPC REST Compatibility", () => {
|
|
|
63
63
|
await pluginManager.loadPlugins(app);
|
|
64
64
|
|
|
65
65
|
// 5. Simulate the request that frontend makes (now /api/auth instead of /api/auth-backend)
|
|
66
|
-
const res = await app.request("/api/auth/
|
|
66
|
+
const res = await app.request("/api/auth/accessRules", {
|
|
67
67
|
method: "GET",
|
|
68
68
|
});
|
|
69
69
|
|
|
@@ -72,7 +72,7 @@ describe("RPC REST Compatibility", () => {
|
|
|
72
72
|
if (res.status === 200) {
|
|
73
73
|
const body = await res.json();
|
|
74
74
|
console.log("Response body:", JSON.stringify(body));
|
|
75
|
-
expect(body.
|
|
75
|
+
expect(body.accessRules).toContain("test-perm");
|
|
76
76
|
} else {
|
|
77
77
|
console.log("Response text:", await res.text());
|
|
78
78
|
}
|