@checkstack/auth-backend 0.5.3 → 0.5.5

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,93 @@
1
1
  # @checkstack/auth-backend
2
2
 
3
+ ## 0.5.5
4
+
5
+ ### Patch 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
+ - 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
34
+ the anonymous role on the frontend.
35
+
36
+ Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
37
+ `incident.incident.read`) so two plugins defining the same short rule id never
38
+ collide. The frontend, however, was checking the UNqualified id (`incident.read`)
39
+ via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
40
+ (admin) grant - masked in development because dev-auth grants `*`. This silently
41
+ broke ALL non-admin frontend gating (route guards, sidebar entries, and
42
+ `useAccess`-based button/link gating).
43
+
44
+ - **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
45
+ `access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
46
+ qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
47
+ matches ONLY the qualified form. There is intentionally NO unqualified fallback
48
+ - matching a bare id would let one plugin's grant satisfy another plugin's
49
+ identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
50
+ that defines access rules now passes its own `pluginId`.
51
+ - **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
52
+ the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
53
+ - **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
54
+ checks the FULL rule object (so it qualifies and escalates), not a bare id.
55
+ - **Anonymous role on the frontend**: the `accessRules` procedure is now
56
+ `public`, returning the configurable anonymous role's grants to unauthenticated
57
+ callers; `useAccessRules` fetches them for guests instead of returning an empty
58
+ set. So anonymous UI now reflects exactly what the anonymous role is allowed -
59
+ which an admin can change (`isPublic` is only the seeded default).
60
+ - Incident / maintenance / SLO detail routes are now read-gated (their read rule
61
+ is an `isPublic` default, so the anonymous role holds it unless an admin
62
+ revokes it); their dashboard status signals carry that rule and render as a
63
+ link only when the viewer may open it.
64
+
65
+ **BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
66
+ `access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
67
+ matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
68
+ unqualified fallback is removed, because it was a cross-plugin
69
+ privilege-escalation flaw. Any code constructing an `AccessRule` or calling
70
+ `access()`/`accessPair()` must supply the owning `pluginId`.
71
+
72
+ Verified live against an anonymous caller: read pages resolve (qualified match),
73
+ manage actions are denied, manage->read escalation and `*` still work.
74
+
75
+ - Updated dependencies [0626782]
76
+ - Updated dependencies [56e7c75]
77
+ - @checkstack/backend-api@0.21.5
78
+ - @checkstack/auth-common@0.8.3
79
+ - @checkstack/common@0.15.0
80
+ - @checkstack/notification-common@1.3.3
81
+ - @checkstack/command-backend@0.2.5
82
+
83
+ ## 0.5.4
84
+
85
+ ### Patch Changes
86
+
87
+ - Updated dependencies [b50916d]
88
+ - @checkstack/backend-api@0.21.4
89
+ - @checkstack/command-backend@0.2.4
90
+
3
91
  ## 0.5.3
4
92
 
5
93
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-backend",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -15,22 +15,22 @@
15
15
  "test": "bun test"
16
16
  },
17
17
  "dependencies": {
18
- "@checkstack/auth-common": "0.8.2",
19
- "@checkstack/backend-api": "0.21.3",
20
- "@checkstack/notification-common": "1.3.2",
21
- "@checkstack/command-backend": "0.2.3",
18
+ "@checkstack/auth-common": "0.8.3",
19
+ "@checkstack/backend-api": "0.21.5",
20
+ "@checkstack/notification-common": "1.3.3",
21
+ "@checkstack/command-backend": "0.2.5",
22
22
  "better-auth": "^1.6.13",
23
23
  "drizzle-orm": "^0.45.0",
24
24
  "hono": "^4.12.23",
25
25
  "kysely": "^0.28.17",
26
26
  "jose": "^6.2.3",
27
27
  "zod": "^4.2.1",
28
- "@checkstack/common": "0.14.1",
28
+ "@checkstack/common": "0.15.0",
29
29
  "@orpc/server": "^1.14.4"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@checkstack/drizzle-helper": "0.0.5",
33
- "@checkstack/scripts": "0.6.0",
33
+ "@checkstack/scripts": "0.6.1",
34
34
  "@checkstack/tsconfig": "0.0.7",
35
35
  "@types/node": "^20.0.0",
36
36
  "@types/pg": "^8.20.0",
package/src/index.ts CHANGED
@@ -323,11 +323,18 @@ export default createBackendPlugin({
323
323
  getStrategies: () => strategies,
324
324
  };
325
325
 
326
- // Access rule registry - gets all access rules from PluginManager
326
+ // Access rule registry - gets all access rules from PluginManager, annotated
327
+ // with `anonymousUsable` (whether a `public` procedure actually requires the
328
+ // rule) so the role editor can guard anonymous-role grants.
327
329
  const accessRuleRegistry = {
328
330
  getAccessRules: () => {
329
- // Get all access rules from the central PluginManager registry
330
- return env.pluginManager.getAllAccessRules();
331
+ const usable = new Set(
332
+ env.pluginManager.getAnonymousUsableAccessRuleIds(),
333
+ );
334
+ return env.pluginManager.getAllAccessRules().map((rule) => ({
335
+ ...rule,
336
+ anonymousUsable: usable.has(rule.id),
337
+ }));
331
338
  },
332
339
  };
333
340
 
package/src/router.ts CHANGED
@@ -190,6 +190,8 @@ export const createAuthRouter = (
190
190
  description?: string;
191
191
  isDefault?: boolean;
192
192
  isPublic?: boolean;
193
+ /** Whether an anonymous caller can actually use this rule (a public RPC requires it). */
194
+ anonymousUsable?: boolean;
193
195
  }[];
194
196
  },
195
197
  getBetterAuth: () =>
@@ -237,10 +239,24 @@ export const createAuthRouter = (
237
239
  return enabledStrategies.filter((s) => s.enabled);
238
240
  });
239
241
 
242
+ // The configurable "anonymous" role's grants - what an unauthenticated visitor
243
+ // is allowed. Same `roleAccessRule` store the enriched `user.accessRules` is
244
+ // built from, so the format matches and the frontend's access checks behave
245
+ // identically for guests and users.
246
+ const loadAnonymousAccessRules = async (): Promise<string[]> => {
247
+ const rolePerms = await internalDb
248
+ .select()
249
+ .from(schema.roleAccessRule)
250
+ .where(eq(schema.roleAccessRule.roleId, ANONYMOUS_ROLE_ID));
251
+ return rolePerms.map((rp) => rp.accessRuleId);
252
+ };
253
+
240
254
  const accessRulesHandler = os.accessRules.handler(async ({ context }) => {
241
255
  const user = context.user;
256
+ // Anonymous callers get the anonymous role's effective rules (NOT empty), so
257
+ // the UI can gate on what a guest may actually do.
242
258
  if (!isRealUser(user)) {
243
- return { accessRules: [] };
259
+ return { accessRules: await loadAnonymousAccessRules() };
244
260
  }
245
261
  return { accessRules: user.accessRules || [] };
246
262
  });
@@ -436,6 +452,36 @@ export const createAuthRouter = (
436
452
  const isAnonymousRole = id === ANONYMOUS_ROLE_ID;
437
453
  if (isAnonymousRole) {
438
454
  const allPerms = accessRuleRegistry.getAccessRules();
455
+
456
+ // GUARDRAIL: refuse to ADD an access rule to the anonymous role that no
457
+ // `public` endpoint uses. The auth middleware rejects unauthenticated
458
+ // callers BEFORE checking access rules, so such a grant is inert and
459
+ // misleading (the admin would think anonymous users gained a capability
460
+ // they cannot actually use). Only NEWLY-added inert rules are blocked;
461
+ // anything already on the role is left untouched so this can never wedge
462
+ // an existing configuration.
463
+ const usableIds = new Set(
464
+ allPerms.filter((p) => p.anonymousUsable).map((p) => p.id),
465
+ );
466
+ const currentAnonRows = await internalDb
467
+ .select()
468
+ .from(schema.roleAccessRule)
469
+ .where(eq(schema.roleAccessRule.roleId, ANONYMOUS_ROLE_ID));
470
+ const currentAnonRules = new Set(
471
+ currentAnonRows.map((r) => r.accessRuleId),
472
+ );
473
+ const inertAdditions = validAccessRules.filter(
474
+ (p) => !usableIds.has(p) && !currentAnonRules.has(p),
475
+ );
476
+ if (inertAdditions.length > 0) {
477
+ throw new ORPCError("BAD_REQUEST", {
478
+ message:
479
+ "These access rules cannot be granted to the anonymous role - no " +
480
+ "public endpoint uses them, so only authenticated callers could " +
481
+ `ever exercise them: ${inertAdditions.join(", ")}`,
482
+ });
483
+ }
484
+
439
485
  const publicDefaultPermIds = allPerms
440
486
  .filter((p) => p.isPublic)
441
487
  .map((p) => p.id);
@@ -922,13 +968,7 @@ export const createAuthRouter = (
922
968
  );
923
969
 
924
970
  const getAnonymousAccessRules = os.getAnonymousAccessRules.handler(
925
- async () => {
926
- const rolePerms = await internalDb
927
- .select()
928
- .from(schema.roleAccessRule)
929
- .where(eq(schema.roleAccessRule.roleId, ANONYMOUS_ROLE_ID));
930
- return rolePerms.map((rp) => rp.accessRuleId);
931
- },
971
+ async () => loadAnonymousAccessRules(),
932
972
  );
933
973
 
934
974
  const filterUsersByAccessRule = os.filterUsersByAccessRule.handler(