@checkstack/common 0.14.1 → 0.15.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 +46 -0
- package/package.json +2 -2
- package/src/access-utils.test.ts +71 -0
- package/src/access-utils.ts +37 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# @checkstack/common
|
|
2
2
|
|
|
3
|
+
## 0.15.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
|
|
8
|
+
the anonymous role on the frontend.
|
|
9
|
+
|
|
10
|
+
Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
|
|
11
|
+
`incident.incident.read`) so two plugins defining the same short rule id never
|
|
12
|
+
collide. The frontend, however, was checking the UNqualified id (`incident.read`)
|
|
13
|
+
via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
|
|
14
|
+
(admin) grant - masked in development because dev-auth grants `*`. This silently
|
|
15
|
+
broke ALL non-admin frontend gating (route guards, sidebar entries, and
|
|
16
|
+
`useAccess`-based button/link gating).
|
|
17
|
+
|
|
18
|
+
- **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
|
|
19
|
+
`access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
|
|
20
|
+
qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
|
|
21
|
+
matches ONLY the qualified form. There is intentionally NO unqualified fallback
|
|
22
|
+
- matching a bare id would let one plugin's grant satisfy another plugin's
|
|
23
|
+
identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
|
|
24
|
+
that defines access rules now passes its own `pluginId`.
|
|
25
|
+
- **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
|
|
26
|
+
the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
|
|
27
|
+
- **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
|
|
28
|
+
checks the FULL rule object (so it qualifies and escalates), not a bare id.
|
|
29
|
+
- **Anonymous role on the frontend**: the `accessRules` procedure is now
|
|
30
|
+
`public`, returning the configurable anonymous role's grants to unauthenticated
|
|
31
|
+
callers; `useAccessRules` fetches them for guests instead of returning an empty
|
|
32
|
+
set. So anonymous UI now reflects exactly what the anonymous role is allowed -
|
|
33
|
+
which an admin can change (`isPublic` is only the seeded default).
|
|
34
|
+
- Incident / maintenance / SLO detail routes are now read-gated (their read rule
|
|
35
|
+
is an `isPublic` default, so the anonymous role holds it unless an admin
|
|
36
|
+
revokes it); their dashboard status signals carry that rule and render as a
|
|
37
|
+
link only when the viewer may open it.
|
|
38
|
+
|
|
39
|
+
**BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
|
|
40
|
+
`access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
|
|
41
|
+
matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
|
|
42
|
+
unqualified fallback is removed, because it was a cross-plugin
|
|
43
|
+
privilege-escalation flaw. Any code constructing an `AccessRule` or calling
|
|
44
|
+
`access()`/`accessPair()` must supply the owning `pluginId`.
|
|
45
|
+
|
|
46
|
+
Verified live against an anonymous caller: read pages resolve (qualified match),
|
|
47
|
+
manage actions are denied, manage->read escalation and `*` still work.
|
|
48
|
+
|
|
3
49
|
## 0.14.1
|
|
4
50
|
|
|
5
51
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"typescript": "^5.7.2",
|
|
22
22
|
"@checkstack/tsconfig": "0.0.7",
|
|
23
|
-
"@checkstack/scripts": "0.
|
|
23
|
+
"@checkstack/scripts": "0.6.1"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"typecheck": "tsgo -b",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access, accessPair, isAccessRuleSatisfied } from "./access-utils";
|
|
3
|
+
|
|
4
|
+
describe("isAccessRuleSatisfied - qualified ids", () => {
|
|
5
|
+
// Granted ids are stored fully-qualified (`{pluginId}.{ruleId}`); rules carry
|
|
6
|
+
// their owning pluginId so the check qualifies before matching. This prevents
|
|
7
|
+
// collisions when two plugins define the same short rule id.
|
|
8
|
+
const incidentRead = access("incident", "read", "", { pluginId: "incident" });
|
|
9
|
+
const incidentManage = access("incident", "manage", "", {
|
|
10
|
+
pluginId: "incident",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("matches a qualified granted id", () => {
|
|
14
|
+
expect(
|
|
15
|
+
isAccessRuleSatisfied(["incident.incident.read"], incidentRead),
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("does NOT match the unqualified id when a pluginId is set", () => {
|
|
20
|
+
// The whole point: a bare "incident.read" grant must not satisfy a rule that
|
|
21
|
+
// belongs to the "incident" plugin (collision safety).
|
|
22
|
+
expect(isAccessRuleSatisfied(["incident.read"], incidentRead)).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("does not collide across plugins with the same short id", () => {
|
|
26
|
+
// Plugin A granted; plugin B's identically-named rule must NOT be satisfied.
|
|
27
|
+
const ruleA = access("thing", "read", "", { pluginId: "plugin-a" });
|
|
28
|
+
const ruleB = access("thing", "read", "", { pluginId: "plugin-b" });
|
|
29
|
+
const granted = ["plugin-a.thing.read"];
|
|
30
|
+
expect(isAccessRuleSatisfied(granted, ruleA)).toBe(true);
|
|
31
|
+
expect(isAccessRuleSatisfied(granted, ruleB)).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("manage grant escalates to read (qualified)", () => {
|
|
35
|
+
expect(
|
|
36
|
+
isAccessRuleSatisfied(["incident.incident.manage"], incidentRead),
|
|
37
|
+
).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("read grant does NOT escalate to manage", () => {
|
|
41
|
+
expect(
|
|
42
|
+
isAccessRuleSatisfied(["incident.incident.read"], incidentManage),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("wildcard satisfies anything", () => {
|
|
47
|
+
expect(isAccessRuleSatisfied(["*"], incidentManage)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("accessPair stamps pluginId on both levels", () => {
|
|
51
|
+
const pair = accessPair(
|
|
52
|
+
"system",
|
|
53
|
+
{ read: { description: "" }, manage: { description: "" } },
|
|
54
|
+
{ pluginId: "catalog" },
|
|
55
|
+
);
|
|
56
|
+
expect(pair.read.pluginId).toBe("catalog");
|
|
57
|
+
expect(pair.manage.pluginId).toBe("catalog");
|
|
58
|
+
expect(isAccessRuleSatisfied(["catalog.system.read"], pair.read)).toBe(true);
|
|
59
|
+
expect(isAccessRuleSatisfied(["catalog.system.manage"], pair.read)).toBe(
|
|
60
|
+
true,
|
|
61
|
+
); // escalation
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("only the qualified form is ever matched (no unqualified fallback)", () => {
|
|
65
|
+
const rule = access("teams", "read", "", { pluginId: "auth" });
|
|
66
|
+
expect(isAccessRuleSatisfied(["auth.teams.read"], rule)).toBe(true);
|
|
67
|
+
// A bare, unqualified grant must NEVER satisfy a qualified rule.
|
|
68
|
+
expect(isAccessRuleSatisfied(["teams.read"], rule)).toBe(false);
|
|
69
|
+
expect(isAccessRuleSatisfied([], rule)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/access-utils.ts
CHANGED
|
@@ -69,6 +69,16 @@ export interface AccessRule {
|
|
|
69
69
|
*/
|
|
70
70
|
readonly resource: string;
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The id of the plugin that OWNS this rule. REQUIRED: granted access-rule ids
|
|
74
|
+
* are stored fully-qualified as `{pluginId}.{id}` (e.g. `incident.incident.read`)
|
|
75
|
+
* so the same short id from two plugins never collides, and access checks ONLY
|
|
76
|
+
* ever compare the qualified form. There is intentionally NO unqualified
|
|
77
|
+
* fallback - matching a bare id would be a privilege-escalation flaw across
|
|
78
|
+
* plugins. Set by the *-common access definitions via `pluginMetadata.pluginId`.
|
|
79
|
+
*/
|
|
80
|
+
readonly pluginId: string;
|
|
81
|
+
|
|
72
82
|
/**
|
|
73
83
|
* The access level this rule grants: "read" or "manage".
|
|
74
84
|
* Directly maps to canRead/canManage in team grants.
|
|
@@ -135,12 +145,16 @@ export function qualifyAccessRuleId(
|
|
|
135
145
|
*/
|
|
136
146
|
export function isAccessRuleSatisfied(
|
|
137
147
|
grantedRuleIds: readonly string[],
|
|
138
|
-
rule: Pick<AccessRule, "id" | "resource" | "level">,
|
|
148
|
+
rule: Pick<AccessRule, "id" | "resource" | "level" | "pluginId">,
|
|
139
149
|
): boolean {
|
|
140
150
|
if (grantedRuleIds.includes("*")) return true;
|
|
141
|
-
|
|
151
|
+
// Granted ids are ALWAYS fully-qualified (`{pluginId}.{id}`). We match ONLY the
|
|
152
|
+
// qualified form - never the bare id - so one plugin's grant can never satisfy
|
|
153
|
+
// another plugin's identically-named rule (a cross-plugin escalation flaw).
|
|
154
|
+
const qualify = (id: string): string => `${rule.pluginId}.${id}`;
|
|
155
|
+
if (grantedRuleIds.includes(qualify(rule.id))) return true;
|
|
142
156
|
if (rule.level === "read") {
|
|
143
|
-
return grantedRuleIds.includes(`${rule.resource}.manage`);
|
|
157
|
+
return grantedRuleIds.includes(qualify(`${rule.resource}.manage`));
|
|
144
158
|
}
|
|
145
159
|
return false;
|
|
146
160
|
}
|
|
@@ -178,7 +192,9 @@ export function access(
|
|
|
178
192
|
resource: string,
|
|
179
193
|
level: AccessLevel,
|
|
180
194
|
description: string,
|
|
181
|
-
options
|
|
195
|
+
options: {
|
|
196
|
+
/** Owning plugin id (REQUIRED) - rules are matched only as `{pluginId}.{id}`. */
|
|
197
|
+
pluginId: string;
|
|
182
198
|
idParam?: string;
|
|
183
199
|
listKey?: string;
|
|
184
200
|
recordKey?: string;
|
|
@@ -187,20 +203,21 @@ export function access(
|
|
|
187
203
|
},
|
|
188
204
|
): AccessRule {
|
|
189
205
|
const hasInstanceAccess =
|
|
190
|
-
options
|
|
206
|
+
options.idParam || options.listKey || options.recordKey;
|
|
191
207
|
|
|
192
208
|
return {
|
|
193
209
|
id: `${resource}.${level}`,
|
|
194
210
|
resource,
|
|
195
211
|
level,
|
|
196
212
|
description,
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
pluginId: options.pluginId,
|
|
214
|
+
isDefault: options.isDefault,
|
|
215
|
+
isPublic: options.isPublic,
|
|
199
216
|
instanceAccess: hasInstanceAccess
|
|
200
217
|
? {
|
|
201
|
-
idParam: options
|
|
202
|
-
listKey: options
|
|
203
|
-
recordKey: options
|
|
218
|
+
idParam: options.idParam,
|
|
219
|
+
listKey: options.listKey,
|
|
220
|
+
recordKey: options.recordKey,
|
|
204
221
|
}
|
|
205
222
|
: undefined,
|
|
206
223
|
};
|
|
@@ -265,22 +282,27 @@ export function accessPair(
|
|
|
265
282
|
read: AccessLevelConfig;
|
|
266
283
|
manage: AccessLevelConfig;
|
|
267
284
|
},
|
|
268
|
-
|
|
285
|
+
options: InstanceAccessConfig & {
|
|
286
|
+
/** Owning plugin id (REQUIRED) - rules are matched only as `{pluginId}.{id}`. */
|
|
287
|
+
pluginId: string;
|
|
288
|
+
},
|
|
269
289
|
): { read: AccessRule; manage: AccessRule } {
|
|
270
290
|
return {
|
|
271
291
|
read: access(resource, "read", levels.read.description, {
|
|
272
|
-
idParam:
|
|
273
|
-
listKey:
|
|
274
|
-
recordKey:
|
|
292
|
+
idParam: options.idParam,
|
|
293
|
+
listKey: options.listKey,
|
|
294
|
+
recordKey: options.recordKey,
|
|
275
295
|
isDefault: levels.read.isDefault,
|
|
276
296
|
isPublic: levels.read.isPublic,
|
|
297
|
+
pluginId: options.pluginId,
|
|
277
298
|
}),
|
|
278
299
|
manage: access(resource, "manage", levels.manage.description, {
|
|
279
|
-
idParam:
|
|
300
|
+
idParam: options.idParam,
|
|
280
301
|
// Note: manage doesn't typically use listKey (you don't "manage" a list in bulk)
|
|
281
302
|
// but we include idParam for single-resource manage checks
|
|
282
303
|
isDefault: levels.manage.isDefault,
|
|
283
304
|
isPublic: levels.manage.isPublic,
|
|
305
|
+
pluginId: options.pluginId,
|
|
284
306
|
}),
|
|
285
307
|
};
|
|
286
308
|
}
|