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
@@ -0,0 +1,350 @@
1
+ import { RoleGranterAdapter } from './RoleGranterAdapter.js'
2
+
3
+ /**
4
+ * EntityRoleGranterAdapter - Role granter backed by an entity
5
+ *
6
+ * Fetches roles and permissions from a 'roles' entity.
7
+ * Auto-invalidates cache on entity:roles:* signals.
8
+ * Use this for apps with a role management UI.
9
+ *
10
+ * Expected entity schema:
11
+ * {
12
+ * id: 'role-1',
13
+ * name: 'ROLE_ADMIN',
14
+ * label: 'Administrator',
15
+ * permissions: ['entity:**', 'admin:**'],
16
+ * inherits: ['ROLE_USER']
17
+ * }
18
+ *
19
+ * @example
20
+ * // Configure Kernel with entity-based roles
21
+ * const kernel = new Kernel({
22
+ * modules: [SecurityModule, ...],
23
+ * security: {
24
+ * roleGranter: new EntityRoleGranterAdapter({
25
+ * entityName: 'roles',
26
+ * nameField: 'name',
27
+ * permissionsField: 'permissions',
28
+ * inheritsField: 'inherits'
29
+ * })
30
+ * }
31
+ * })
32
+ *
33
+ * // Load roles before boot
34
+ * await kernel.options.security.roleGranter.load()
35
+ * await kernel.boot()
36
+ */
37
+ export class EntityRoleGranterAdapter extends RoleGranterAdapter {
38
+ /**
39
+ * @param {Object} options
40
+ * @param {string} [options.entityName='roles'] - Entity name for roles
41
+ * @param {string} [options.nameField='name'] - Field for role name
42
+ * @param {string} [options.labelField='label'] - Field for display label
43
+ * @param {string} [options.permissionsField='permissions'] - Field for permissions array
44
+ * @param {string} [options.inheritsField='inherits'] - Field for parent roles (hierarchy)
45
+ */
46
+ constructor(options = {}) {
47
+ super()
48
+ this._entityName = options.entityName || 'roles'
49
+ this._nameField = options.nameField || 'name'
50
+ this._labelField = options.labelField || 'label'
51
+ this._permissionsField = options.permissionsField || 'permissions'
52
+ this._inheritsField = options.inheritsField || 'inherits'
53
+
54
+ this._cache = null
55
+ this._ctx = null
56
+ this._orchestrator = null
57
+ this._signalCleanup = null
58
+ this._loading = null
59
+ }
60
+
61
+ /**
62
+ * Install adapter (called by Kernel after orchestrator ready)
63
+ * @param {Object} ctx - Kernel context
64
+ */
65
+ install(ctx) {
66
+ this._ctx = ctx
67
+ this._orchestrator = ctx.orchestrator
68
+
69
+ // Auto-invalidate on role changes
70
+ if (ctx.signals) {
71
+ this._signalCleanup = ctx.signals.on(`entity:${this._entityName}:**`, () => {
72
+ this.invalidate()
73
+ })
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Uninstall adapter (cleanup)
79
+ */
80
+ uninstall() {
81
+ if (this._signalCleanup) {
82
+ this._signalCleanup()
83
+ this._signalCleanup = null
84
+ }
85
+ this._ctx = null
86
+ this._orchestrator = null
87
+ this._cache = null
88
+ this._loading = null
89
+ }
90
+
91
+ /**
92
+ * Load roles from entity (async, must be called before use)
93
+ * @returns {Promise<void>}
94
+ */
95
+ async load() {
96
+ // Prevent concurrent loads
97
+ if (this._loading) {
98
+ return this._loading
99
+ }
100
+
101
+ this._loading = this._doLoad()
102
+ await this._loading
103
+ this._loading = null
104
+ }
105
+
106
+ /**
107
+ * Internal load implementation
108
+ * @private
109
+ */
110
+ async _doLoad() {
111
+ const manager = this._orchestrator?.get(this._entityName)
112
+ if (!manager) {
113
+ console.warn(`[EntityRoleGranter] Entity '${this._entityName}' not found`)
114
+ this._cache = { permissions: {}, hierarchy: {}, labels: {} }
115
+ return
116
+ }
117
+
118
+ try {
119
+ const { data: roles } = await manager.list({ limit: 1000 })
120
+
121
+ // Build cache
122
+ const permissions = {}
123
+ const hierarchy = {}
124
+ const labels = {}
125
+
126
+ for (const role of roles) {
127
+ const name = role[this._nameField]
128
+ if (!name) continue
129
+
130
+ permissions[name] = role[this._permissionsField] || []
131
+ labels[name] = role[this._labelField] || name
132
+
133
+ const inherits = role[this._inheritsField]
134
+ if (inherits?.length > 0) {
135
+ hierarchy[name] = inherits
136
+ }
137
+ }
138
+
139
+ this._cache = { permissions, hierarchy, labels }
140
+ } catch (error) {
141
+ console.error(`[EntityRoleGranter] Failed to load roles:`, error)
142
+ this._cache = { permissions: {}, hierarchy: {}, labels: {} }
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Ensure cache is loaded (throws if not)
148
+ * @private
149
+ */
150
+ _ensureLoaded() {
151
+ if (!this._cache) {
152
+ throw new Error(
153
+ '[EntityRoleGranter] Roles not loaded. Call load() before using the adapter.'
154
+ )
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Get permissions for a role
160
+ * @param {string} role
161
+ * @returns {string[]}
162
+ */
163
+ getPermissions(role) {
164
+ this._ensureLoaded()
165
+ return this._cache.permissions[role] || []
166
+ }
167
+
168
+ /**
169
+ * Get all defined roles
170
+ * @returns {string[]}
171
+ */
172
+ getRoles() {
173
+ this._ensureLoaded()
174
+ return Object.keys(this._cache.permissions)
175
+ }
176
+
177
+ /**
178
+ * Get role hierarchy
179
+ * @returns {Object<string, string[]>}
180
+ */
181
+ getHierarchy() {
182
+ this._ensureLoaded()
183
+ return this._cache.hierarchy
184
+ }
185
+
186
+ /**
187
+ * Get role metadata
188
+ * @param {string} role
189
+ * @returns {RoleMeta|null}
190
+ */
191
+ getRoleMeta(role) {
192
+ this._ensureLoaded()
193
+ const label = this._cache.labels[role]
194
+ if (!label) return null
195
+ return { label }
196
+ }
197
+
198
+ /**
199
+ * Invalidate cache (triggers reload on next access)
200
+ * Call this after role changes
201
+ */
202
+ invalidate() {
203
+ this._cache = null
204
+ }
205
+
206
+ /**
207
+ * Check if cache is loaded
208
+ * @returns {boolean}
209
+ */
210
+ get isLoaded() {
211
+ return this._cache !== null
212
+ }
213
+
214
+ /**
215
+ * Entity-backed adapter can always persist (via entity manager)
216
+ * @returns {boolean}
217
+ */
218
+ get canPersist() {
219
+ return true
220
+ }
221
+
222
+ // ─────────────────────────────────────────────────────────────────────────────
223
+ // Role query methods
224
+ // ─────────────────────────────────────────────────────────────────────────────
225
+
226
+ /**
227
+ * Check if a role exists
228
+ * @param {string} role - Role name
229
+ * @returns {boolean}
230
+ */
231
+ roleExists(role) {
232
+ this._ensureLoaded()
233
+ return this._cache.permissions[role] !== undefined
234
+ }
235
+
236
+ /**
237
+ * Get complete role object
238
+ * @param {string} role - Role name
239
+ * @returns {Object|null}
240
+ */
241
+ getRole(role) {
242
+ this._ensureLoaded()
243
+ if (!this.roleExists(role)) {
244
+ return null
245
+ }
246
+ return {
247
+ name: role,
248
+ label: this._cache.labels[role] || role,
249
+ permissions: this._cache.permissions[role] || [],
250
+ inherits: this._cache.hierarchy[role] || []
251
+ }
252
+ }
253
+
254
+ // ─────────────────────────────────────────────────────────────────────────────
255
+ // Mutation methods (via entity manager)
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Get the entity manager for roles
260
+ * @returns {Object}
261
+ * @private
262
+ */
263
+ _getManager() {
264
+ const manager = this._orchestrator?.get(this._entityName)
265
+ if (!manager) {
266
+ throw new Error(`Entity '${this._entityName}' not found`)
267
+ }
268
+ return manager
269
+ }
270
+
271
+ /**
272
+ * Create a new role
273
+ * @param {string} name - Role name
274
+ * @param {Object} [options]
275
+ * @param {string} [options.label] - Display label
276
+ * @param {string[]} [options.permissions=[]] - Permissions
277
+ * @param {string[]} [options.inherits=[]] - Parent roles
278
+ * @returns {Promise<Object>} Created role entity
279
+ */
280
+ async createRole(name, { label, permissions = [], inherits = [] } = {}) {
281
+ if (this.roleExists(name)) {
282
+ throw new Error(`Role '${name}' already exists`)
283
+ }
284
+ const manager = this._getManager()
285
+ const data = {
286
+ [this._nameField]: name,
287
+ [this._labelField]: label || name,
288
+ [this._permissionsField]: permissions,
289
+ [this._inheritsField]: inherits
290
+ }
291
+ const result = await manager.create(data)
292
+ this.invalidate()
293
+ return result
294
+ }
295
+
296
+ /**
297
+ * Update an existing role
298
+ * @param {string} name - Role name
299
+ * @param {Object} [options]
300
+ * @param {string} [options.label] - Display label
301
+ * @param {string[]} [options.permissions] - Permissions
302
+ * @param {string[]} [options.inherits] - Parent roles
303
+ * @returns {Promise<Object>} Updated role entity
304
+ */
305
+ async updateRole(name, { label, permissions, inherits } = {}) {
306
+ this._ensureLoaded()
307
+ const manager = this._getManager()
308
+
309
+ // Find the role entity by name
310
+ const { data: roles } = await manager.list({
311
+ filter: { [this._nameField]: name },
312
+ limit: 1
313
+ })
314
+ if (roles.length === 0) {
315
+ throw new Error(`Role '${name}' does not exist`)
316
+ }
317
+
318
+ const roleEntity = roles[0]
319
+ const updates = {}
320
+ if (label !== undefined) updates[this._labelField] = label
321
+ if (permissions !== undefined) updates[this._permissionsField] = permissions
322
+ if (inherits !== undefined) updates[this._inheritsField] = inherits
323
+
324
+ const result = await manager.update(roleEntity.id, updates)
325
+ this.invalidate()
326
+ return result
327
+ }
328
+
329
+ /**
330
+ * Delete a role
331
+ * @param {string} name - Role name
332
+ * @returns {Promise<void>}
333
+ */
334
+ async deleteRole(name) {
335
+ this._ensureLoaded()
336
+ const manager = this._getManager()
337
+
338
+ // Find the role entity by name
339
+ const { data: roles } = await manager.list({
340
+ filter: { [this._nameField]: name },
341
+ limit: 1
342
+ })
343
+ if (roles.length === 0) {
344
+ throw new Error(`Role '${name}' does not exist`)
345
+ }
346
+
347
+ await manager.delete(roles[0].id)
348
+ this.invalidate()
349
+ }
350
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * PermissionMatcher - Wildcard permission matching
3
+ *
4
+ * Supports two wildcard patterns (similar to signals):
5
+ * - `*` matches exactly one segment
6
+ * - `**` matches zero or more segments (greedy)
7
+ *
8
+ * Permission format: namespace:target:action
9
+ * Examples:
10
+ * entity:books:read - specific permission
11
+ * entity:books:* - any action on books
12
+ * entity:*:read - read on any entity
13
+ * entity:** - all entity permissions
14
+ * ** - super admin (matches everything)
15
+ *
16
+ * @example
17
+ * PermissionMatcher.matches('entity:*:read', 'entity:books:read') // true
18
+ * PermissionMatcher.matches('entity:**', 'entity:books:read') // true
19
+ * PermissionMatcher.matches('**', 'anything:here') // true
20
+ */
21
+ export class PermissionMatcher {
22
+ /**
23
+ * Check if a permission pattern matches a required permission
24
+ *
25
+ * @param {string} pattern - Pattern with wildcards (e.g., 'entity:*:read')
26
+ * @param {string} required - Required permission (e.g., 'entity:books:read')
27
+ * @returns {boolean} True if pattern matches required
28
+ *
29
+ * @example
30
+ * PermissionMatcher.matches('entity:books:read', 'entity:books:read') // true (exact)
31
+ * PermissionMatcher.matches('entity:books:*', 'entity:books:read') // true (* = one segment)
32
+ * PermissionMatcher.matches('entity:*:read', 'entity:books:read') // true
33
+ * PermissionMatcher.matches('entity:**', 'entity:books:read') // true (** = greedy)
34
+ * PermissionMatcher.matches('**', 'entity:books:read') // true (super admin)
35
+ * PermissionMatcher.matches('entity:*:read', 'entity:a:b:read') // false (* = exactly one)
36
+ */
37
+ static matches(pattern, required) {
38
+ // Super admin
39
+ if (pattern === '**') return true
40
+
41
+ // Exact match
42
+ if (pattern === required) return true
43
+
44
+ const patternParts = pattern.split(':')
45
+ const requiredParts = required.split(':')
46
+
47
+ return this._matchParts(patternParts, requiredParts, 0, 0)
48
+ }
49
+
50
+ /**
51
+ * Recursive matching of pattern parts against required parts
52
+ * @private
53
+ */
54
+ static _matchParts(pattern, required, pi, ri) {
55
+ // Both exhausted = match
56
+ if (pi === pattern.length && ri === required.length) {
57
+ return true
58
+ }
59
+
60
+ // Pattern exhausted but required has more = no match
61
+ if (pi === pattern.length) {
62
+ return false
63
+ }
64
+
65
+ const p = pattern[pi]
66
+
67
+ // ** matches zero or more remaining segments
68
+ if (p === '**') {
69
+ // ** at end matches everything remaining
70
+ if (pi === pattern.length - 1) {
71
+ return true
72
+ }
73
+
74
+ // Try matching ** against 0, 1, 2, ... segments
75
+ for (let skip = 0; skip <= required.length - ri; skip++) {
76
+ if (this._matchParts(pattern, required, pi + 1, ri + skip)) {
77
+ return true
78
+ }
79
+ }
80
+ return false
81
+ }
82
+
83
+ // Required exhausted but pattern has more (and not **)
84
+ if (ri === required.length) {
85
+ return false
86
+ }
87
+
88
+ // * matches exactly one segment
89
+ if (p === '*') {
90
+ return this._matchParts(pattern, required, pi + 1, ri + 1)
91
+ }
92
+
93
+ // Literal match
94
+ if (p === required[ri]) {
95
+ return this._matchParts(pattern, required, pi + 1, ri + 1)
96
+ }
97
+
98
+ return false
99
+ }
100
+
101
+ /**
102
+ * Check if any pattern in array matches the required permission
103
+ *
104
+ * @param {string[]} patterns - Array of patterns (user's permissions)
105
+ * @param {string} required - Required permission to check
106
+ * @returns {boolean} True if any pattern matches
107
+ *
108
+ * @example
109
+ * const userPerms = ['entity:*:read', 'entity:*:list', 'auth:impersonate']
110
+ * PermissionMatcher.any(userPerms, 'entity:books:read') // true
111
+ * PermissionMatcher.any(userPerms, 'entity:books:delete') // false
112
+ */
113
+ static any(patterns, required) {
114
+ if (!patterns || patterns.length === 0) return false
115
+ return patterns.some(p => this.matches(p, required))
116
+ }
117
+
118
+ /**
119
+ * Filter permissions that match a pattern
120
+ *
121
+ * @param {string[]} permissions - Array of specific permissions
122
+ * @param {string} pattern - Pattern to filter by
123
+ * @returns {string[]} Permissions that match the pattern
124
+ *
125
+ * @example
126
+ * const allPerms = ['entity:books:read', 'entity:books:create', 'auth:login']
127
+ * PermissionMatcher.filter(allPerms, 'entity:books:*') // ['entity:books:read', 'entity:books:create']
128
+ */
129
+ static filter(permissions, pattern) {
130
+ return permissions.filter(p => this.matches(pattern, p))
131
+ }
132
+
133
+ /**
134
+ * Expand a pattern against registered permissions
135
+ * Useful for UI: show what a wildcard pattern actually grants
136
+ *
137
+ * @param {string} pattern - Pattern to expand
138
+ * @param {string[]} allPermissions - All registered permissions
139
+ * @returns {string[]} Permissions that the pattern would grant
140
+ *
141
+ * @example
142
+ * const registry = ['entity:books:read', 'entity:books:create', 'entity:loans:read']
143
+ * PermissionMatcher.expand('entity:*:read', registry) // ['entity:books:read', 'entity:loans:read']
144
+ */
145
+ static expand(pattern, allPermissions) {
146
+ return allPermissions.filter(p => this.matches(pattern, p))
147
+ }
148
+ }