@quazardous/qdadm 2.23.0 → 2.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.23.0",
3
+ "version": "2.24.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -654,7 +654,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
654
654
  }
655
655
 
656
656
  /**
657
- * Check if entity is system-provided (roles, users)
657
+ * Whether this entity is one the framework provides (roles, users). Owned
658
+ * by qdadm — see `EntityManagerOptions.system`.
658
659
  */
659
660
  get system(): boolean {
660
661
  return this._system
@@ -271,6 +271,15 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
271
271
  * drop the stale cache but leave the screen alone until the user asks.
272
272
  */
273
273
  live?: LiveEntityPolicy
274
+ /**
275
+ * Marks an entity the framework provides (`roles`, `users`). Owned by
276
+ * qdadm: set by its system managers (`RolesManager`, `UsersManager`), not
277
+ * by applications. Behaviour may be attached to it in any release.
278
+ *
279
+ * To keep a system entity on your own storage, use its manager with your
280
+ * storage — `new RolesManager({ storage })` — rather than setting the flag
281
+ * on a plain entity.
282
+ */
274
283
  system?: boolean
275
284
  scopeWhitelist?: string[] | null
276
285
  isOwn?: ((record: T, user: AuthUser) => boolean) | null
@@ -26,8 +26,8 @@ export interface GrantJudgeContext {
26
26
  *
27
27
  * `install` runs while the Kernel is still being built, before modules
28
28
  * register their entities, so `getKeys()` called here returns only the
29
- * framework's own namespaces. Read the keys when you pre-warm (after login,
30
- * before mount), not in `install`.
29
+ * framework's own namespaces. Read the keys when you fetch (on `kernel:ready`
30
+ * or `auth:login`), not in `install`.
31
31
  */
32
32
  permissionRegistry?: { getKeys(): string[] }
33
33
  }
@@ -44,12 +44,29 @@ export interface GrantJudgeContext {
44
44
  * built-in judgement, which stays the default.
45
45
  * - A judge that THROWS denies — see `SecurityChecker._judge`.
46
46
  *
47
- * `isGranted` is called synchronously on every check, so answer from memory.
48
- * Fetch in bulk beforehand and emit `security:changed` when answers change.
47
+ * `isGranted` is called synchronously on every check, so answer from memory,
48
+ * and emit `security:changed` when answers change.
49
+ *
50
+ * A judge whose answers come from a request cannot have them before the first
51
+ * navigation: it starts inside `createApp()`. Such a judge implements `ready()`
52
+ * (#2412), and the route guard waits for it instead of deciding early.
49
53
  */
50
54
  export interface GrantJudge {
51
55
  isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
52
56
  install?(ctx: GrantJudgeContext): void
57
+ /**
58
+ * Settles once the answers for the current user are loaded (#2412).
59
+ *
60
+ * Return the promise of the load in progress — the one started on
61
+ * `kernel:ready`, or the new one after `auth:login` — so the route guard
62
+ * waits for this user's answers, not an earlier user's. A resolved promise
63
+ * means "decide now". A rejection, or no settling within
64
+ * `security.readyTimeoutMs`, denies the navigation: the unknown is never
65
+ * allowed, only no longer decided before the answers can exist.
66
+ *
67
+ * Optional: a judge that answers from memory from the start needs none.
68
+ */
69
+ ready?(): Promise<void>
53
70
  }
54
71
 
55
72
  /**
@@ -406,13 +406,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
406
406
  this._warnUnknownKeys(
407
407
  'security.',
408
408
  security,
409
- new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
409
+ new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant', 'readyTimeoutMs']),
410
410
  {
411
411
  role_hierarchy: 'roles will not inherit from one another',
412
412
  role_permissions: 'no role will carry any permission',
413
413
  entity_permissions: 'per-entity permissions will not be generated',
414
414
  rolesProvider: "the current user's roles will not be resolved",
415
415
  grant: "the application's judgement will not be consulted — the role matrix decides alone",
416
+ readyTimeoutMs: 'the route guard waits the default 10 s for grant.ready()',
416
417
  }
417
418
  )
418
419
  }
@@ -15,6 +15,28 @@ type Self = Kernel
15
15
  /**
16
16
  * Patch Kernel prototype with routing-related methods.
17
17
  */
18
+ /**
19
+ * `judge.ready()`, bounded (#2412): a judge that never settles must not hold a
20
+ * navigation forever. A synchronous throw from `ready()` counts as a rejection.
21
+ */
22
+ function waitForJudge(judge: { ready(): Promise<void> }, timeoutMs: number): Promise<void> {
23
+ return new Promise((resolve, reject) => {
24
+ const timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs} ms`)), timeoutMs)
25
+ Promise.resolve()
26
+ .then(() => judge.ready())
27
+ .then(
28
+ () => {
29
+ clearTimeout(timer)
30
+ resolve()
31
+ },
32
+ (e: unknown) => {
33
+ clearTimeout(timer)
34
+ reject(e instanceof Error ? e : new Error(String(e)))
35
+ }
36
+ )
37
+ })
38
+ }
39
+
18
40
  export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
19
41
  const proto = KernelClass.prototype
20
42
 
@@ -230,14 +252,17 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
230
252
  // declared before its manager loads) — allow. This is the ONLY
231
253
  // allowed pass-through; a failing access check must fail CLOSED,
232
254
  // not silently allow (#1190).
233
- if (entity && this.orchestrator && this.orchestrator.isRegistered(entity)) {
255
+ const orchestrator = this.orchestrator
256
+ if (!entity || !orchestrator || !orchestrator.isRegistered(entity)) return
257
+
258
+ const checkEntity = (): { path: string } | undefined => {
234
259
  try {
235
- const manager = this.orchestrator.get(entity)
260
+ const manager = orchestrator.get(entity)
236
261
  if (manager && !manager.canRead()) {
237
262
  console.warn(
238
263
  `[qdadm] Access denied to ${to.path} (entity: ${entity})`
239
264
  )
240
- this.orchestrator.toast.error(
265
+ orchestrator.toast.error(
241
266
  'Access Denied',
242
267
  `You don't have permission to access ${manager.labelPlural || entity}`,
243
268
  'Kernel'
@@ -256,7 +281,23 @@ export function applyRoutingMethods(KernelClass: { prototype: Kernel }): void {
256
281
  )
257
282
  return { path: '/' }
258
283
  }
284
+ return undefined
259
285
  }
286
+
287
+ // A judge whose answers come from a request cannot have them yet on the
288
+ // first navigation, which starts inside createApp() (#2412). Its cache
289
+ // miss would fall through to an empty role matrix and read as a refusal:
290
+ // wait for its answers instead of deciding early. Still fail closed — a
291
+ // judge that rejects or never settles denies.
292
+ const judge = this.securityChecker?.grant
293
+ if (typeof judge?.ready !== 'function') return checkEntity()
294
+ const timeoutMs = this.options.security?.readyTimeoutMs ?? 10_000
295
+ return waitForJudge(judge as { ready(): Promise<void> }, timeoutMs).then(checkEntity, (reason: Error) => {
296
+ console.error(
297
+ `[qdadm] security.grant never became ready for ${to.path} (entity: ${entity}): ${reason.message} — denying navigation`
298
+ )
299
+ return { path: '/' }
300
+ })
260
301
  })
261
302
  }
262
303
 
@@ -105,6 +105,11 @@ export interface SecurityConfig {
105
105
  * throws denies. See docs/security.md.
106
106
  */
107
107
  grant?: GrantJudge
108
+ /**
109
+ * How long the route guard waits for `grant.ready()` before denying (#2412).
110
+ * Default 10000 ms. Only used when the judge implements `ready()`.
111
+ */
112
+ readyTimeoutMs?: number
108
113
  }
109
114
 
110
115
  /**
@@ -40,6 +40,22 @@ interface EntityManagerInstance {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * The `roles` system entity.
45
+ *
46
+ * It is a system entity because this manager makes it one — apps do not set
47
+ * `system` themselves. To keep roles behind your own API, give it your
48
+ * storage; it stays a system entity:
49
+ *
50
+ * ```ts
51
+ * ctx.entity('roles', new RolesManager({ storage: myApiStorage }))
52
+ * ```
53
+ *
54
+ * Two things change compared with a plain entity, and both are deliberate:
55
+ * every `can*` goes through ONE permission (`adminPermission`), and `fields`
56
+ * / `idField` assume qdadm's shape (`name`, `label`, `permissions`,
57
+ * `inherits`) — override them when your API serves another one.
58
+ */
43
59
  export class RolesManager extends EntityManager<RoleRecord> {
44
60
  private _rolesProvider: RoleProvider | null
45
61
  private _permissionRegistry: PermissionRegistry | null
@@ -128,8 +144,12 @@ export class RolesManager extends EntityManager<RoleRecord> {
128
144
  canDelete(item?: RoleRecord | null): boolean {
129
145
  if (!this._isAdmin()) return false
130
146
  if (item) {
131
- // Protected system roles cannot be deleted
132
- return !RolesManager.PROTECTED_ROLES.includes(item.name)
147
+ // Protected system roles cannot be deleted. The role code is `name` in qdadm's shape, but an
148
+ // app whose API keys roles differently overrides `idField` (#2406): read both, or the guard
149
+ // silently stops matching.
150
+ const record = item as unknown as Record<string, unknown>
151
+ const codes = [record.name, record[this.idField]]
152
+ return !codes.some((code) => typeof code === 'string' && RolesManager.PROTECTED_ROLES.includes(code))
133
153
  }
134
154
  return true
135
155
  }