@checkstack/backend 0.17.1 → 0.18.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 +94 -0
- package/package.json +12 -12
- package/src/plugin-manager/app-principal-authz.test.ts +3 -1
- package/src/plugin-manager/plugin-loader.getservice.test.ts +1 -0
- package/src/plugin-manager/plugin-loader.skip-naming.test.ts +1 -0
- package/src/plugin-manager/plugin-loader.ts +7 -1
- package/src/plugin-manager.anon-rules.test.ts +61 -0
- package/src/plugin-manager.ts +55 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,99 @@
|
|
|
1
1
|
# @checkstack/backend
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0626782: Guard the role editor against granting inert (and misleading) permissions to the
|
|
8
|
+
anonymous role.
|
|
9
|
+
|
|
10
|
+
RPC procedures carry two independent axes: `userType` (the hard authentication
|
|
11
|
+
gate) and `access` rules (authorization). An admin can grant the anonymous role
|
|
12
|
+
any access rule, but if the procedures needing that rule are `userType:
|
|
13
|
+
"authenticated"`/`"user"`, the grant does nothing - the auth middleware rejects
|
|
14
|
+
unauthenticated callers BEFORE access rules are checked (so there is no security
|
|
15
|
+
hole; the grant is simply inert). After anonymous users started seeing
|
|
16
|
+
permission-gated UI, such a grant would surface as visible-but-broken controls.
|
|
17
|
+
|
|
18
|
+
- The backend now computes, from contract metadata, the access rules an anonymous
|
|
19
|
+
caller can actually use (a rule is "usable" iff at least one `public` procedure
|
|
20
|
+
requires it) via `pluginManager.getAnonymousUsableAccessRuleIds()`, exposed to
|
|
21
|
+
plugins through the plugin environment.
|
|
22
|
+
- `auth.getAccessRules` annotates each rule with `anonymousUsable`.
|
|
23
|
+
- `auth.updateRole` REFUSES to ADD a non-usable rule to the anonymous role
|
|
24
|
+
(existing grants are untouched, so no configuration can be wedged). This is a
|
|
25
|
+
guardrail, not an enforcement change - RPC authorization is unchanged.
|
|
26
|
+
- The role editor disables non-usable rules (with an explanation) when editing
|
|
27
|
+
the anonymous role.
|
|
28
|
+
|
|
29
|
+
Verified live: `getAccessRules` reports 11 anonymous-usable vs 58 not; granting
|
|
30
|
+
`incident.incident.manage` to the anonymous role returns HTTP 400 with a clear
|
|
31
|
+
message.
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
|
|
36
|
+
the anonymous role on the frontend.
|
|
37
|
+
|
|
38
|
+
Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
|
|
39
|
+
`incident.incident.read`) so two plugins defining the same short rule id never
|
|
40
|
+
collide. The frontend, however, was checking the UNqualified id (`incident.read`)
|
|
41
|
+
via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
|
|
42
|
+
(admin) grant - masked in development because dev-auth grants `*`. This silently
|
|
43
|
+
broke ALL non-admin frontend gating (route guards, sidebar entries, and
|
|
44
|
+
`useAccess`-based button/link gating).
|
|
45
|
+
|
|
46
|
+
- **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
|
|
47
|
+
`access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
|
|
48
|
+
qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
|
|
49
|
+
matches ONLY the qualified form. There is intentionally NO unqualified fallback
|
|
50
|
+
- matching a bare id would let one plugin's grant satisfy another plugin's
|
|
51
|
+
identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
|
|
52
|
+
that defines access rules now passes its own `pluginId`.
|
|
53
|
+
- **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
|
|
54
|
+
the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
|
|
55
|
+
- **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
|
|
56
|
+
checks the FULL rule object (so it qualifies and escalates), not a bare id.
|
|
57
|
+
- **Anonymous role on the frontend**: the `accessRules` procedure is now
|
|
58
|
+
`public`, returning the configurable anonymous role's grants to unauthenticated
|
|
59
|
+
callers; `useAccessRules` fetches them for guests instead of returning an empty
|
|
60
|
+
set. So anonymous UI now reflects exactly what the anonymous role is allowed -
|
|
61
|
+
which an admin can change (`isPublic` is only the seeded default).
|
|
62
|
+
- Incident / maintenance / SLO detail routes are now read-gated (their read rule
|
|
63
|
+
is an `isPublic` default, so the anonymous role holds it unless an admin
|
|
64
|
+
revokes it); their dashboard status signals carry that rule and render as a
|
|
65
|
+
link only when the viewer may open it.
|
|
66
|
+
|
|
67
|
+
**BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
|
|
68
|
+
`access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
|
|
69
|
+
matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
|
|
70
|
+
unqualified fallback is removed, because it was a cross-plugin
|
|
71
|
+
privilege-escalation flaw. Any code constructing an `AccessRule` or calling
|
|
72
|
+
`access()`/`accessPair()` must supply the owning `pluginId`.
|
|
73
|
+
|
|
74
|
+
Verified live against an anonymous caller: read pages resolve (qualified match),
|
|
75
|
+
manage actions are denied, manage->read escalation and `*` still work.
|
|
76
|
+
|
|
77
|
+
- Updated dependencies [0626782]
|
|
78
|
+
- Updated dependencies [56e7c75]
|
|
79
|
+
- @checkstack/backend-api@0.21.5
|
|
80
|
+
- @checkstack/auth-common@0.8.3
|
|
81
|
+
- @checkstack/common@0.15.0
|
|
82
|
+
- @checkstack/api-docs-common@0.1.19
|
|
83
|
+
- @checkstack/pluginmanager-common@0.2.8
|
|
84
|
+
- @checkstack/signal-backend@0.3.5
|
|
85
|
+
- @checkstack/cache-api@0.3.12
|
|
86
|
+
- @checkstack/queue-api@0.3.12
|
|
87
|
+
- @checkstack/signal-common@0.2.9
|
|
88
|
+
|
|
89
|
+
## 0.17.2
|
|
90
|
+
|
|
91
|
+
### Patch Changes
|
|
92
|
+
|
|
93
|
+
- Updated dependencies [b50916d]
|
|
94
|
+
- @checkstack/backend-api@0.21.4
|
|
95
|
+
- @checkstack/signal-backend@0.3.4
|
|
96
|
+
|
|
3
97
|
## 0.17.1
|
|
4
98
|
|
|
5
99
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "backend"
|
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
"lint:code": "eslint . --max-warnings 0"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@checkstack/api-docs-common": "0.1.
|
|
18
|
-
"@checkstack/auth-common": "0.8.
|
|
19
|
-
"@checkstack/backend-api": "0.21.
|
|
20
|
-
"@checkstack/common": "0.
|
|
17
|
+
"@checkstack/api-docs-common": "0.1.19",
|
|
18
|
+
"@checkstack/auth-common": "0.8.3",
|
|
19
|
+
"@checkstack/backend-api": "0.21.5",
|
|
20
|
+
"@checkstack/common": "0.15.0",
|
|
21
21
|
"@checkstack/drizzle-helper": "0.0.5",
|
|
22
|
-
"@checkstack/cache-api": "0.3.
|
|
23
|
-
"@checkstack/queue-api": "0.3.
|
|
24
|
-
"@checkstack/signal-backend": "0.3.
|
|
25
|
-
"@checkstack/signal-common": "0.2.
|
|
26
|
-
"@checkstack/pluginmanager-common": "0.2.
|
|
22
|
+
"@checkstack/cache-api": "0.3.12",
|
|
23
|
+
"@checkstack/queue-api": "0.3.12",
|
|
24
|
+
"@checkstack/signal-backend": "0.3.5",
|
|
25
|
+
"@checkstack/signal-common": "0.2.9",
|
|
26
|
+
"@checkstack/pluginmanager-common": "0.2.8",
|
|
27
27
|
"@hono/zod-validator": "^0.7.6",
|
|
28
28
|
"@orpc/client": "^1.14.4",
|
|
29
29
|
"@orpc/contract": "^1.14.4",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"@types/bun": "latest",
|
|
46
46
|
"@types/semver": "^7.5.0",
|
|
47
47
|
"@checkstack/tsconfig": "0.0.7",
|
|
48
|
-
"@checkstack/scripts": "0.6.
|
|
49
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
48
|
+
"@checkstack/scripts": "0.6.1",
|
|
49
|
+
"@checkstack/test-utils-backend": "0.1.39",
|
|
50
50
|
"drizzle-kit": "^0.31.10"
|
|
51
51
|
}
|
|
52
52
|
}
|
|
@@ -35,7 +35,9 @@ import { createApiRouteHandler, registerApiRoute } from "./api-router";
|
|
|
35
35
|
* bypassed.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
const manageRule = access("thing", "manage", "Manage things"
|
|
38
|
+
const manageRule = access("thing", "manage", "Manage things", {
|
|
39
|
+
pluginId: "target",
|
|
40
|
+
});
|
|
39
41
|
|
|
40
42
|
const targetContract = {
|
|
41
43
|
// Gated by `thing.manage`. autoAuthMiddleware qualifies it to
|
|
@@ -32,6 +32,7 @@ function makeDeps(registry: ServiceRegistry): PluginLoaderDeps {
|
|
|
32
32
|
extensionPointManager: createExtensionPointManager(),
|
|
33
33
|
registeredAccessRules: [] as (AccessRule & { pluginId: string })[],
|
|
34
34
|
getAllAccessRules: () => [],
|
|
35
|
+
getAnonymousUsableAccessRuleIds: () => [],
|
|
35
36
|
db: {} as PluginLoaderDeps["db"],
|
|
36
37
|
pluginMetadataRegistry: new Map<string, PluginMetadata>(),
|
|
37
38
|
cleanupHandlers: new Map<string, Array<() => Promise<void>>>(),
|
|
@@ -32,6 +32,7 @@ function makeDeps(registry: ServiceRegistry): PluginLoaderDeps {
|
|
|
32
32
|
extensionPointManager: createExtensionPointManager(),
|
|
33
33
|
registeredAccessRules: [] as (AccessRule & { pluginId: string })[],
|
|
34
34
|
getAllAccessRules: () => [],
|
|
35
|
+
getAnonymousUsableAccessRuleIds: () => [],
|
|
35
36
|
db: {} as PluginLoaderDeps["db"],
|
|
36
37
|
pluginMetadataRegistry: new Map<string, PluginMetadata>(),
|
|
37
38
|
cleanupHandlers: new Map<string, Array<() => Promise<void>>>(),
|
|
@@ -48,6 +48,7 @@ export interface PluginLoaderDeps {
|
|
|
48
48
|
extensionPointManager: ExtensionPointManager;
|
|
49
49
|
registeredAccessRules: (AccessRule & { pluginId: string })[];
|
|
50
50
|
getAllAccessRules: () => AccessRule[];
|
|
51
|
+
getAnonymousUsableAccessRuleIds: () => string[];
|
|
51
52
|
db: SafeDatabase<Record<string, unknown>>;
|
|
52
53
|
/**
|
|
53
54
|
* Map of pluginId -> PluginMetadata for request-time context injection.
|
|
@@ -236,6 +237,8 @@ export function registerPlugin({
|
|
|
236
237
|
},
|
|
237
238
|
pluginManager: {
|
|
238
239
|
getAllAccessRules: () => deps.getAllAccessRules(),
|
|
240
|
+
getAnonymousUsableAccessRuleIds: () =>
|
|
241
|
+
deps.getAnonymousUsableAccessRuleIds(),
|
|
239
242
|
},
|
|
240
243
|
});
|
|
241
244
|
}
|
|
@@ -639,7 +642,10 @@ export async function loadPlugins({
|
|
|
639
642
|
// Emit access rule registration hooks at start of Phase 3
|
|
640
643
|
// (EventBus already retrieved above, all plugins can receive notifications)
|
|
641
644
|
const accessRulesByPlugin = new Map<string, AccessRule[]>();
|
|
642
|
-
for (const
|
|
645
|
+
for (const rule of deps.registeredAccessRules) {
|
|
646
|
+
// Keep `pluginId` on the rule (required AccessRule field); use it only to
|
|
647
|
+
// group. The rule's `id` is already fully-qualified.
|
|
648
|
+
const { pluginId } = rule;
|
|
643
649
|
if (!accessRulesByPlugin.has(pluginId)) {
|
|
644
650
|
accessRulesByPlugin.set(pluginId, []);
|
|
645
651
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access } from "@checkstack/common";
|
|
3
|
+
import { collectAnonymousUsableRuleIds } from "./plugin-manager";
|
|
4
|
+
|
|
5
|
+
// A fake contract is just a record of procedures; each procedure carries its
|
|
6
|
+
// metadata under `~orpc.meta`, exactly like a real oRPC contract procedure.
|
|
7
|
+
const proc = (userType: string, access_: ReturnType<typeof access>[]) => ({
|
|
8
|
+
["~orpc"]: { meta: { userType, access: access_ } },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const incidentRead = access("incident", "read", "", { pluginId: "incident" });
|
|
12
|
+
const incidentManage = access("incident", "manage", "", {
|
|
13
|
+
pluginId: "incident",
|
|
14
|
+
});
|
|
15
|
+
const catalogRead = access("system", "read", "", { pluginId: "catalog" });
|
|
16
|
+
|
|
17
|
+
describe("collectAnonymousUsableRuleIds", () => {
|
|
18
|
+
test("includes rules required by a public procedure (qualified id)", () => {
|
|
19
|
+
const contracts = [
|
|
20
|
+
{ getIncidents: proc("public", [incidentRead]) },
|
|
21
|
+
{ getSystems: proc("public", [catalogRead]) },
|
|
22
|
+
];
|
|
23
|
+
expect(collectAnonymousUsableRuleIds(contracts).sort()).toEqual([
|
|
24
|
+
"catalog.system.read",
|
|
25
|
+
"incident.incident.read",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("excludes rules required only by authenticated/user procedures", () => {
|
|
30
|
+
const contracts = [
|
|
31
|
+
{
|
|
32
|
+
createIncident: proc("authenticated", [incidentManage]),
|
|
33
|
+
deleteIncident: proc("user", [incidentManage]),
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
expect(collectAnonymousUsableRuleIds(contracts)).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a rule on BOTH a public and an authenticated procedure is usable", () => {
|
|
40
|
+
const contracts = [
|
|
41
|
+
{
|
|
42
|
+
getIncidents: proc("public", [incidentRead]),
|
|
43
|
+
createIncident: proc("authenticated", [incidentManage]),
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
// read is usable (public); manage is not (only authenticated).
|
|
47
|
+
expect(collectAnonymousUsableRuleIds(contracts)).toEqual([
|
|
48
|
+
"incident.incident.read",
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("ignores procedures without metadata and anonymous-userType ones", () => {
|
|
53
|
+
const contracts = [
|
|
54
|
+
{ notAProc: {} as unknown },
|
|
55
|
+
// anonymous userType skips access checks entirely, so its rules are not
|
|
56
|
+
// 'usable via grant' - they would never be consulted.
|
|
57
|
+
{ ping: proc("anonymous", [incidentRead]) },
|
|
58
|
+
];
|
|
59
|
+
expect(collectAnonymousUsableRuleIds(contracts)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/plugin-manager.ts
CHANGED
|
@@ -15,7 +15,11 @@ import {
|
|
|
15
15
|
HookUnsubscribe,
|
|
16
16
|
} from "@checkstack/backend-api";
|
|
17
17
|
import type { AnyContractRouter } from "@orpc/contract";
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
AccessRule,
|
|
20
|
+
PluginMetadata,
|
|
21
|
+
ProcedureMetadata,
|
|
22
|
+
} from "@checkstack/common";
|
|
19
23
|
import { extractErrorMessage } from "@checkstack/common";
|
|
20
24
|
|
|
21
25
|
// Extracted modules
|
|
@@ -30,6 +34,32 @@ import { stripPublicSchemaFromMigrations } from "./utils/strip-public-schema";
|
|
|
30
34
|
import { runPluginMigrations } from "./utils/run-plugin-migrations";
|
|
31
35
|
import { createScopedDb } from "./utils/scoped-db";
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Collect the qualified ids (`{pluginId}.{ruleId}`) of access rules an ANONYMOUS
|
|
39
|
+
* caller can actually use: a rule is included iff at least one procedure that
|
|
40
|
+
* requires it has `userType: "public"` (the only userType where the anonymous
|
|
41
|
+
* role's rules are consulted). Pure + exported for unit testing.
|
|
42
|
+
*/
|
|
43
|
+
export function collectAnonymousUsableRuleIds(
|
|
44
|
+
contracts: Iterable<unknown>,
|
|
45
|
+
): string[] {
|
|
46
|
+
const usable = new Set<string>();
|
|
47
|
+
for (const contract of contracts) {
|
|
48
|
+
for (const procedure of Object.values(
|
|
49
|
+
contract as Record<string, unknown>,
|
|
50
|
+
)) {
|
|
51
|
+
const meta = (
|
|
52
|
+
procedure as { ["~orpc"]?: { meta?: ProcedureMetadata } } | undefined
|
|
53
|
+
)?.["~orpc"]?.meta;
|
|
54
|
+
if (meta?.userType !== "public") continue;
|
|
55
|
+
for (const rule of meta.access ?? []) {
|
|
56
|
+
usable.add(`${rule.pluginId}.${rule.id}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return [...usable];
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
export interface DeregisterOptions {
|
|
34
64
|
deleteSchema: boolean;
|
|
35
65
|
}
|
|
@@ -194,9 +224,10 @@ export class PluginManager {
|
|
|
194
224
|
}
|
|
195
225
|
|
|
196
226
|
getAllAccessRules(): AccessRule[] {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
)
|
|
227
|
+
// Return rules WITH their `pluginId` (it is a required AccessRule field now).
|
|
228
|
+
// Their `id` is already fully-qualified (`{pluginId}.{id}`, see the registration
|
|
229
|
+
// above), which is what gets synced to the DB and granted to roles.
|
|
230
|
+
return [...this.registeredAccessRules];
|
|
200
231
|
}
|
|
201
232
|
|
|
202
233
|
/**
|
|
@@ -207,6 +238,22 @@ export class PluginManager {
|
|
|
207
238
|
return new Map(this.pluginContractRegistry);
|
|
208
239
|
}
|
|
209
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Qualified ids (`{pluginId}.{ruleId}`) of access rules an ANONYMOUS caller can
|
|
243
|
+
* actually USE: a rule is included iff at least one registered procedure that
|
|
244
|
+
* requires it has `userType: "public"` - the only userType where the anonymous
|
|
245
|
+
* role's granted rules are consulted. Rules used solely by
|
|
246
|
+
* authenticated/user/service procedures are excluded: granting them to the
|
|
247
|
+
* anonymous role is inert (the auth middleware rejects unauthenticated callers
|
|
248
|
+
* before access rules are checked), so the role editor uses this to avoid
|
|
249
|
+
* misleading "granted but unusable" anonymous permissions.
|
|
250
|
+
*/
|
|
251
|
+
getAnonymousUsableAccessRuleIds(): string[] {
|
|
252
|
+
return collectAnonymousUsableRuleIds(
|
|
253
|
+
this.pluginContractRegistry.values(),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
210
257
|
async loadPlugins(
|
|
211
258
|
rootRouter: Hono,
|
|
212
259
|
manualPlugins: BackendPlugin[] = [],
|
|
@@ -227,6 +274,8 @@ export class PluginManager {
|
|
|
227
274
|
extensionPointManager: this.extensionPointManager,
|
|
228
275
|
registeredAccessRules: this.registeredAccessRules,
|
|
229
276
|
getAllAccessRules: () => this.getAllAccessRules(),
|
|
277
|
+
getAnonymousUsableAccessRuleIds: () =>
|
|
278
|
+
this.getAnonymousUsableAccessRuleIds(),
|
|
230
279
|
db,
|
|
231
280
|
pluginMetadataRegistry: this.pluginMetadataRegistry,
|
|
232
281
|
cleanupHandlers: this.cleanupHandlers,
|
|
@@ -771,6 +820,8 @@ export class PluginManager {
|
|
|
771
820
|
},
|
|
772
821
|
pluginManager: {
|
|
773
822
|
getAllAccessRules: () => this.getAllAccessRules(),
|
|
823
|
+
getAnonymousUsableAccessRuleIds: () =>
|
|
824
|
+
this.getAnonymousUsableAccessRuleIds(),
|
|
774
825
|
},
|
|
775
826
|
});
|
|
776
827
|
|