@checkstack/auth-common 0.8.1 → 0.8.3

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,87 @@
1
1
  # @checkstack/auth-common
2
2
 
3
+ ## 0.8.3
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 [56e7c75]
76
+ - @checkstack/common@0.15.0
77
+
78
+ ## 0.8.2
79
+
80
+ ### Patch Changes
81
+
82
+ - Updated dependencies [1fee9da]
83
+ - @checkstack/common@0.14.1
84
+
3
85
  ## 0.8.1
4
86
 
5
87
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-common",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -10,14 +10,14 @@
10
10
  }
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/common": "0.13.0",
13
+ "@checkstack/common": "0.15.0",
14
14
  "@orpc/contract": "^1.14.4",
15
15
  "zod": "^4.0.0"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@checkstack/tsconfig": "0.0.7",
19
19
  "typescript": "^5.7.2",
20
- "@checkstack/scripts": "0.4.0"
20
+ "@checkstack/scripts": "0.6.1"
21
21
  },
22
22
  "scripts": {
23
23
  "typecheck": "tsgo -b",
package/src/access.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { access, accessPair, type AccessRule } from "@checkstack/common";
2
+ import { pluginMetadata } from "./plugin-metadata";
2
3
 
3
4
  /**
4
5
  * Access rules for the Auth plugin.
@@ -11,28 +12,42 @@ export const authAccess = {
11
12
  * User management access rules.
12
13
  */
13
14
  users: {
14
- read: access("users", "read", "List all users"),
15
+ read: access("users", "read", "List all users", {
16
+ pluginId: pluginMetadata.pluginId,
17
+ }),
15
18
  create: access(
16
19
  "users.create",
17
20
  "manage",
18
21
  "Create new users (credential strategy)",
22
+ { pluginId: pluginMetadata.pluginId },
19
23
  ),
20
- manage: access("users", "manage", "Delete users"),
24
+ manage: access("users", "manage", "Delete users", {
25
+ pluginId: pluginMetadata.pluginId,
26
+ }),
21
27
  },
22
28
 
23
29
  /**
24
30
  * Role management access rules.
25
31
  */
26
32
  roles: {
27
- read: access("roles", "read", "Read and list roles"),
28
- create: access("roles.create", "manage", "Create new roles"),
33
+ read: access("roles", "read", "Read and list roles", {
34
+ pluginId: pluginMetadata.pluginId,
35
+ }),
36
+ create: access("roles.create", "manage", "Create new roles", {
37
+ pluginId: pluginMetadata.pluginId,
38
+ }),
29
39
  update: access(
30
40
  "roles.update",
31
41
  "manage",
32
42
  "Update role names and access rules",
43
+ { pluginId: pluginMetadata.pluginId },
33
44
  ),
34
- delete: access("roles.delete", "manage", "Delete roles"),
35
- manage: access("roles", "manage", "Assign roles to users"),
45
+ delete: access("roles.delete", "manage", "Delete roles", {
46
+ pluginId: pluginMetadata.pluginId,
47
+ }),
48
+ manage: access("roles", "manage", "Assign roles to users", {
49
+ pluginId: pluginMetadata.pluginId,
50
+ }),
36
51
  },
37
52
 
38
53
  /**
@@ -42,6 +57,7 @@ export const authAccess = {
42
57
  "strategies",
43
58
  "manage",
44
59
  "Manage authentication strategies and settings",
60
+ { pluginId: pluginMetadata.pluginId },
45
61
  ),
46
62
 
47
63
  /**
@@ -51,6 +67,7 @@ export const authAccess = {
51
67
  "registration",
52
68
  "manage",
53
69
  "Manage user registration settings",
70
+ { pluginId: pluginMetadata.pluginId },
54
71
  ),
55
72
 
56
73
  /**
@@ -60,17 +77,22 @@ export const authAccess = {
60
77
  "applications",
61
78
  "manage",
62
79
  "Create, update, delete, and view external applications",
80
+ { pluginId: pluginMetadata.pluginId },
63
81
  ),
64
82
 
65
83
  /**
66
84
  * Team management access rules.
67
85
  */
68
- teams: accessPair("teams", {
69
- read: { description: "View teams and team memberships" },
70
- manage: {
71
- description: "Create, delete, and manage all teams and resource access",
86
+ teams: accessPair(
87
+ "teams",
88
+ {
89
+ read: { description: "View teams and team memberships" },
90
+ manage: {
91
+ description: "Create, delete, and manage all teams and resource access",
92
+ },
72
93
  },
73
- }),
94
+ { pluginId: pluginMetadata.pluginId },
95
+ ),
74
96
  };
75
97
 
76
98
  /**
@@ -27,6 +27,13 @@ const RoleDtoSchema = z.object({
27
27
  const AccessRuleDtoSchema = z.object({
28
28
  id: z.string(),
29
29
  description: z.string().optional(),
30
+ /**
31
+ * Whether an anonymous caller can actually USE this rule (a `public` procedure
32
+ * requires it). The role editor uses this to warn/disable granting inert
33
+ * permissions to the anonymous role. Absent/false => only authenticated
34
+ * procedures need it, so granting it to the anonymous role has no effect.
35
+ */
36
+ anonymousUsable: z.boolean().optional(),
30
37
  });
31
38
 
32
39
  const StrategyDtoSchema = z.object({
@@ -177,9 +184,13 @@ export const authContract = {
177
184
  // AUTHENTICATED ENDPOINTS (userType: "authenticated")
178
185
  // ==========================================================================
179
186
 
187
+ // Public so ANONYMOUS callers also get their effective rules (the configurable
188
+ // "anonymous" role's grants), not an empty set. The frontend gates nav/links
189
+ // on these, so an anonymous visitor must see exactly what the anonymous role
190
+ // is allowed - which an admin can change.
180
191
  accessRules: proc({
181
192
  operationType: "query",
182
- userType: "authenticated",
193
+ userType: "public",
183
194
  access: [],
184
195
  }).output(z.object({ accessRules: z.array(z.string()) })),
185
196