@quazardous/qdadm 2.20.0 → 2.21.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.20.0",
3
+ "version": "2.21.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -15,6 +15,43 @@ export interface RoleProvider {
15
15
  readonly canPersist: boolean
16
16
  }
17
17
 
18
+ /**
19
+ * What an application's judge receives at install time (#2225).
20
+ */
21
+ export interface GrantJudgeContext {
22
+ orchestrator?: unknown
23
+ signals?: unknown
24
+ /**
25
+ * The live permission registry — a REFERENCE, not a snapshot.
26
+ *
27
+ * `install` runs while the Kernel is still being built, before modules
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`.
31
+ */
32
+ permissionRegistry?: { getKeys(): string[] }
33
+ }
34
+
35
+ /**
36
+ * An application-provided judgement of permissions (#2225).
37
+ *
38
+ * For apps whose backend decides who may do what: the judge answers from its
39
+ * own cache instead of qdadm's role matrix. It is consulted FIRST, for roles
40
+ * and permissions alike.
41
+ *
42
+ * - `true` / `false` is the verdict.
43
+ * - Anything else (`undefined`, `null`, a cache miss) falls through to the
44
+ * built-in judgement, which stays the default.
45
+ * - A judge that THROWS denies — see `SecurityChecker._judge`.
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.
49
+ */
50
+ export interface GrantJudge {
51
+ isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
52
+ install?(ctx: GrantJudgeContext): void
53
+ }
54
+
18
55
  /**
19
56
  * SecurityChecker constructor options
20
57
  */
@@ -22,6 +59,8 @@ export interface SecurityCheckerOptions {
22
59
  rolesProvider?: RoleProvider
23
60
  roleHierarchy?: RoleHierarchy | RoleHierarchyConfig
24
61
  rolePermissions?: Record<string, string[]>
62
+ /** Application-provided judgement, consulted first (#2225). */
63
+ grant?: GrantJudge
25
64
  getCurrentUser: () => AuthUser | null
26
65
  }
27
66
 
@@ -32,6 +71,8 @@ export interface SecurityCheckerConfig {
32
71
  rolesProvider?: RoleProvider
33
72
  role_hierarchy?: RoleHierarchyConfig
34
73
  role_permissions?: Record<string, string[]>
74
+ /** Application-provided judgement, consulted first (#2225). */
75
+ grant?: GrantJudge
35
76
  getCurrentUser: () => AuthUser | null
36
77
  }
37
78
 
@@ -55,8 +96,12 @@ export class SecurityChecker {
55
96
  readonly getCurrentUser: () => AuthUser | null
56
97
  /** Roles already warned about (warn-once, #1388) */
57
98
  protected _warnedRoles = new Set<string>()
99
+ /** Application-provided judgement, consulted before the role matrix (#2225) */
100
+ protected _grant: GrantJudge | null
101
+ /** Attributes whose judge already threw (report-once) */
102
+ protected _judgeFailures = new Set<string>()
58
103
 
59
- constructor({ rolesProvider, roleHierarchy, rolePermissions, getCurrentUser }: SecurityCheckerOptions) {
104
+ constructor({ rolesProvider, roleHierarchy, rolePermissions, grant, getCurrentUser }: SecurityCheckerOptions) {
60
105
  if (rolesProvider) {
61
106
  this._rolesProvider = rolesProvider
62
107
  } else {
@@ -75,6 +120,12 @@ export class SecurityChecker {
75
120
  }
76
121
 
77
122
  this.getCurrentUser = getCurrentUser
123
+ this._grant = grant ?? null
124
+ }
125
+
126
+ /** The application's judge, when one is configured (#2225). */
127
+ get grant(): GrantJudge | null {
128
+ return this._grant
78
129
  }
79
130
 
80
131
  /**
@@ -108,10 +159,15 @@ export class SecurityChecker {
108
159
  *
109
160
  * This is the main contract method, similar to Symfony's isGranted().
110
161
  */
111
- isGranted(attribute: string, _subject: unknown = null): boolean {
162
+ isGranted(attribute: string, subject: unknown = null): boolean {
112
163
  const user = this.getCurrentUser()
113
164
  if (!user) return false
114
165
 
166
+ // 0. The application's own judgement comes first (#2225). A boolean is a
167
+ // verdict; anything else falls through to the built-in matrix below.
168
+ const verdict = this._judge(attribute, subject, user)
169
+ if (verdict !== undefined) return verdict
170
+
115
171
  // 1. Check if it's a role (ROLE_*)
116
172
  if (attribute.startsWith('ROLE_')) {
117
173
  return this.roleHierarchy.isGrantedRole(user.roles || [user.role || ''], attribute)
@@ -122,6 +178,34 @@ export class SecurityChecker {
122
178
  return PermissionMatcher.any(userPerms, attribute)
123
179
  }
124
180
 
181
+ /**
182
+ * Ask the application's judge, if any.
183
+ *
184
+ * A judge that THROWS denies. Falling through to the built-in matrix would
185
+ * quietly substitute a different authority for the one the app configured,
186
+ * and failing open would grant whatever the backend never got to answer —
187
+ * the worst direction a security check can fail in (the rule the route
188
+ * guard already follows, #1190). Reported once per attribute.
189
+ */
190
+ protected _judge(attribute: string, subject: unknown, user: AuthUser): boolean | undefined {
191
+ if (!this._grant) return undefined
192
+ let answer: unknown
193
+ try {
194
+ answer = this._grant.isGranted(attribute, subject, user)
195
+ } catch (error) {
196
+ if (!this._judgeFailures.has(attribute)) {
197
+ this._judgeFailures.add(attribute)
198
+ console.error(
199
+ `[qdadm] security.grant threw while judging "${attribute}" — DENIED. ` +
200
+ `A failing judge denies rather than falling back to the role matrix.`,
201
+ error
202
+ )
203
+ }
204
+ return false
205
+ }
206
+ return typeof answer === 'boolean' ? answer : undefined
207
+ }
208
+
125
209
  /**
126
210
  * Get all permissions for a user (resolved from roles + user overrides)
127
211
  */
@@ -203,6 +287,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
203
287
  if (config.rolesProvider) {
204
288
  return new SecurityChecker({
205
289
  rolesProvider: config.rolesProvider,
290
+ grant: config.grant,
206
291
  getCurrentUser: config.getCurrentUser,
207
292
  })
208
293
  }
@@ -213,6 +298,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
213
298
  role_hierarchy: config.role_hierarchy || {},
214
299
  role_permissions: config.role_permissions || {},
215
300
  }),
301
+ grant: config.grant,
216
302
  getCurrentUser: config.getCurrentUser,
217
303
  })
218
304
  }
@@ -23,6 +23,8 @@ export type {
23
23
  RoleProvider,
24
24
  SecurityCheckerOptions,
25
25
  SecurityCheckerConfig,
26
+ GrantJudge,
27
+ GrantJudgeContext,
26
28
  } from './SecurityChecker'
27
29
 
28
30
  // Implementations
@@ -120,11 +120,15 @@ export function applyAuthMethods(KernelClass: { prototype: Kernel }): void {
120
120
  */
121
121
  proto._setupAuthInvalidation = function (this: Self): void {
122
122
  const debug = this.options.debug ?? false
123
+ // `security:changed` (#2225) rides the same path: nothing on screen reads
124
+ // permissions reactively (canCreate, menu filtering and the route guard
125
+ // all ask synchronously), so a remount is what makes new answers show.
123
126
  const authSignals = [
124
127
  'auth:login',
125
128
  'auth:logout',
126
129
  'auth:impersonate',
127
130
  'auth:impersonate:stop',
131
+ 'security:changed',
128
132
  ]
129
133
 
130
134
  for (const signal of authSignals) {
@@ -141,6 +141,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
141
141
  )
142
142
  }
143
143
 
144
+ // A `grant` that cannot judge would be silently skipped, leaving the role
145
+ // matrix to decide alone while the app believes its backend does (#2225).
146
+ if (security.grant && typeof security.grant.isGranted !== 'function') {
147
+ throw new Error(
148
+ '[qdadm] security.grant must provide isGranted(attribute, subject, user). ' +
149
+ 'Without it the application judgement would be skipped and the role matrix would decide alone.'
150
+ )
151
+ }
152
+
144
153
  let rolesProvider = security.rolesProvider
145
154
  if (!rolesProvider && (security.role_permissions || security.role_hierarchy)) {
146
155
  rolesProvider = new StaticRoleProvider({
@@ -152,6 +161,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
152
161
 
153
162
  this.securityChecker = createSecurityChecker({
154
163
  rolesProvider: rolesProvider || undefined,
164
+ grant: security.grant,
155
165
  getCurrentUser: () =>
156
166
  (entityAuthAdapter as EntityAuthAdapter | null)?.getCurrentUser?.() || null,
157
167
  })
@@ -165,14 +175,22 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
165
175
  adapter.setSecurityChecker(this.securityChecker)
166
176
  }
167
177
 
178
+ // One context for both installers. `permissionRegistry` lets a provider or
179
+ // a judge pre-warm in one batch (#2225) — but modules register their
180
+ // entities AFTER this point, so the keys must be read at fetch time, not
181
+ // inside install().
182
+ const ctx = {
183
+ orchestrator: this.orchestrator,
184
+ signals: this.signals,
185
+ permissionRegistry: this.permissionRegistry,
186
+ }
168
187
  if (rolesProvider?.install) {
169
- const ctx = {
170
- orchestrator: this.orchestrator,
171
- signals: this.signals,
172
- }
173
188
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
189
  rolesProvider.install(ctx as any)
175
190
  }
191
+ if (security.grant?.install) {
192
+ security.grant.install(ctx)
193
+ }
176
194
  }
177
195
 
178
196
  /**
@@ -388,12 +406,13 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
388
406
  this._warnUnknownKeys(
389
407
  'security.',
390
408
  security,
391
- new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
409
+ new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
392
410
  {
393
411
  role_hierarchy: 'roles will not inherit from one another',
394
412
  role_permissions: 'no role will carry any permission',
395
413
  entity_permissions: 'per-entity permissions will not be generated',
396
414
  rolesProvider: "the current user's roles will not be resolved",
415
+ grant: "the application's judgement will not be consulted — the role matrix decides alone",
397
416
  }
398
417
  )
399
418
  }
@@ -10,6 +10,7 @@ import type { RoutesConfig } from './EventRouter'
10
10
  import type { EntityManager } from '../entity/EntityManager'
11
11
  import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
12
12
  import type { RoleProvider } from '../security/RolesProvider'
13
+ import type { GrantJudge } from '../entity/auth/SecurityChecker'
13
14
  import type { I18nOptions } from '../i18n/types'
14
15
  import type { ApiClientSource } from '../api/apiClient'
15
16
  import type { RouteStatePersister } from '../routeState'
@@ -98,7 +99,12 @@ export interface SecurityConfig {
98
99
  role_permissions?: Record<string, string[]>
99
100
  role_labels?: Record<string, string>
100
101
  entity_permissions?: boolean | string[]
101
- rolesProvider?: RoleProvider
102
+ rolesProvider?: RoleProvider /**
103
+ * Application-provided judgement (#2225), consulted before the role matrix.
104
+ * `true`/`false` is the verdict, anything else falls through; a judge that
105
+ * throws denies. See docs/security.md.
106
+ */
107
+ grant?: GrantJudge
102
108
  }
103
109
 
104
110
  /**
@@ -43,6 +43,12 @@ export const SIGNALS = {
43
43
  AUTH_LOGOUT: 'auth:logout',
44
44
  AUTH_EXPIRED: 'auth:expired', // Emitted on 401/403 API responses
45
45
 
46
+ // Security signals
47
+ // The application's permission answers changed — every screen re-evaluates
48
+ // (#2225). Emit it only when an answer actually differs: it remounts the app,
49
+ // which discards unsaved form state.
50
+ SECURITY_CHANGED: 'security:changed',
51
+
46
52
  // API error signals
47
53
  API_ERROR: 'api:error', // Emitted on any API error { status, message, url }
48
54
  } as const
@@ -41,7 +41,7 @@ export interface AuthEvent {
41
41
  * Auth entry types
42
42
  */
43
43
  export interface AuthEntry extends CollectorEntry {
44
- type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'adapter' | 'error'
44
+ type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'delegated-verdicts' | 'adapter' | 'error'
45
45
  label?: string
46
46
  message?: string
47
47
  data?: unknown
@@ -372,6 +372,20 @@ export class AuthCollector extends Collector<AuthEntry> {
372
372
  label: 'Permissions',
373
373
  data: permissions
374
374
  })
375
+
376
+ // With a delegated judge (#2225) the role matrix above is not the
377
+ // authority, and showing it alone would mislead. Show what the
378
+ // checker actually answers for every registered key — the judge's
379
+ // verdicts, with the matrix only where the judge abstains.
380
+ const judged = (this._securityChecker ?? authCtx?.security) as SecurityChecker | null | undefined
381
+ if (judged?.grant) {
382
+ entries.push({
383
+ timestamp: Date.now(),
384
+ type: 'delegated-verdicts',
385
+ label: 'Delegated Judgement (security.grant)',
386
+ data: Object.fromEntries(permissions.map((key: string) => [key, judged.isGranted(key)]))
387
+ })
388
+ }
375
389
  }
376
390
  } catch (e) {
377
391
  console.warn('[AuthCollector] Error accessing permissionRegistry:', e)
@@ -157,6 +157,7 @@ function getIcon(type: string): string {
157
157
  permissions: 'pi-list',
158
158
  hierarchy: 'pi-sitemap',
159
159
  'role-permissions': 'pi-lock',
160
+ 'delegated-verdicts': 'pi-server',
160
161
  adapter: 'pi-cog'
161
162
  }
162
163
  return icons[type] || 'pi-info-circle'
@@ -70,6 +70,14 @@ export interface RoleProviderContext {
70
70
  orchestrator?: {
71
71
  get: (name: string) => unknown
72
72
  }
73
+ /**
74
+ * The live permission registry (#2225) — a reference, not a snapshot:
75
+ * `install` runs before modules register their entities, so read
76
+ * `getKeys()` later, when you actually fetch.
77
+ */
78
+ permissionRegistry?: {
79
+ getKeys: () => string[]
80
+ }
73
81
  [key: string]: unknown
74
82
  }
75
83