@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 +1 -1
- package/src/entity/auth/SecurityChecker.ts +88 -2
- package/src/entity/auth/index.ts +2 -0
- package/src/kernel/Kernel.auth.ts +4 -0
- package/src/kernel/Kernel.registries.ts +24 -5
- package/src/kernel/Kernel.types.ts +7 -1
- package/src/kernel/SignalBus.ts +6 -0
- package/src/modules/debug/AuthCollector.ts +15 -1
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
- package/src/security/RolesProvider.ts +8 -0
package/package.json
CHANGED
|
@@ -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,
|
|
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
|
}
|
package/src/entity/auth/index.ts
CHANGED
|
@@ -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
|
/**
|
package/src/kernel/SignalBus.ts
CHANGED
|
@@ -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)
|
|
@@ -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
|
|