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
@@ -59,9 +59,12 @@ export class EntityManager {
59
59
  localFilterThreshold = null, // Items threshold to switch to local filtering (null = use default)
60
60
  readOnly = false, // If true, canCreate/canUpdate/canDelete return false
61
61
  warmup = true, // If true, cache is preloaded at boot via DeferredRegistry
62
- authSensitive = false, // If true, auto-invalidate datalayer on auth events
62
+ authSensitive, // If true, auto-invalidate datalayer on auth events (auto-inferred from storage.requiresAuth if not set)
63
+ system = false, // If true, marks entity as system-provided (roles, users)
63
64
  // Scope control
64
65
  scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
66
+ // Ownership (for record-level access control)
67
+ isOwn = null, // (record, user) => boolean - check if user owns the record
65
68
  // Relations
66
69
  children = {}, // { roles: { entity: 'roles', endpoint?: ':id/roles' } }
67
70
  parent = null, // { entity: 'users', foreignKey: 'user_id' }
@@ -86,11 +89,16 @@ export class EntityManager {
86
89
  this.localFilterThreshold = localFilterThreshold
87
90
  this._readOnly = readOnly
88
91
  this._warmup = warmup
89
- this._authSensitive = authSensitive
92
+ // Auto-infer authSensitive from storage.requiresAuth if not explicitly set
93
+ this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
94
+ this._system = system
90
95
 
91
96
  // Scope control
92
97
  this._scopeWhitelist = scopeWhitelist
93
98
 
99
+ // Ownership
100
+ this._isOwn = isOwn
101
+
94
102
  // Relations
95
103
  this._children = children
96
104
  this._parent = parent
@@ -367,48 +375,54 @@ export class EntityManager {
367
375
  }
368
376
 
369
377
  /**
370
- * Build permission string for an action based on entity_permissions config
378
+ * Build permission string for an entity action
379
+ *
380
+ * Format: entity:{name}:{action}
381
+ * Examples: entity:books:read, entity:users:create
371
382
  *
372
- * The permission format depends on the security config:
373
- * - entity_permissions: false 'entity:read'
374
- * - entity_permissions: true 'books:read'
375
- * - entity_permissions: ['books'] 'books:read' for books, 'entity:read' for others
383
+ * Role permissions can use wildcards to match:
384
+ * - entity:*:readread any entity
385
+ * - entity:books:*any action on books
386
+ * - entity:**all entity permissions
376
387
  *
377
388
  * @param {string} action - Action name (read, create, update, delete, list)
378
389
  * @returns {string} - Permission string
379
390
  * @private
380
391
  */
381
392
  _getPermissionString(action) {
382
- const checker = this.authAdapter._securityChecker
383
- if (!checker) return `entity:${action}`
384
-
385
- const config = checker.entityPermissions
386
- if (config === false) return `entity:${action}`
387
- if (config === true) return `${this.name}:${action}`
388
- if (Array.isArray(config) && config.includes(this.name)) {
389
- return `${this.name}:${action}`
390
- }
391
- return `entity:${action}`
393
+ return `entity:${this.name}:${action}`
392
394
  }
393
395
 
394
396
  /**
395
- * Check permission using isGranted() if security is configured
397
+ * Get the current authenticated user
396
398
  *
397
- * Falls back to traditional canPerform()/canAccessRecord() if no SecurityChecker.
398
- * This method respects the entity_permissions config for granular permissions.
399
+ * Tries authAdapter.getCurrentUser() first, then falls back to kernel's authAdapter.
399
400
  *
400
- * @param {string} action - Action to check (read, create, update, delete, list)
401
- * @param {object} [subject] - Optional subject for context-aware checks
402
- * @returns {boolean}
401
+ * @returns {object|null} Current user or null
402
+ * @private
403
403
  */
404
- checkPermission(action, subject = null) {
405
- // If isGranted is available, use it
406
- if (this.authAdapter.isGranted && this.authAdapter._securityChecker) {
407
- const perm = this._getPermissionString(action)
408
- return this.authAdapter.isGranted(perm, subject)
404
+ _getCurrentUser() {
405
+ // Try authAdapter.getCurrentUser() first (EntityAuthAdapter subclass)
406
+ if (typeof this.authAdapter?.getCurrentUser === 'function') {
407
+ try {
408
+ return this.authAdapter.getCurrentUser()
409
+ } catch {
410
+ // Fallback if not implemented
411
+ }
409
412
  }
410
- // Fallback to traditional method
411
- return this.canAccess(action, subject)
413
+ // Fallback to kernel's authAdapter
414
+ return this._orchestrator?.kernel?.options?.authAdapter?.getUser?.() ?? null
415
+ }
416
+
417
+ /**
418
+ * Check if SecurityChecker is configured via authAdapter
419
+ * Only returns true when adapter has _securityChecker set,
420
+ * allowing legacy canPerform() adapters to work correctly.
421
+ * @returns {boolean}
422
+ * @private
423
+ */
424
+ _hasSecurityChecker() {
425
+ return this.authAdapter?._securityChecker != null
412
426
  }
413
427
 
414
428
  /**
@@ -423,9 +437,22 @@ export class EntityManager {
423
437
  * Check if the current user can perform an action, optionally on a specific record
424
438
  *
425
439
  * This is the primary permission check method. It combines:
426
- * 1. Local restrictions (readOnly, scopeWhitelist)
427
- * 2. Scope check via AuthAdapter.canPerform() - can user do this action type?
428
- * 3. Silo check via AuthAdapter.canAccessRecord() - can user access this record?
440
+ * 1. Local restrictions (readOnly)
441
+ * 2. Ownership check via isOwn callback (if configured)
442
+ * 3. Permission check via isGranted() (if SecurityChecker configured)
443
+ * OR legacy canPerform()/canAccessRecord() fallback
444
+ *
445
+ * Permission format: entity:{name}:{action}
446
+ * Wildcard examples:
447
+ * - entity:*:read → can read any entity
448
+ * - entity:books:* → any action on books
449
+ * - entity:** → all entity permissions
450
+ *
451
+ * Ownership pattern:
452
+ * - Configure isOwn callback: (record, user) => boolean
453
+ * - When user owns a record, check entity-own:{entity}:{action} permission
454
+ * - Example: entity-own:loans:update allows owner to update their loans
455
+ * - Use entity-own:{entity}:** to allow all actions on owned records
429
456
  *
430
457
  * @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
431
458
  * @param {object} [record] - Optional: specific record to check (for silo validation)
@@ -441,6 +468,16 @@ export class EntityManager {
441
468
  * manager.canAccess('read', item) // Can user see this specific item?
442
469
  * manager.canAccess('update', item) // Can user edit this specific item?
443
470
  * manager.canAccess('delete', item) // Can user delete this specific item?
471
+ *
472
+ * @example
473
+ * // Ownership pattern with permissions
474
+ * const loansManager = new EntityManager({
475
+ * name: 'loans',
476
+ * isOwn: (record, user) => record.user_id === user?.id,
477
+ * storage: loansStorage
478
+ * })
479
+ * // Role config: { permissions: ['entity-own:loans:**'] }
480
+ * loansManager.canAccess('update', myLoan) // true if I own the loan
444
481
  */
445
482
  canAccess(action, record = null) {
446
483
  // 1. Check readOnly restriction for write actions
@@ -448,13 +485,31 @@ export class EntityManager {
448
485
  return false
449
486
  }
450
487
 
451
- // 2. Scope check: can user perform this action on this entity type?
488
+ // 2. Ownership check: if user owns the record, check entity-own permission
489
+ if (record && this._isOwn && this._hasSecurityChecker()) {
490
+ const user = this._getCurrentUser()
491
+ if (user && this._isOwn(record, user)) {
492
+ // Owner - check entity-own:{entity}:{action} permission
493
+ const ownPerm = `entity-own:${this.name}:${action}`
494
+ if (this.authAdapter.isGranted(ownPerm, record)) {
495
+ return true
496
+ }
497
+ }
498
+ }
499
+
500
+ // 3. Use isGranted() with entity:name:action format when available
501
+ if (this._hasSecurityChecker()) {
502
+ const perm = this._getPermissionString(action)
503
+ return this.authAdapter.isGranted(perm, record)
504
+ }
505
+
506
+ // 4. Legacy fallback: canPerform() + canAccessRecord()
452
507
  const canPerformAction = this.authAdapter.canPerform(this.name, action)
453
508
  if (!canPerformAction) {
454
509
  return false
455
510
  }
456
511
 
457
- // 3. Silo check: if record provided, can user access this specific record?
512
+ // 5. Silo check: if record provided, can user access this specific record?
458
513
  if (record !== null) {
459
514
  return this.authAdapter.canAccessRecord(this.name, record)
460
515
  }
@@ -484,6 +539,14 @@ export class EntityManager {
484
539
  return this._readOnly
485
540
  }
486
541
 
542
+ /**
543
+ * Check if entity is system-provided (roles, users)
544
+ * @returns {boolean}
545
+ */
546
+ get system() {
547
+ return this._system
548
+ }
549
+
487
550
  /**
488
551
  * Check if user can create new entities
489
552
  * Delegates to canAccess('create')
@@ -642,6 +705,91 @@ export class EntityManager {
642
705
  .map(([name, config]) => ({ name, ...config }))
643
706
  }
644
707
 
708
+ // ============ REFERENCE OPTIONS ============
709
+
710
+ /**
711
+ * Resolve reference options for a field
712
+ *
713
+ * If the field has a `reference` property, fetches data from the referenced
714
+ * entity and returns options array for select/dropdown.
715
+ *
716
+ * @param {string} fieldName - Field name
717
+ * @returns {Promise<Array<{label: string, value: any}>>} - Options array
718
+ *
719
+ * @example
720
+ * // Field config: { type: 'select', reference: { entity: 'roles', labelField: 'label' } }
721
+ * const options = await manager.resolveReferenceOptions('role')
722
+ * // Returns: [{ label: 'Admin', value: 'ROLE_ADMIN' }, { label: 'User', value: 'ROLE_USER' }]
723
+ */
724
+ async resolveReferenceOptions(fieldName) {
725
+ const fieldConfig = this._fields[fieldName]
726
+ if (!fieldConfig) {
727
+ console.warn(`[EntityManager:${this.name}] Unknown field '${fieldName}'`)
728
+ return []
729
+ }
730
+
731
+ // If field has static options, return them
732
+ if (fieldConfig.options && !fieldConfig.reference) {
733
+ return fieldConfig.options
734
+ }
735
+
736
+ // If no reference, return empty
737
+ if (!fieldConfig.reference) {
738
+ return []
739
+ }
740
+
741
+ // Need orchestrator to access other managers
742
+ if (!this._orchestrator) {
743
+ console.warn(`[EntityManager:${this.name}] No orchestrator, cannot resolve reference for '${fieldName}'`)
744
+ return fieldConfig.options || []
745
+ }
746
+
747
+ const { entity, labelField, valueField } = fieldConfig.reference
748
+ const refManager = this._orchestrator.get(entity)
749
+
750
+ if (!refManager) {
751
+ console.warn(`[EntityManager:${this.name}] Referenced entity '${entity}' not found`)
752
+ return fieldConfig.options || []
753
+ }
754
+
755
+ try {
756
+ // Fetch all items from referenced entity
757
+ const { items } = await refManager.list({ limit: 1000 })
758
+
759
+ // Build options array
760
+ const refLabelField = labelField || refManager.labelField || 'label'
761
+ const refValueField = valueField || refManager.idField || 'id'
762
+
763
+ return items.map(item => ({
764
+ label: item[refLabelField] ?? item[refValueField],
765
+ value: item[refValueField]
766
+ }))
767
+ } catch (error) {
768
+ console.error(`[EntityManager:${this.name}] Failed to resolve reference for '${fieldName}':`, error)
769
+ return fieldConfig.options || []
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Resolve all reference options for form fields
775
+ *
776
+ * Returns a map of fieldName -> options for all fields with references.
777
+ *
778
+ * @returns {Promise<Map<string, Array<{label: string, value: any}>>>}
779
+ */
780
+ async resolveAllReferenceOptions() {
781
+ const optionsMap = new Map()
782
+
783
+ for (const [fieldName, fieldConfig] of Object.entries(this._fields)) {
784
+ if (fieldConfig.reference) {
785
+ const options = await this.resolveReferenceOptions(fieldName)
786
+ optionsMap.set(fieldName, options)
787
+ }
788
+ }
789
+
790
+ return optionsMap
791
+ }
792
+
645
793
  // ============ SEVERITY MAPS ============
646
794
 
647
795
  /**
@@ -1034,6 +1182,25 @@ export class EntityManager {
1034
1182
  return caps.supportsTotal ?? false
1035
1183
  }
1036
1184
 
1185
+ /**
1186
+ * Check if storage requires authentication
1187
+ *
1188
+ * Used to auto-infer authSensitive when not explicitly set.
1189
+ * Checks both instance capabilities and static capabilities.
1190
+ *
1191
+ * @returns {boolean} - true if storage requires auth
1192
+ * @private
1193
+ */
1194
+ _getStorageRequiresAuth() {
1195
+ // Check instance capabilities first (may have dynamic requiresAuth)
1196
+ if (this.storage?.capabilities?.requiresAuth !== undefined) {
1197
+ return this.storage.capabilities.requiresAuth
1198
+ }
1199
+ // Fallback to static capabilities
1200
+ const caps = this.storage?.constructor?.capabilities || {}
1201
+ return caps.requiresAuth ?? false
1202
+ }
1203
+
1037
1204
  /**
1038
1205
  * Get searchable fields declared by storage adapter
1039
1206
  *
@@ -1243,7 +1410,9 @@ export class EntityManager {
1243
1410
  */
1244
1411
  get isCacheEnabled() {
1245
1412
  if (this.effectiveThreshold <= 0) return false
1246
- if (this.storage?.supportsCaching === false) return false
1413
+ // Check capabilities (instance getter or static)
1414
+ const caps = this.storage?.capabilities || this.storage?.constructor?.capabilities
1415
+ if (caps?.supportsCaching === false) return false
1247
1416
  if (!this.storageSupportsTotal) return false
1248
1417
  return true
1249
1418
  }
@@ -1,37 +1,31 @@
1
1
  /**
2
- * EntityAuthAdapter - Interface for entity-level permission checks
2
+ * EntityAuthAdapter - Thin layer for entity-level permission checks
3
3
  *
4
- * Applications implement this interface to plug their authorization
5
- * system into qdadm's EntityManager. The adapter provides two levels of permission checks:
4
+ * Provides entity-level permission checking by delegating to SecurityChecker.
5
+ * All methods have sensible defaults - subclass only if you need custom behavior.
6
6
  *
7
- * 1. **Scopes** (action-level): Can the user perform this action on this entity type?
8
- * Example: Can user create invoices? Can user delete users?
7
+ * Default behavior (when SecurityChecker is configured):
8
+ * - canPerform() isGranted('entity:{entity}:{action}')
9
+ * - canAccessRecord() → isGranted('entity:{entity}:read', record)
10
+ * - getCurrentUser() → uses callback if provided, null otherwise
9
11
  *
10
- * 2. **Silos** (record-level): Can the user access this specific record?
11
- * Example: Can user see invoice #123? (ownership, team membership, etc.)
12
- *
13
- * Note: For user session authentication (login/logout), see SessionAuthAdapter.
14
- *
15
- * Usage:
12
+ * Usage patterns:
16
13
  * ```js
17
- * class MyEntityAuthAdapter extends EntityAuthAdapter {
18
- * canPerform(entity, action) {
19
- * const user = this.getCurrentUser()
20
- * return user?.permissions?.includes(`${entity}:${action}`)
21
- * }
22
- *
23
- * canAccessRecord(entity, record) {
24
- * const user = this.getCurrentUser()
25
- * return record.owner_id === user?.id || record.team_id === user?.team_id
26
- * }
14
+ * // 1. Callback-based (simplest - no subclass needed)
15
+ * const adapter = new EntityAuthAdapter({
16
+ * getCurrentUser: () => myAuthStore.user
17
+ * })
27
18
  *
28
- * getCurrentUser() {
29
- * return this._userStore.currentUser
19
+ * // 2. Subclass-based (for custom permission logic)
20
+ * class MyAdapter extends EntityAuthAdapter {
21
+ * canPerform(entity, action) {
22
+ * if (['orders', 'invoices'].includes(entity) && !this.getCurrentUser()) {
23
+ * return false
24
+ * }
25
+ * return super.canPerform(entity, action)
30
26
  * }
31
27
  * }
32
28
  * ```
33
- *
34
- * @interface
35
29
  */
36
30
  export class EntityAuthAdapter {
37
31
  /**
@@ -41,6 +35,23 @@ export class EntityAuthAdapter {
41
35
  */
42
36
  _securityChecker = null
43
37
 
38
+ /**
39
+ * Callback to get current user (alternative to subclassing)
40
+ * @type {Function|null}
41
+ * @private
42
+ */
43
+ _getCurrentUserCallback = null
44
+
45
+ /**
46
+ * @param {object} [options]
47
+ * @param {Function} [options.getCurrentUser] - Callback that returns current user or null
48
+ */
49
+ constructor(options = {}) {
50
+ if (options.getCurrentUser) {
51
+ this._getCurrentUserCallback = options.getCurrentUser
52
+ }
53
+ }
54
+
44
55
  /**
45
56
  * Set the SecurityChecker instance for isGranted() delegation
46
57
  *
@@ -62,7 +73,7 @@ export class EntityAuthAdapter {
62
73
  *
63
74
  * @example
64
75
  * adapter.isGranted('ROLE_ADMIN') // Check role
65
- * adapter.isGranted('entity:delete') // Check permission
76
+ * adapter.isGranted('entity:books:delete') // Check permission
66
77
  * adapter.isGranted('books:delete', book) // Check with subject
67
78
  */
68
79
  isGranted(attribute, subject = null) {
@@ -73,8 +84,6 @@ export class EntityAuthAdapter {
73
84
  /**
74
85
  * Check if current user can assign a specific role
75
86
  *
76
- * Uses SecurityChecker's canAssignRole if available.
77
- *
78
87
  * @param {string} targetRole - Role to assign
79
88
  * @returns {boolean} True if user can assign this role
80
89
  */
@@ -96,50 +105,49 @@ export class EntityAuthAdapter {
96
105
  /**
97
106
  * Check if the current user can perform an action on an entity type (scope check)
98
107
  *
99
- * This is the coarse-grained permission check. It determines if the user has
100
- * the right to perform a category of actions, regardless of which specific
101
- * record they want to act on.
108
+ * Default: delegates to isGranted('entity:{entity}:{action}')
109
+ * Override for custom authentication requirements or business rules.
102
110
  *
103
111
  * @param {string} entity - Entity name (e.g., 'users', 'invoices', 'products')
104
112
  * @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
105
113
  * @returns {boolean} True if user can perform the action on this entity type
106
- *
107
- * @example
108
- * adapter.canPerform('invoices', 'create') // true/false
109
- * adapter.canPerform('users', 'delete') // true/false
110
114
  */
111
115
  canPerform(entity, action) {
112
- throw new Error('[EntityAuthAdapter] canPerform() must be implemented by subclass')
116
+ return this.isGranted(`entity:${entity}:${action}`)
113
117
  }
114
118
 
115
119
  /**
116
120
  * Check if the current user can access a specific record (silo check)
117
121
  *
118
- * This is the fine-grained permission check. After the scope check passes,
119
- * this determines if the user can access a particular record based on
120
- * ownership, team membership, or other business rules.
122
+ * Default: delegates to isGranted('entity:{entity}:read', record)
123
+ * Note: Ownership checks are typically handled via EntityManager's isOwn callback.
121
124
  *
122
125
  * @param {string} entity - Entity name (e.g., 'users', 'invoices')
123
126
  * @param {object} record - The full entity record to check access for
124
127
  * @returns {boolean} True if user can access this specific record
125
- *
126
- * @example
127
- * adapter.canAccessRecord('invoices', { id: 123, owner_id: 456, ... })
128
128
  */
129
129
  canAccessRecord(entity, record) {
130
- throw new Error('[EntityAuthAdapter] canAccessRecord() must be implemented by subclass')
130
+ return this.isGranted(`entity:${entity}:read`, record)
131
131
  }
132
132
 
133
133
  /**
134
134
  * Get the current authenticated user
135
135
  *
136
- * Returns the user object or null if not authenticated. The user object
137
- * should contain whatever information is needed for permission checks.
136
+ * Returns user from:
137
+ * 1. _getCurrentUserCallback if provided in constructor
138
+ * 2. Subclass override if using class extension pattern
139
+ * 3. null if neither is configured
140
+ *
141
+ * Note: SecurityChecker calls this method, so it cannot delegate back to SecurityChecker.
138
142
  *
139
143
  * @returns {object|null} Current user object or null if not authenticated
140
144
  */
141
145
  getCurrentUser() {
142
- throw new Error('[EntityAuthAdapter] getCurrentUser() must be implemented by subclass')
146
+ if (this._getCurrentUserCallback) {
147
+ return this._getCurrentUserCallback()
148
+ }
149
+ // Subclasses can override this method
150
+ return null
143
151
  }
144
152
  }
145
153