qdadm 0.36.0 → 0.38.1

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 +175 -33
  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 +157 -27
  20. package/src/debug/components/panels/EntitiesPanel.vue +17 -1
  21. package/src/entity/EntityManager.js +183 -34
  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 +132 -21
  29. package/src/kernel/KernelContext.js +158 -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
@@ -37,7 +37,7 @@
37
37
  * ```
38
38
  */
39
39
 
40
- import { createApp, h } from 'vue'
40
+ import { createApp, h, defineComponent } from 'vue'
41
41
  import { createPinia } from 'pinia'
42
42
  import { createRouter, createWebHistory } from 'vue-router'
43
43
  import ToastService from 'primevue/toastservice'
@@ -55,6 +55,8 @@ import { registerStandardZones } from '../zones/zones.js'
55
55
  import { createHookRegistry } from '../hooks/HookRegistry.js'
56
56
  import { createSecurityChecker } from '../entity/auth/SecurityChecker.js'
57
57
  import { authFactory, CompositeAuthAdapter } from '../entity/auth/index.js'
58
+ import { PermissionRegistry } from '../security/PermissionRegistry.js'
59
+ import { StaticRoleGranterAdapter } from '../security/StaticRoleGranterAdapter.js'
58
60
  import { createManagers } from '../entity/factory.js'
59
61
  import { defaultStorageResolver } from '../entity/storage/factory.js'
60
62
  import { createDeferredRegistry } from '../deferred/DeferredRegistry.js'
@@ -131,6 +133,8 @@ export class Kernel {
131
133
  this.sseBridge = null
132
134
  this.layoutComponents = null
133
135
  this.securityChecker = null
136
+ /** @type {import('../security/PermissionRegistry.js').PermissionRegistry|null} */
137
+ this.permissionRegistry = null
134
138
  /** @type {import('./ModuleLoader.js').ModuleLoader|null} */
135
139
  this.moduleLoader = null
136
140
  /** @type {Map<string|symbol, any>} Pending provides from modules (applied after vueApp creation) */
@@ -185,6 +189,10 @@ export class Kernel {
185
189
  this._createDeferredRegistry()
186
190
  // 2. Create orchestrator early (modules need it for ctx.entity())
187
191
  this._createOrchestrator()
192
+ // 2.5. Create PermissionRegistry early (modules register permissions via ctx.entity())
193
+ this._createPermissionRegistry()
194
+ // 2.6. Setup security early (SecurityModule needs ctx.security)
195
+ this._setupSecurity()
188
196
  // 3. Register auth:ready deferred (if auth configured)
189
197
  this._registerAuthDeferred()
190
198
  // 4. Initialize legacy modules (can use all services, registers routes)
@@ -197,13 +205,14 @@ export class Kernel {
197
205
  this._setupAuthGuard()
198
206
  // 6. Setup auth:expired handler (needs router + authAdapter)
199
207
  this._setupAuthExpiredHandler()
208
+ // 6.5. Setup auth impersonation (authAdapter reacts to signals)
209
+ this._setupAuthImpersonation()
200
210
  // 7. Wire modules that need orchestrator (phase 2 - kernel:ready signal)
201
211
  this._wireModules()
202
212
  // 8. Create EventRouter (needs signals + orchestrator)
203
213
  this._createEventRouter()
204
214
  // 9. Create SSEBridge (needs signals + authAdapter for token)
205
215
  this._createSSEBridge()
206
- this._setupSecurity()
207
216
  this._createLayoutComponents()
208
217
  this._createVueApp()
209
218
  this._installPlugins()
@@ -230,6 +239,10 @@ export class Kernel {
230
239
  this._createDeferredRegistry()
231
240
  // 2. Create orchestrator early (modules need it for ctx.entity())
232
241
  this._createOrchestrator()
242
+ // 2.5. Create PermissionRegistry early (modules register permissions via ctx.entity())
243
+ this._createPermissionRegistry()
244
+ // 2.6. Setup security early (SecurityModule needs ctx.security)
245
+ this._setupSecurity()
233
246
  // 3. Register auth:ready deferred (if auth configured)
234
247
  this._registerAuthDeferred()
235
248
  // 4. Initialize legacy modules (can use all services, registers routes)
@@ -242,13 +255,14 @@ export class Kernel {
242
255
  this._setupAuthGuard()
243
256
  // 6. Setup auth:expired handler (needs router + authAdapter)
244
257
  this._setupAuthExpiredHandler()
258
+ // 6.5. Setup auth impersonation (authAdapter reacts to signals)
259
+ this._setupAuthImpersonation()
245
260
  // 7. Wire modules that need orchestrator (phase 2 - kernel:ready signal)
246
261
  await this._wireModulesAsync()
247
262
  // 8. Create EventRouter (needs signals + orchestrator)
248
263
  this._createEventRouter()
249
264
  // 9. Create SSEBridge (needs signals + authAdapter for token)
250
265
  this._createSSEBridge()
251
- this._setupSecurity()
252
266
  this._createLayoutComponents()
253
267
  this._createVueApp()
254
268
  this._installPlugins()
@@ -327,6 +341,29 @@ export class Kernel {
327
341
  })
328
342
  }
329
343
 
344
+ /**
345
+ * Setup authAdapter to react to impersonation signals
346
+ *
347
+ * If the authAdapter has a connectSignals() method, wire it up to the
348
+ * signal bus so it can react to auth:impersonate and auth:impersonate:stop
349
+ * signals automatically.
350
+ *
351
+ * This enables signal-driven impersonation: components emit signals,
352
+ * authAdapter updates its state in response.
353
+ */
354
+ _setupAuthImpersonation() {
355
+ const { authAdapter } = this.options
356
+ if (!authAdapter?.connectSignals) return
357
+
358
+ const debug = this.options.debug ?? false
359
+ if (debug) {
360
+ console.debug('[Kernel] Wiring authAdapter.connectSignals() for impersonation')
361
+ }
362
+
363
+ // Connect authAdapter to signals - returns cleanup function
364
+ this._authImpersonationCleanup = authAdapter.connectSignals(this.signals)
365
+ }
366
+
330
367
  /**
331
368
  * Fire entity cache warmups
332
369
  * Fire-and-forget: pages that need cache will await via DeferredRegistry.
@@ -467,15 +504,19 @@ export class Kernel {
467
504
  * Some modules may need access to the orchestrator after it's created.
468
505
  * This method emits 'kernel:ready' signal that modules can listen to.
469
506
  *
507
+ * Note: We don't pass kernel/orchestrator in the payload to avoid cyclic
508
+ * reference errors when debug logging is enabled. Handlers can access
509
+ * these via their stored context from connect().
510
+ *
470
511
  * @private
471
512
  */
472
513
  _wireModules() {
473
514
  if (!this.moduleLoader) return
474
515
 
475
- // Emit kernel:ready signal for modules that need orchestrator
516
+ // Emit kernel:ready signal - payload is intentionally minimal to avoid
517
+ // cyclic reference errors when QuarKernel debug mode serializes events
476
518
  const result = this.signals.emit('kernel:ready', {
477
- orchestrator: this.orchestrator,
478
- kernel: this
519
+ ready: true
479
520
  })
480
521
 
481
522
  // If emit returns a promise (async handlers), we can't await it
@@ -495,10 +536,10 @@ export class Kernel {
495
536
  async _wireModulesAsync() {
496
537
  if (!this.moduleLoader) return
497
538
 
498
- // Emit kernel:ready signal for modules that need orchestrator
539
+ // Emit kernel:ready signal - payload is intentionally minimal to avoid
540
+ // cyclic reference errors when QuarKernel debug mode serializes events
499
541
  await this.signals.emit('kernel:ready', {
500
- orchestrator: this.orchestrator,
501
- kernel: this
542
+ ready: true
502
543
  })
503
544
  }
504
545
 
@@ -692,6 +733,35 @@ export class Kernel {
692
733
  })
693
734
  }
694
735
 
736
+ /**
737
+ * Create PermissionRegistry early so modules can register permissions
738
+ * via ctx.entity() and ctx.permissions()
739
+ */
740
+ _createPermissionRegistry() {
741
+ this.permissionRegistry = new PermissionRegistry()
742
+
743
+ // Register core system permissions
744
+ this._registerCorePermissions()
745
+ }
746
+
747
+ /**
748
+ * Register core system permissions provided by the framework
749
+ * These are always available regardless of which modules are loaded
750
+ */
751
+ _registerCorePermissions() {
752
+ // Auth permissions - for impersonation and auth management
753
+ this.permissionRegistry.register('auth', {
754
+ 'impersonate': 'Impersonate other users',
755
+ 'manage': 'Manage authentication settings'
756
+ })
757
+
758
+ // Admin permissions - for system administration
759
+ this.permissionRegistry.register('admin', {
760
+ 'access': 'Access admin panel',
761
+ 'config': 'Edit system configuration'
762
+ })
763
+ }
764
+
695
765
  /**
696
766
  * Setup security layer (role hierarchy, permissions)
697
767
  *
@@ -712,12 +782,28 @@ export class Kernel {
712
782
  */
713
783
  _setupSecurity() {
714
784
  const { security, entityAuthAdapter } = this.options
785
+
786
+ // PermissionRegistry should already be created by _createPermissionRegistry()
787
+ // but create it here as fallback for backward compatibility
788
+ if (!this.permissionRegistry) {
789
+ this.permissionRegistry = new PermissionRegistry()
790
+ }
791
+
715
792
  if (!security) return
716
793
 
717
- // Create SecurityChecker with role hierarchy and permissions
794
+ // Resolve roleGranter: explicit adapter or auto-create from config
795
+ let roleGranter = security.roleGranter
796
+ if (!roleGranter && (security.role_permissions || security.role_hierarchy)) {
797
+ roleGranter = new StaticRoleGranterAdapter({
798
+ role_hierarchy: security.role_hierarchy || {},
799
+ role_permissions: security.role_permissions || {},
800
+ role_labels: security.role_labels || {}
801
+ })
802
+ }
803
+
804
+ // Create SecurityChecker with roleGranter
718
805
  this.securityChecker = createSecurityChecker({
719
- role_hierarchy: security.role_hierarchy || {},
720
- role_permissions: security.role_permissions || {},
806
+ roleGranter,
721
807
  getCurrentUser: () => entityAuthAdapter?.getCurrentUser?.() || null
722
808
  })
723
809
 
@@ -728,6 +814,16 @@ export class Kernel {
728
814
  if (entityAuthAdapter?.setSecurityChecker) {
729
815
  entityAuthAdapter.setSecurityChecker(this.securityChecker)
730
816
  }
817
+
818
+ // Install roleGranter (for EntityRoleGranterAdapter signal subscriptions)
819
+ if (roleGranter?.install) {
820
+ // Create minimal context for roleGranter installation
821
+ const ctx = {
822
+ orchestrator: this.orchestrator,
823
+ signals: this.signals
824
+ }
825
+ roleGranter.install(ctx)
826
+ }
731
827
  }
732
828
 
733
829
  /**
@@ -839,19 +935,26 @@ export class Kernel {
839
935
  throw new Error('[Kernel] root component is required')
840
936
  }
841
937
 
842
- // If debugBar is enabled and component provided, wrap root with DebugBar
843
- if (this.options.debugBar?.component && QdadmDebugBar) {
844
- const OriginalRoot = this.options.root
845
- const DebugBarComponent = QdadmDebugBar
846
- const WrappedRoot = {
938
+ // Always wrap root with Toast (and DebugBar if enabled)
939
+ const OriginalRoot = this.options.root
940
+ const DebugBarComponent = this.options.debugBar?.component && QdadmDebugBar ? QdadmDebugBar : null
941
+
942
+ // Wrap root with DebugBar if enabled
943
+ // Note: Toast must be included by apps in their App.vue for pages outside BaseLayout
944
+ if (DebugBarComponent) {
945
+ const WrappedRoot = defineComponent({
847
946
  name: 'QdadmRootWrapper',
848
- render() {
849
- return [h(OriginalRoot), h(DebugBarComponent)]
947
+ components: { OriginalRoot, DebugBarComponent },
948
+ setup() {
949
+ return () => h('div', { id: 'qdadm-root', style: 'display: contents' }, [
950
+ h(OriginalRoot),
951
+ h(DebugBarComponent)
952
+ ])
850
953
  }
851
- }
954
+ })
852
955
  this.vueApp = createApp(WrappedRoot)
853
956
  } else {
854
- this.vueApp = createApp(this.options.root)
957
+ this.vueApp = createApp(OriginalRoot)
855
958
  }
856
959
  }
857
960
 
@@ -941,6 +1044,14 @@ export class Kernel {
941
1044
  // Layout components injection for useLayoutResolver
942
1045
  app.provide('qdadmLayoutComponents', this.layoutComponents)
943
1046
 
1047
+ // Security injection (for SecurityModule and other system modules)
1048
+ if (this.securityChecker) {
1049
+ app.provide('qdadmSecurityChecker', this.securityChecker)
1050
+ }
1051
+ if (this.permissionRegistry) {
1052
+ app.provide('qdadmPermissionRegistry', this.permissionRegistry)
1053
+ }
1054
+
944
1055
  // qdadm plugin
945
1056
  // Note: Don't pass managers here - orchestrator already has resolved managers
946
1057
  // from createManagers(). Passing raw configs would overwrite them.
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { managerFactory } from '../entity/factory.js'
28
28
  import { registry } from '../module/moduleRegistry.js'
29
+ import { UsersManager } from '../security/UsersManager.js'
29
30
 
30
31
  export class KernelContext {
31
32
  /**
@@ -129,6 +130,22 @@ export class KernelContext {
129
130
  return this._kernel.securityChecker
130
131
  }
131
132
 
133
+ /**
134
+ * Get permission registry
135
+ * @returns {import('../security/PermissionRegistry.js').PermissionRegistry|null}
136
+ */
137
+ get permissionRegistry() {
138
+ return this._kernel.permissionRegistry
139
+ }
140
+
141
+ /**
142
+ * Get auth adapter shortcut
143
+ * @returns {object|null}
144
+ */
145
+ get auth() {
146
+ return this._kernel.options?.authAdapter ?? null
147
+ }
148
+
132
149
  // ─────────────────────────────────────────────────────────────────────────────
133
150
  // Fluent registration methods (return this for chaining)
134
151
  // ─────────────────────────────────────────────────────────────────────────────
@@ -167,6 +184,73 @@ export class KernelContext {
167
184
  this._kernel.orchestrator.register(name, manager)
168
185
  }
169
186
 
187
+ // Auto-register CRUD permissions for this entity
188
+ if (this._kernel.permissionRegistry) {
189
+ this._kernel.permissionRegistry.registerEntity(name, {
190
+ module: this._module?.constructor?.name || 'unknown',
191
+ // Register entity-own:* permissions if manager has isOwn configured
192
+ hasOwnership: !!manager._isOwn
193
+ })
194
+ }
195
+
196
+ return this
197
+ }
198
+
199
+ /**
200
+ * Register a users entity with standard fields and role linking
201
+ *
202
+ * Creates a UsersManager with:
203
+ * - username, password, role fields (role linked to roles entity)
204
+ * - System entity flag
205
+ * - Admin-only access by default
206
+ *
207
+ * @param {Object} options - Configuration options
208
+ * @param {Object} options.storage - Storage adapter (required)
209
+ * @param {Object} [options.extraFields={}] - Additional fields beyond username/password/role
210
+ * @param {string} [options.adminRole='ROLE_ADMIN'] - Role required for user management
211
+ * @param {boolean} [options.adminOnly=true] - Restrict access to admin role only
212
+ * @param {Object} [options.fieldOverrides={}] - Override default field configs
213
+ * @returns {this}
214
+ *
215
+ * @example
216
+ * // Basic usage with MockApiStorage
217
+ * ctx.userEntity({
218
+ * storage: new MockApiStorage({ entityName: 'users', initialData: usersFixture })
219
+ * })
220
+ *
221
+ * @example
222
+ * // With extra fields and API storage
223
+ * ctx.userEntity({
224
+ * storage: new ApiStorage({ endpoint: '/api/users' }),
225
+ * extraFields: {
226
+ * email: { type: 'email', label: 'Email', required: true },
227
+ * firstName: { type: 'text', label: 'First Name' },
228
+ * lastName: { type: 'text', label: 'Last Name' }
229
+ * }
230
+ * })
231
+ *
232
+ * @example
233
+ * // Allow non-admin access (e.g., for user profile editing)
234
+ * ctx.userEntity({
235
+ * storage: usersStorage,
236
+ * adminOnly: false
237
+ * })
238
+ */
239
+ userEntity(options = {}) {
240
+ const manager = new UsersManager(options)
241
+
242
+ // Register with orchestrator
243
+ if (this._kernel.orchestrator) {
244
+ this._kernel.orchestrator.register('users', manager)
245
+ }
246
+
247
+ // Auto-register CRUD permissions for users entity
248
+ if (this._kernel.permissionRegistry) {
249
+ this._kernel.permissionRegistry.registerEntity('users', {
250
+ module: this._module?.constructor?.name || 'unknown'
251
+ })
252
+ }
253
+
170
254
  return this
171
255
  }
172
256
 
@@ -521,6 +605,80 @@ export class KernelContext {
521
605
  }
522
606
  return this
523
607
  }
608
+
609
+ // ─────────────────────────────────────────────────────────────────────────────
610
+ // Permission registration
611
+ // ─────────────────────────────────────────────────────────────────────────────
612
+
613
+ /**
614
+ * Register permissions for this module
615
+ *
616
+ * Permissions are namespaced and collected in the central PermissionRegistry.
617
+ * Use this to declare what permissions your module provides.
618
+ *
619
+ * Permission format: namespace:action
620
+ * - For entity CRUD: automatically prefixed with 'entity:'
621
+ * - For system features: use any namespace (auth:, admin:, feature:, etc.)
622
+ *
623
+ * @param {string} namespace - Permission namespace (e.g., 'books', 'auth', 'admin:config')
624
+ * @param {Object<string, string|PermissionMeta>} permissions - Permission definitions
625
+ * @param {Object} [options={}]
626
+ * @param {boolean} [options.isEntity=false] - Auto-prefix with 'entity:'
627
+ * @returns {this}
628
+ *
629
+ * @example
630
+ * // Entity custom permissions (auto-prefixed with 'entity:')
631
+ * ctx.permissions('books', {
632
+ * checkout: 'Checkout a book',
633
+ * reserve: { label: 'Reserve', description: 'Reserve a book for later' }
634
+ * }, { isEntity: true })
635
+ * // → entity:books:checkout, entity:books:reserve
636
+ *
637
+ * @example
638
+ * // System permissions (no prefix)
639
+ * ctx.permissions('auth', {
640
+ * impersonate: 'Impersonate another user',
641
+ * 'manage-roles': 'Manage user roles'
642
+ * })
643
+ * // → auth:impersonate, auth:manage-roles
644
+ *
645
+ * @example
646
+ * // Admin permissions with nested namespace
647
+ * ctx.permissions('admin:config', {
648
+ * view: 'View configuration',
649
+ * edit: 'Edit configuration'
650
+ * })
651
+ * // → admin:config:view, admin:config:edit
652
+ */
653
+ permissions(namespace, permissions, options = {}) {
654
+ if (this._kernel.permissionRegistry) {
655
+ this._kernel.permissionRegistry.register(namespace, permissions, {
656
+ ...options,
657
+ module: this._module?.constructor?.name || 'unknown'
658
+ })
659
+ }
660
+ return this
661
+ }
662
+
663
+ /**
664
+ * Register entity permissions (convenience method)
665
+ *
666
+ * Shorthand for registering entity-namespaced permissions.
667
+ * Equivalent to: ctx.permissions(entity, perms, { isEntity: true })
668
+ *
669
+ * @param {string} entity - Entity name
670
+ * @param {Object<string, string|PermissionMeta>} permissions - Permission definitions
671
+ * @returns {this}
672
+ *
673
+ * @example
674
+ * ctx.entityPermissions('books', {
675
+ * checkout: 'Checkout a book'
676
+ * })
677
+ * // → entity:books:checkout
678
+ */
679
+ entityPermissions(entity, permissions) {
680
+ return this.permissions(entity, permissions, { isEntity: true })
681
+ }
524
682
  }
525
683
 
526
684
  /**