@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 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.14.1",
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.4.2"
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
+ });
@@ -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
- if (grantedRuleIds.includes(rule.id)) return true;
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?.idParam || options?.listKey || options?.recordKey;
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
- isDefault: options?.isDefault,
198
- isPublic: options?.isPublic,
213
+ pluginId: options.pluginId,
214
+ isDefault: options.isDefault,
215
+ isPublic: options.isPublic,
199
216
  instanceAccess: hasInstanceAccess
200
217
  ? {
201
- idParam: options?.idParam,
202
- listKey: options?.listKey,
203
- recordKey: options?.recordKey,
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
- instanceAccess?: InstanceAccessConfig,
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: instanceAccess?.idParam,
273
- listKey: instanceAccess?.listKey,
274
- recordKey: instanceAccess?.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: instanceAccess?.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
  }