qdadm 0.35.0 → 0.38.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.
Files changed (43) hide show
  1. package/README.md +27 -174
  2. package/package.json +2 -1
  3. package/src/auth/SessionAuthAdapter.js +114 -3
  4. package/src/components/editors/PermissionEditor.vue +535 -0
  5. package/src/components/forms/FormField.vue +1 -11
  6. package/src/components/index.js +1 -0
  7. package/src/components/layout/AppLayout.vue +20 -8
  8. package/src/components/layout/defaults/DefaultToaster.vue +3 -3
  9. package/src/components/pages/LoginPage.vue +26 -5
  10. package/src/composables/useCurrentEntity.js +26 -17
  11. package/src/composables/useForm.js +7 -0
  12. package/src/composables/useFormPageBuilder.js +7 -0
  13. package/src/composables/useNavContext.js +30 -16
  14. package/src/core/index.js +0 -3
  15. package/src/debug/AuthCollector.js +199 -31
  16. package/src/debug/Collector.js +24 -2
  17. package/src/debug/EntitiesCollector.js +8 -0
  18. package/src/debug/SignalCollector.js +60 -2
  19. package/src/debug/components/panels/AuthPanel.vue +159 -27
  20. package/src/debug/components/panels/EntitiesPanel.vue +18 -2
  21. package/src/entity/EntityManager.js +205 -36
  22. package/src/entity/auth/EntityAuthAdapter.js +54 -46
  23. package/src/entity/auth/SecurityChecker.js +110 -42
  24. package/src/entity/auth/factory.js +11 -2
  25. package/src/entity/auth/factory.test.js +29 -0
  26. package/src/entity/storage/factory.test.js +6 -5
  27. package/src/index.js +3 -0
  28. package/src/kernel/Kernel.js +135 -25
  29. package/src/kernel/KernelContext.js +166 -0
  30. package/src/security/EntityRoleGranterAdapter.js +350 -0
  31. package/src/security/PermissionMatcher.js +148 -0
  32. package/src/security/PermissionRegistry.js +263 -0
  33. package/src/security/PersistableRoleGranterAdapter.js +618 -0
  34. package/src/security/RoleGranterAdapter.js +123 -0
  35. package/src/security/RoleGranterStorage.js +161 -0
  36. package/src/security/RolesManager.js +81 -0
  37. package/src/security/SecurityModule.js +73 -0
  38. package/src/security/StaticRoleGranterAdapter.js +114 -0
  39. package/src/security/UsersManager.js +122 -0
  40. package/src/security/index.js +45 -0
  41. package/src/security/pages/RoleForm.vue +212 -0
  42. package/src/security/pages/RoleList.vue +106 -0
  43. package/src/styles/main.css +62 -2
@@ -0,0 +1,618 @@
1
+ /**
2
+ * PersistableRoleGranterAdapter - Role granter with load/persist callbacks
3
+ *
4
+ * Flexible adapter that can load role→permissions mapping from any source
5
+ * (localStorage, API, entity) and persist changes back.
6
+ *
7
+ * Features:
8
+ * - Fixed permissions (incompressible, always applied, never overridden)
9
+ * - Default mapping as fallback
10
+ * - Async load/persist callbacks
11
+ * - Merge strategy for defaults + loaded data
12
+ * - Cache invalidation on persist
13
+ *
14
+ * Permission priority (highest to lowest):
15
+ * 1. fixed - System permissions, always present, never overridden
16
+ * 2. loaded - Data from load() callback
17
+ * 3. defaults - Fallback when load returns null/fails
18
+ *
19
+ * @example
20
+ * // With fixed system permissions + API loading
21
+ * const adapter = new PersistableRoleGranterAdapter({
22
+ * // Fixed: ALWAYS present, even if API returns different data
23
+ * fixed: {
24
+ * role_permissions: {
25
+ * ROLE_ANONYMOUS: ['auth:login', 'auth:register'],
26
+ * ROLE_USER: ['auth:authenticated', 'auth:logout', 'profile:read']
27
+ * }
28
+ * },
29
+ * // Defaults: used before load() or if load fails
30
+ * defaults: {
31
+ * role_permissions: {
32
+ * ROLE_USER: ['entity:*:read'],
33
+ * ROLE_ADMIN: ['entity:**']
34
+ * }
35
+ * },
36
+ * // Load from API (requires auth, called after login)
37
+ * load: async () => {
38
+ * const res = await fetch('/api/config/roles')
39
+ * return res.json()
40
+ * },
41
+ * autoLoad: false // Load manually after auth
42
+ * })
43
+ *
44
+ * // After user authenticates
45
+ * signals.on('auth:login', () => adapter.load())
46
+ *
47
+ * @example
48
+ * // With localStorage
49
+ * const adapter = new PersistableRoleGranterAdapter({
50
+ * load: () => JSON.parse(localStorage.getItem('roles') || 'null'),
51
+ * persist: (data) => localStorage.setItem('roles', JSON.stringify(data)),
52
+ * defaults: {
53
+ * role_permissions: {
54
+ * ROLE_USER: ['entity:*:read', 'entity:*:list'],
55
+ * ROLE_ADMIN: ['entity:**', 'admin:**']
56
+ * }
57
+ * }
58
+ * })
59
+ */
60
+
61
+ import { RoleGranterAdapter } from './RoleGranterAdapter.js'
62
+
63
+ export class PersistableRoleGranterAdapter extends RoleGranterAdapter {
64
+ /**
65
+ * Create a persistable role granter
66
+ *
67
+ * @param {Object} options
68
+ * @param {Function} [options.load] - Load callback: () => Promise<RoleConfig>|RoleConfig|null
69
+ * @param {Function} [options.persist] - Persist callback: (data: RoleConfig) => Promise<void>|void
70
+ * @param {Object} [options.fixed={}] - Fixed/system configuration (incompressible, never overridden)
71
+ * @param {Object} [options.fixed.role_hierarchy={}] - Fixed role hierarchy
72
+ * @param {Object} [options.fixed.role_permissions={}] - Fixed role permissions (e.g., auth:login)
73
+ * @param {Object} [options.fixed.role_labels={}] - Fixed role labels
74
+ * @param {Object} [options.defaults={}] - Default configuration (fallback)
75
+ * @param {Object} [options.defaults.role_hierarchy={}] - Default role hierarchy
76
+ * @param {Object} [options.defaults.role_permissions={}] - Default role permissions
77
+ * @param {Object} [options.defaults.role_labels={}] - Default role labels
78
+ * @param {string} [options.mergeStrategy='extend'] - How to merge defaults with loaded data
79
+ * - 'extend': Loaded data extends defaults (loaded takes priority)
80
+ * - 'replace': Loaded data replaces defaults entirely
81
+ * - 'defaults-only': Use defaults, ignore loaded (for fallback mode)
82
+ * @param {boolean} [options.autoLoad=true] - Auto-load on first access
83
+ */
84
+ constructor(options = {}) {
85
+ super()
86
+
87
+ this._loadFn = options.load || null
88
+ this._persistFn = options.persist || null
89
+ this._mergeStrategy = options.mergeStrategy || 'extend'
90
+ this._autoLoad = options.autoLoad ?? true
91
+
92
+ // Fixed (incompressible, always applied on top)
93
+ this._fixed = {
94
+ role_hierarchy: options.fixed?.role_hierarchy || {},
95
+ role_permissions: options.fixed?.role_permissions || {},
96
+ role_labels: options.fixed?.role_labels || {}
97
+ }
98
+
99
+ // Defaults (fallback)
100
+ this._defaults = {
101
+ role_hierarchy: options.defaults?.role_hierarchy || {},
102
+ role_permissions: options.defaults?.role_permissions || {},
103
+ role_labels: options.defaults?.role_labels || {}
104
+ }
105
+
106
+ // Current state (starts with defaults)
107
+ this._hierarchy = { ...this._defaults.role_hierarchy }
108
+ this._permissions = { ...this._defaults.role_permissions }
109
+ this._labels = { ...this._defaults.role_labels }
110
+
111
+ // Loading state
112
+ this._loaded = false
113
+ this._loading = null // Promise when loading
114
+ this._dirty = false // Has unsaved changes
115
+ }
116
+
117
+ /**
118
+ * Load configuration from source
119
+ *
120
+ * @returns {Promise<void>}
121
+ */
122
+ async load() {
123
+ if (!this._loadFn) {
124
+ this._loaded = true
125
+ return
126
+ }
127
+
128
+ // Prevent concurrent loads
129
+ if (this._loading) {
130
+ return this._loading
131
+ }
132
+
133
+ this._loading = this._doLoad()
134
+ try {
135
+ await this._loading
136
+ } finally {
137
+ this._loading = null
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Internal load implementation
143
+ * @private
144
+ */
145
+ async _doLoad() {
146
+ try {
147
+ const data = await this._loadFn()
148
+
149
+ if (data) {
150
+ this._applyData(data)
151
+ }
152
+
153
+ this._loaded = true
154
+ this._dirty = false
155
+ } catch (err) {
156
+ console.error('[PersistableRoleGranterAdapter] Load failed:', err)
157
+ // Keep defaults on error
158
+ this._loaded = true
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Apply loaded data according to merge strategy
164
+ *
165
+ * @param {Object} data - Loaded data
166
+ * @private
167
+ */
168
+ _applyData(data) {
169
+ switch (this._mergeStrategy) {
170
+ case 'replace':
171
+ // Loaded data replaces everything
172
+ this._hierarchy = data.role_hierarchy || {}
173
+ this._permissions = data.role_permissions || {}
174
+ this._labels = data.role_labels || {}
175
+ break
176
+
177
+ case 'defaults-only':
178
+ // Ignore loaded data, use defaults
179
+ break
180
+
181
+ case 'extend':
182
+ default:
183
+ // Loaded extends defaults (loaded takes priority)
184
+ this._hierarchy = {
185
+ ...this._defaults.role_hierarchy,
186
+ ...(data.role_hierarchy || {})
187
+ }
188
+ this._permissions = {
189
+ ...this._defaults.role_permissions,
190
+ ...(data.role_permissions || {})
191
+ }
192
+ this._labels = {
193
+ ...this._defaults.role_labels,
194
+ ...(data.role_labels || {})
195
+ }
196
+ break
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Persist current configuration to source
202
+ *
203
+ * @returns {Promise<void>}
204
+ */
205
+ async persist() {
206
+ if (!this._persistFn) {
207
+ console.warn('[PersistableRoleGranterAdapter] No persist function configured')
208
+ return
209
+ }
210
+
211
+ const data = {
212
+ role_hierarchy: this._hierarchy,
213
+ role_permissions: this._permissions,
214
+ role_labels: this._labels
215
+ }
216
+
217
+ try {
218
+ await this._persistFn(data)
219
+ this._dirty = false
220
+ } catch (err) {
221
+ console.error('[PersistableRoleGranterAdapter] Persist failed:', err)
222
+ throw err
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Ensure data is loaded (auto-load if needed)
228
+ *
229
+ * @returns {Promise<void>}
230
+ * @private
231
+ */
232
+ async _ensureLoaded() {
233
+ if (!this._loaded && this._autoLoad) {
234
+ await this.load()
235
+ }
236
+ }
237
+
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+ // RoleGranterAdapter interface
240
+ // ─────────────────────────────────────────────────────────────────────────────
241
+
242
+ /**
243
+ * Get permissions for a role
244
+ *
245
+ * Merges: current permissions + fixed permissions (fixed always wins)
246
+ *
247
+ * @param {string} role - Role name
248
+ * @returns {string[]} Permission patterns
249
+ */
250
+ getPermissions(role) {
251
+ // Note: This is synchronous. For async sources, call load() first
252
+ // or use autoLoad + await ensureReady() before first use
253
+ const base = this._permissions[role] || []
254
+ const fixed = this._fixed.role_permissions[role] || []
255
+
256
+ // Merge: base + fixed (deduplicated)
257
+ if (fixed.length === 0) return base
258
+ if (base.length === 0) return fixed
259
+
260
+ const merged = [...base]
261
+ for (const perm of fixed) {
262
+ if (!merged.includes(perm)) {
263
+ merged.push(perm)
264
+ }
265
+ }
266
+ return merged
267
+ }
268
+
269
+ /**
270
+ * Get all defined roles
271
+ *
272
+ * Includes roles from: current + fixed
273
+ *
274
+ * @returns {string[]} Role names
275
+ */
276
+ getRoles() {
277
+ const roles = new Set([
278
+ ...Object.keys(this._permissions),
279
+ ...Object.keys(this._fixed.role_permissions)
280
+ ])
281
+ return [...roles]
282
+ }
283
+
284
+ /**
285
+ * Get role hierarchy
286
+ *
287
+ * Merges: current hierarchy + fixed hierarchy
288
+ *
289
+ * @returns {Object} Hierarchy map { role: [inheritedRoles] }
290
+ */
291
+ getHierarchy() {
292
+ return {
293
+ ...this._hierarchy,
294
+ ...this._fixed.role_hierarchy
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Get role labels for display
300
+ *
301
+ * Merges: current labels + fixed labels
302
+ *
303
+ * @returns {Object} Labels map { role: label }
304
+ */
305
+ getLabels() {
306
+ return {
307
+ ...this._labels,
308
+ ...this._fixed.role_labels
309
+ }
310
+ }
311
+
312
+ // getAnonymousRole() inherited from RoleGranterAdapter (returns 'ROLE_ANONYMOUS')
313
+
314
+ // ─────────────────────────────────────────────────────────────────────────────
315
+ // Role query methods
316
+ // ─────────────────────────────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Check if a role exists (in current or fixed)
320
+ *
321
+ * @param {string} role - Role name
322
+ * @returns {boolean}
323
+ */
324
+ roleExists(role) {
325
+ return this._permissions[role] !== undefined ||
326
+ this._fixed.role_permissions[role] !== undefined
327
+ }
328
+
329
+ /**
330
+ * Get complete role object
331
+ *
332
+ * @param {string} role - Role name
333
+ * @returns {RoleData|null} Role data or null if not found
334
+ */
335
+ getRole(role) {
336
+ if (!this.roleExists(role)) {
337
+ return null
338
+ }
339
+ return {
340
+ name: role,
341
+ label: this._labels[role] || this._fixed.role_labels[role] || role,
342
+ permissions: this.getPermissions(role),
343
+ inherits: this._hierarchy[role] || this._fixed.role_hierarchy[role] || []
344
+ }
345
+ }
346
+
347
+ // ─────────────────────────────────────────────────────────────────────────────
348
+ // Mutation methods
349
+ // ─────────────────────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Create a new role (async, auto-persists)
353
+ *
354
+ * @param {string} name - Role name
355
+ * @param {Object} [options]
356
+ * @param {string} [options.label] - Display label
357
+ * @param {string[]} [options.permissions=[]] - Permission patterns
358
+ * @param {string[]} [options.inherits=[]] - Roles to inherit from
359
+ * @returns {Promise<RoleData>} Created role data
360
+ * @throws {Error} If role already exists
361
+ */
362
+ async createRole(name, { label, permissions = [], inherits = [] } = {}) {
363
+ if (this.roleExists(name)) {
364
+ throw new Error(`Role '${name}' already exists`)
365
+ }
366
+ this._permissions[name] = [...permissions]
367
+ if (label) this._labels[name] = label
368
+ if (inherits.length > 0) this._hierarchy[name] = [...inherits]
369
+ this._dirty = true
370
+
371
+ if (this._persistFn) {
372
+ await this.persist()
373
+ }
374
+
375
+ return this.getRole(name)
376
+ }
377
+
378
+ /**
379
+ * Update an existing role (async, auto-persists)
380
+ *
381
+ * @param {string} name - Role name
382
+ * @param {Object} [options] - Only provided properties are updated
383
+ * @param {string} [options.label] - Display label
384
+ * @param {string[]} [options.permissions] - Permission patterns
385
+ * @param {string[]} [options.inherits] - Roles to inherit from
386
+ * @returns {Promise<RoleData>} Updated role data
387
+ * @throws {Error} If role doesn't exist
388
+ */
389
+ async updateRole(name, { label, permissions, inherits } = {}) {
390
+ if (!this.roleExists(name)) {
391
+ throw new Error(`Role '${name}' does not exist`)
392
+ }
393
+ if (permissions !== undefined) this._permissions[name] = [...permissions]
394
+ if (label !== undefined) this._labels[name] = label
395
+ if (inherits !== undefined) this._hierarchy[name] = [...inherits]
396
+ this._dirty = true
397
+
398
+ if (this._persistFn) {
399
+ await this.persist()
400
+ }
401
+
402
+ return this.getRole(name)
403
+ }
404
+
405
+ /**
406
+ * Set permissions for a role
407
+ *
408
+ * @param {string} role - Role name
409
+ * @param {string[]} permissions - Permission patterns
410
+ * @returns {this}
411
+ */
412
+ setRolePermissions(role, permissions) {
413
+ this._permissions[role] = [...permissions]
414
+ this._dirty = true
415
+ return this
416
+ }
417
+
418
+ /**
419
+ * Add permissions to a role
420
+ *
421
+ * @param {string} role - Role name
422
+ * @param {string[]} permissions - Permissions to add
423
+ * @returns {this}
424
+ */
425
+ addRolePermissions(role, permissions) {
426
+ const current = this._permissions[role] || []
427
+ const newPerms = permissions.filter(p => !current.includes(p))
428
+ this._permissions[role] = [...current, ...newPerms]
429
+ this._dirty = true
430
+ return this
431
+ }
432
+
433
+ /**
434
+ * Remove permissions from a role
435
+ *
436
+ * @param {string} role - Role name
437
+ * @param {string[]} permissions - Permissions to remove
438
+ * @returns {this}
439
+ */
440
+ removeRolePermissions(role, permissions) {
441
+ const current = this._permissions[role] || []
442
+ this._permissions[role] = current.filter(p => !permissions.includes(p))
443
+ this._dirty = true
444
+ return this
445
+ }
446
+
447
+ /**
448
+ * Set role hierarchy
449
+ *
450
+ * @param {string} role - Role name
451
+ * @param {string[]} inherits - Roles this role inherits from
452
+ * @returns {this}
453
+ */
454
+ setRoleHierarchy(role, inherits) {
455
+ this._hierarchy[role] = [...inherits]
456
+ this._dirty = true
457
+ return this
458
+ }
459
+
460
+ /**
461
+ * Set role label
462
+ *
463
+ * @param {string} role - Role name
464
+ * @param {string} label - Display label
465
+ * @returns {this}
466
+ */
467
+ setRoleLabel(role, label) {
468
+ this._labels[role] = label
469
+ this._dirty = true
470
+ return this
471
+ }
472
+
473
+ /**
474
+ * Delete a role entirely (async, auto-persists)
475
+ *
476
+ * @param {string} name - Role name
477
+ * @returns {Promise<void>}
478
+ * @throws {Error} If role doesn't exist
479
+ */
480
+ async deleteRole(name) {
481
+ if (!this.roleExists(name)) {
482
+ throw new Error(`Role '${name}' does not exist`)
483
+ }
484
+ delete this._permissions[name]
485
+ delete this._hierarchy[name]
486
+ delete this._labels[name]
487
+ this._dirty = true
488
+
489
+ if (this._persistFn) {
490
+ await this.persist()
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Reset to defaults
496
+ *
497
+ * @returns {this}
498
+ */
499
+ reset() {
500
+ this._hierarchy = { ...this._defaults.role_hierarchy }
501
+ this._permissions = { ...this._defaults.role_permissions }
502
+ this._labels = { ...this._defaults.role_labels }
503
+ this._dirty = true
504
+ return this
505
+ }
506
+
507
+ // ─────────────────────────────────────────────────────────────────────────────
508
+ // State inspection
509
+ // ─────────────────────────────────────────────────────────────────────────────
510
+
511
+ /**
512
+ * Check if data has been loaded
513
+ * @returns {boolean}
514
+ */
515
+ get isLoaded() {
516
+ return this._loaded
517
+ }
518
+
519
+ /**
520
+ * Check if there are unsaved changes
521
+ * @returns {boolean}
522
+ */
523
+ get isDirty() {
524
+ return this._dirty
525
+ }
526
+
527
+ /**
528
+ * Check if adapter supports persistence
529
+ * Returns true if a persist function was provided
530
+ * @returns {boolean}
531
+ */
532
+ get canPersist() {
533
+ return !!this._persistFn
534
+ }
535
+
536
+ /**
537
+ * Get current configuration as object
538
+ *
539
+ * @returns {Object} Current config
540
+ */
541
+ toJSON() {
542
+ return {
543
+ role_hierarchy: this._hierarchy,
544
+ role_permissions: this._permissions,
545
+ role_labels: this._labels
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Get a ready promise (resolves when loaded)
551
+ *
552
+ * @returns {Promise<this>}
553
+ */
554
+ async ensureReady() {
555
+ await this._ensureLoaded()
556
+ return this
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Create a localStorage-backed role granter
562
+ *
563
+ * Unlike async sources, localStorage is synchronous so data is loaded
564
+ * immediately at construction time. No need to call ensureReady().
565
+ *
566
+ * @param {Object} options
567
+ * @param {string} [options.key='qdadm_roles'] - localStorage key
568
+ * @param {Object} [options.fixed] - Fixed/system configuration (incompressible)
569
+ * @param {Object} [options.defaults] - Default configuration
570
+ * @param {string} [options.mergeStrategy] - Merge strategy
571
+ * @returns {PersistableRoleGranterAdapter}
572
+ */
573
+ export function createLocalStorageRoleGranter(options = {}) {
574
+ const key = options.key || 'qdadm_roles'
575
+
576
+ // Load data synchronously from localStorage (localStorage is sync)
577
+ let initialData = null
578
+ try {
579
+ const stored = localStorage.getItem(key)
580
+ initialData = stored ? JSON.parse(stored) : null
581
+ } catch {
582
+ // Ignore parse errors, will use defaults
583
+ }
584
+
585
+ const adapter = new PersistableRoleGranterAdapter({
586
+ load: () => {
587
+ try {
588
+ const stored = localStorage.getItem(key)
589
+ return stored ? JSON.parse(stored) : null
590
+ } catch {
591
+ return null
592
+ }
593
+ },
594
+ persist: (data) => {
595
+ localStorage.setItem(key, JSON.stringify(data))
596
+ },
597
+ fixed: options.fixed,
598
+ defaults: options.defaults,
599
+ mergeStrategy: options.mergeStrategy,
600
+ autoLoad: false // We load synchronously below
601
+ })
602
+
603
+ // Apply initial data immediately (synchronous load)
604
+ if (initialData) {
605
+ adapter._applyData(initialData)
606
+ }
607
+ adapter._loaded = true
608
+
609
+ return adapter
610
+ }
611
+
612
+ /**
613
+ * @typedef {Object} RoleData
614
+ * @property {string} name - Role name
615
+ * @property {string} label - Display label
616
+ * @property {string[]} permissions - Permission patterns (includes fixed)
617
+ * @property {string[]} inherits - Roles this role inherits from
618
+ */