@checkstack/auth-frontend 0.7.3 → 0.7.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,138 @@
1
1
  # @checkstack/auth-frontend
2
2
 
3
+ ## 0.7.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: Hide navigation, actions and links that the current user cannot use, so anonymous
34
+ and read-only users no longer see entries that lead to "Access Denied" or to
35
+ actions the server would reject.
36
+
37
+ - **Sidebar**: a nav entry can now declare a dynamic `nav.isVisible({ accessRules, isAuthenticated })` predicate (in addition to the static `accessRule`). A group whose every entry is filtered out is no longer rendered. The filtering/grouping logic is extracted to a pure, unit-tested helper.
38
+ - **Infrastructure**: its sidebar entry is shown only when the user can READ at least one contributed tab (queue, cache, …), instead of always (it previously had no static rule because tabs are contributed at runtime).
39
+ - **Notification Settings**: hidden from anonymous users - notifications are per-user, so an anonymous visitor can't have any.
40
+ - **Anomaly Mute / Suppress**: the "Mute" / "Mute all" controls (a per-user preference) are hidden from anonymous visitors; the "Suppress" control is gated on `anomalyAccess.feed.manage`. Both were previously always visible.
41
+ - **Dashboard**: the "Open Catalog" actions (which open the manage-only Catalog config page) are hidden from users without `catalogAccess.system.manage`, and the "View catalog" link is gated on `catalogAccess.system.read`.
42
+ - **Dashboard status signals**: the per-system status rows contributed by plugins (`SystemSignalsSlot`) now render as a LINK only when the user can open the target, and as plain text otherwise. `SystemSignal` gains an optional `accessRule`; the healthcheck, anomaly, and dependency fillers set it for their gated targets (check-history / assignments / dependency-map). Signals pointing at ungated pages (incident / maintenance / SLO detail) stay links.
43
+ - **Plugin Manager**: the "Install plugin" button (which opens the install-gated page) is hidden from users with only `plugin` view access.
44
+ - **Satellites**: the page is entirely manage-gated, but its route/sidebar entry was gated on `read`, so read-only users saw the nav item and hit "Access Denied" on click. The route and nav entry now require `satellite.manage`.
45
+
46
+ The `@checkstack/ai-backend` bump is only the regenerated bundled docs index
47
+ (the frontend routing guide gained the `nav.isVisible` section); no code change.
48
+
49
+ **BREAKING (`@checkstack/frontend-api`):** the `AccessApi` interface gains a
50
+ required `useIsAuthenticated()` method. Custom `AccessApi` implementations must
51
+ add it (it returns `{ loading, isAuthenticated }`). The built-in auth
52
+ implementation and the no-auth fallback already do. `NavEntry` also gains an
53
+ optional `isVisible` predicate (purely additive).
54
+
55
+ - 56e7c75: Fix frontend access checks to use FULLY-QUALIFIED access-rule ids, and resolve
56
+ the anonymous role on the frontend.
57
+
58
+ Granted access-rule ids are stored fully-qualified as `{pluginId}.{ruleId}` (e.g.
59
+ `incident.incident.read`) so two plugins defining the same short rule id never
60
+ collide. The frontend, however, was checking the UNqualified id (`incident.read`)
61
+ via `isAccessRuleSatisfied`, so every check failed for any user without the `*`
62
+ (admin) grant - masked in development because dev-auth grants `*`. This silently
63
+ broke ALL non-admin frontend gating (route guards, sidebar entries, and
64
+ `useAccess`-based button/link gating).
65
+
66
+ - **`@checkstack/common`**: `AccessRule` now carries a REQUIRED owning `pluginId`;
67
+ `access()` / `accessPair()` require and stamp it; `isAccessRuleSatisfied`
68
+ qualifies the rule (`{pluginId}.{id}`, plus the manage->read escalation) and
69
+ matches ONLY the qualified form. There is intentionally NO unqualified fallback
70
+ - matching a bare id would let one plugin's grant satisfy another plugin's
71
+ identically-named rule (a cross-plugin privilege-escalation flaw). Every plugin
72
+ that defines access rules now passes its own `pluginId`.
73
+ - **`@checkstack/backend`**: `pluginManager.getAllAccessRules()` no longer strips
74
+ the `pluginId` field (the rule `id` is already fully-qualified for the DB sync).
75
+ - **Route guard** (`@checkstack/frontend` / `@checkstack/frontend-api`) now
76
+ checks the FULL rule object (so it qualifies and escalates), not a bare id.
77
+ - **Anonymous role on the frontend**: the `accessRules` procedure is now
78
+ `public`, returning the configurable anonymous role's grants to unauthenticated
79
+ callers; `useAccessRules` fetches them for guests instead of returning an empty
80
+ set. So anonymous UI now reflects exactly what the anonymous role is allowed -
81
+ which an admin can change (`isPublic` is only the seeded default).
82
+ - Incident / maintenance / SLO detail routes are now read-gated (their read rule
83
+ is an `isPublic` default, so the anonymous role holds it unless an admin
84
+ revokes it); their dashboard status signals carry that rule and render as a
85
+ link only when the viewer may open it.
86
+
87
+ **BREAKING (`@checkstack/common`):** `AccessRule.pluginId` is now REQUIRED, and
88
+ `access()` / `accessPair()` require a `pluginId` option. `isAccessRuleSatisfied`
89
+ matches ONLY the fully-qualified `{pluginId}.{ruleId}` form - the previous
90
+ unqualified fallback is removed, because it was a cross-plugin
91
+ privilege-escalation flaw. Any code constructing an `AccessRule` or calling
92
+ `access()`/`accessPair()` must supply the owning `pluginId`.
93
+
94
+ Verified live against an anonymous caller: read pages resolve (qualified match),
95
+ manage actions are denied, manage->read escalation and `*` still work.
96
+
97
+ - Updated dependencies [0626782]
98
+ - Updated dependencies [56e7c75]
99
+ - Updated dependencies [56e7c75]
100
+ - @checkstack/auth-common@0.8.3
101
+ - @checkstack/frontend-api@0.9.0
102
+ - @checkstack/ui@1.15.1
103
+ - @checkstack/common@0.15.0
104
+
105
+ ## 0.7.4
106
+
107
+ ### Patch Changes
108
+
109
+ - fb705df: Upgrade React 18 to React 19 across the platform.
110
+
111
+ **BREAKING (runtime frontend plugins):** React is shared as a Module Federation
112
+ singleton, so the host now provides **React 19** to every runtime plugin.
113
+ Frontend plugins built against React 18 must be rebuilt against React 19
114
+ (`react` / `react-dom` `^19`). The scaffold templates and the host/plugin MF
115
+ `requiredVersion` are updated to `^19`. `react` (and now `react-dom`) are pinned
116
+ to a single version across the workspace via syncpack so the singleton can never
117
+ skew (react and react-dom must match exactly).
118
+
119
+ The React 19 removed-API surface was audited - the codebase used only no-arg
120
+ `useRef()` (now `useRef<T | undefined>(undefined)`); no `ReactDOM.render`,
121
+ legacy context, string refs, or function-component `defaultProps`. This also
122
+ clears the `IMPORT_IS_UNDEFINED` build warnings for `React.use` /
123
+ `React.useOptimistic` (react-router 7 feature-detection), which React 19 exports.
124
+
125
+ The downstream `*-frontend` packages (and `@checkstack/infrastructure-common`)
126
+ receive only the mechanical `react` dependency bump (`patch`); the framework
127
+ packages carrying the shared-singleton change are bumped `minor`.
128
+
129
+ - Updated dependencies [9d8961c]
130
+ - Updated dependencies [fb705df]
131
+ - @checkstack/ui@1.15.0
132
+ - @checkstack/frontend-api@0.8.0
133
+ - @checkstack/auth-common@0.8.2
134
+ - @checkstack/common@0.14.1
135
+
3
136
  ## 0.7.3
4
137
 
5
138
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-frontend",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
@@ -18,21 +18,21 @@
18
18
  "test:e2e": "bunx playwright test"
19
19
  },
20
20
  "dependencies": {
21
- "@checkstack/frontend-api": "0.7.2",
22
- "@checkstack/common": "0.14.1",
23
- "@checkstack/ui": "1.14.0",
24
- "react": "^18.3.1",
21
+ "@checkstack/frontend-api": "0.9.0",
22
+ "@checkstack/common": "0.15.0",
23
+ "@checkstack/ui": "1.15.1",
24
+ "react": "19.2.7",
25
25
  "react-router-dom": "^7.16.0",
26
26
  "lucide-react": "^1.17.0",
27
27
  "better-auth": "^1.6.13",
28
- "@checkstack/auth-common": "0.8.2"
28
+ "@checkstack/auth-common": "0.8.3"
29
29
  },
30
30
  "devDependencies": {
31
31
  "typescript": "^5.0.0",
32
- "@types/react": "^18.2.0",
32
+ "@types/react": "^19.0.0",
33
33
  "@playwright/test": "^1.60.0",
34
- "@checkstack/test-utils-frontend": "0.1.0",
34
+ "@checkstack/test-utils-frontend": "0.1.1",
35
35
  "@checkstack/tsconfig": "0.0.7",
36
- "@checkstack/scripts": "0.5.0"
36
+ "@checkstack/scripts": "0.6.1"
37
37
  }
38
38
  }
package/src/api.ts CHANGED
@@ -31,6 +31,12 @@ export interface Role {
31
31
  export interface AccessRuleEntry {
32
32
  id: string;
33
33
  description?: string;
34
+ /**
35
+ * Whether an anonymous caller can actually use this rule (a public endpoint
36
+ * requires it). When false, granting it to the anonymous role is inert, so the
37
+ * role editor disables it for that role.
38
+ */
39
+ anonymousUsable?: boolean;
34
40
  }
35
41
 
36
42
  export interface AuthStrategy {
@@ -62,6 +62,14 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
62
62
  const isAdminRole = role?.id === "admin";
63
63
  // Disable access rules for admin (wildcard) or user's own roles (prevent elevation)
64
64
  const accessRulesDisabled = isAdminRole || isUserRole;
65
+ // The anonymous role may only hold rules that a PUBLIC endpoint actually uses;
66
+ // granting an authenticated-only rule to it is inert (the server rejects
67
+ // unauthenticated callers before checking rules). Mirrors the backend guardrail.
68
+ const isAnonymousRole = role?.id === "anonymous";
69
+ const isBlockedForAnonymous = (perm: AccessRuleEntry): boolean =>
70
+ isAnonymousRole &&
71
+ perm.anonymousUsable === false &&
72
+ !selectedAccessRules.has(perm.id);
65
73
 
66
74
  // Group access rules by plugin
67
75
  const accessRulesByPlugin: Record<string, AccessRuleEntry[]> = {};
@@ -78,6 +86,10 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
78
86
  if (newSelected.has(accessRuleId)) {
79
87
  newSelected.delete(accessRuleId);
80
88
  } else {
89
+ // Defense in depth: never add a rule the anonymous role can't use (the
90
+ // checkbox is also disabled, and the backend rejects it).
91
+ const perm = accessRulesList.find((p) => p.id === accessRuleId);
92
+ if (perm && isBlockedForAnonymous(perm)) return;
81
93
  newSelected.add(accessRuleId);
82
94
  }
83
95
  setSelectedAccessRules(newSelected);
@@ -158,6 +170,16 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
158
170
  </AlertDescription>
159
171
  </Alert>
160
172
  )}
173
+ {isAnonymousRole && (
174
+ <Alert variant="info" className="mb-3">
175
+ <AlertDescription>
176
+ The anonymous role applies to signed-out visitors. Rules that
177
+ no public page or endpoint uses are disabled here - granting
178
+ them would have no effect, since anonymous visitors are
179
+ rejected before those rules are checked.
180
+ </AlertDescription>
181
+ </Alert>
182
+ )}
161
183
  <div className="border rounded-lg">
162
184
  <Accordion
163
185
  type="multiple"
@@ -236,14 +258,21 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
236
258
  }
237
259
 
238
260
  // Use editable checkbox design when access rules are editable
261
+ const blockedForAnonymous =
262
+ isBlockedForAnonymous(perm);
239
263
  return (
240
264
  <div
241
265
  key={perm.id}
242
- className="flex items-start space-x-3 p-2 rounded-md hover:bg-muted/50 transition-colors"
266
+ className={`flex items-start space-x-3 p-2 rounded-md transition-colors ${
267
+ blockedForAnonymous
268
+ ? "opacity-50"
269
+ : "hover:bg-muted/50"
270
+ }`}
243
271
  >
244
272
  <Checkbox
245
273
  id={`perm-${perm.id}`}
246
274
  checked={selectedAccessRules.has(perm.id)}
275
+ disabled={blockedForAnonymous}
247
276
  onCheckedChange={() =>
248
277
  handleToggleAccessRule(perm.id)
249
278
  }
@@ -251,7 +280,11 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
251
280
  />
252
281
  <label
253
282
  htmlFor={`perm-${perm.id}`}
254
- className="text-sm cursor-pointer flex-1 space-y-1"
283
+ className={`text-sm flex-1 space-y-1 ${
284
+ blockedForAnonymous
285
+ ? "cursor-not-allowed"
286
+ : "cursor-pointer"
287
+ }`}
255
288
  >
256
289
  <div className="font-medium">{perm.id}</div>
257
290
  {perm.description && (
@@ -259,6 +292,12 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
259
292
  {perm.description}
260
293
  </div>
261
294
  )}
295
+ {blockedForAnonymous && (
296
+ <div className="text-xs text-muted-foreground italic">
297
+ Not available to anonymous visitors (no public
298
+ endpoint uses this rule).
299
+ </div>
300
+ )}
262
301
  </label>
263
302
  </div>
264
303
  );
@@ -7,25 +7,26 @@ export const useAccessRules = () => {
7
7
  const authClient = usePluginClient(AuthApi);
8
8
  const { data: session, isPending: sessionPending } = authApi.useSession();
9
9
 
10
- // Query: Fetch access rules (only when user is authenticated)
10
+ // Fetch the caller's EFFECTIVE access rules once the session is resolved -
11
+ // for authenticated users their own grants, for anonymous visitors the
12
+ // configurable "anonymous" role's grants (the `accessRules` procedure is
13
+ // public). Gating on these makes guest UI match what a guest may actually do.
11
14
  const { data, isLoading } = authClient.accessRules.useQuery(
12
15
  {},
13
16
  {
14
- enabled: !sessionPending && !!session?.user,
17
+ enabled: !sessionPending,
15
18
  }
16
19
  );
17
20
 
18
- // If no session or pending, return empty access rules
21
+ // `isAuthenticated` lets callers additionally gate logged-in-only UI (e.g.
22
+ // Notification Settings) that needs a real user rather than a specific rule.
19
23
  if (sessionPending) {
20
- return { accessRules: [], loading: true };
21
- }
22
-
23
- if (!session?.user) {
24
- return { accessRules: [], loading: false };
24
+ return { accessRules: [], loading: true, isAuthenticated: false };
25
25
  }
26
26
 
27
27
  return {
28
28
  accessRules: data?.accessRules ?? [],
29
29
  loading: isLoading,
30
+ isAuthenticated: !!session?.user,
30
31
  };
31
32
  };
@@ -14,6 +14,7 @@ const testReadAccess: AccessRule = {
14
14
  resource: "test",
15
15
  level: "read",
16
16
  description: "Test read access",
17
+ pluginId: "test",
17
18
  };
18
19
 
19
20
  const testManageAccess: AccessRule = {
@@ -21,6 +22,7 @@ const testManageAccess: AccessRule = {
21
22
  resource: "test",
22
23
  level: "manage",
23
24
  description: "Test manage access",
25
+ pluginId: "test",
24
26
  };
25
27
 
26
28
  const otherAccess: AccessRule = {
@@ -28,6 +30,7 @@ const otherAccess: AccessRule = {
28
30
  resource: "other",
29
31
  level: "read",
30
32
  description: "Other read access",
33
+ pluginId: "other",
31
34
  };
32
35
 
33
36
  describe("AuthAccessApi", () => {
@@ -39,7 +42,7 @@ describe("AuthAccessApi", () => {
39
42
 
40
43
  it("should return true if user has the access rule", () => {
41
44
  (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
42
- accessRules: ["test.read"],
45
+ accessRules: ["test.test.read"],
43
46
  loading: false,
44
47
  });
45
48
 
@@ -51,7 +54,7 @@ describe("AuthAccessApi", () => {
51
54
 
52
55
  it("should return false if user is missing the access rule", () => {
53
56
  (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
54
- accessRules: ["other.read"],
57
+ accessRules: ["other.other.read"],
55
58
  loading: false,
56
59
  });
57
60
 
@@ -99,7 +102,7 @@ describe("AuthAccessApi", () => {
99
102
 
100
103
  it("should return true if user has manage access for a manage check", () => {
101
104
  (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
102
- accessRules: ["test.manage"],
105
+ accessRules: ["test.test.manage"],
103
106
  loading: false,
104
107
  });
105
108
 
@@ -123,7 +126,7 @@ describe("AuthAccessApi", () => {
123
126
 
124
127
  it("should return true if user has manage access for a read check", () => {
125
128
  (useAccessRules as ReturnType<typeof mock>).mockReturnValue({
126
- accessRules: ["test.manage"],
129
+ accessRules: ["test.test.manage"],
127
130
  loading: false,
128
131
  });
129
132
 
@@ -25,4 +25,10 @@ export class AuthAccessApi implements AccessApi {
25
25
  allowed: isAccessRuleSatisfied(accessRules, accessRule),
26
26
  };
27
27
  }
28
+
29
+ useIsAuthenticated(): { loading: boolean; isAuthenticated: boolean } {
30
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- Class adapter delegates to hook; consumed as API, not a component
31
+ const { loading, isAuthenticated } = useAccessRules();
32
+ return { loading, isAuthenticated };
33
+ }
28
34
  }