@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 +88 -0
- package/package.json +7 -7
- package/src/index.ts +10 -3
- package/src/router.ts +48 -8
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
|
+
"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.
|
|
19
|
-
"@checkstack/backend-api": "0.21.
|
|
20
|
-
"@checkstack/notification-common": "1.3.
|
|
21
|
-
"@checkstack/command-backend": "0.2.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
330
|
-
|
|
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(
|