qdadm 0.17.0 → 0.18.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/EntityManager.js +45 -0
- package/src/entity/auth/AuthAdapter.js +59 -0
- package/src/entity/auth/RoleHierarchy.js +153 -0
- package/src/entity/auth/SecurityChecker.js +167 -0
- package/src/entity/auth/index.js +7 -0
- package/src/index.js +4 -0
- package/src/kernel/Kernel.js +73 -4
- package/src/module/moduleRegistry.js +31 -22
- package/src/zones/ZoneRegistry.js +10 -3
package/package.json
CHANGED
|
@@ -328,6 +328,51 @@ export class EntityManager {
|
|
|
328
328
|
return this._authAdapter
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Build permission string for an action based on entity_permissions config
|
|
333
|
+
*
|
|
334
|
+
* The permission format depends on the security config:
|
|
335
|
+
* - entity_permissions: false → 'entity:read'
|
|
336
|
+
* - entity_permissions: true → 'books:read'
|
|
337
|
+
* - entity_permissions: ['books'] → 'books:read' for books, 'entity:read' for others
|
|
338
|
+
*
|
|
339
|
+
* @param {string} action - Action name (read, create, update, delete, list)
|
|
340
|
+
* @returns {string} - Permission string
|
|
341
|
+
* @private
|
|
342
|
+
*/
|
|
343
|
+
_getPermissionString(action) {
|
|
344
|
+
const checker = this.authAdapter._securityChecker
|
|
345
|
+
if (!checker) return `entity:${action}`
|
|
346
|
+
|
|
347
|
+
const config = checker.entityPermissions
|
|
348
|
+
if (config === false) return `entity:${action}`
|
|
349
|
+
if (config === true) return `${this.name}:${action}`
|
|
350
|
+
if (Array.isArray(config) && config.includes(this.name)) {
|
|
351
|
+
return `${this.name}:${action}`
|
|
352
|
+
}
|
|
353
|
+
return `entity:${action}`
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Check permission using isGranted() if security is configured
|
|
358
|
+
*
|
|
359
|
+
* Falls back to traditional canPerform()/canAccessRecord() if no SecurityChecker.
|
|
360
|
+
* This method respects the entity_permissions config for granular permissions.
|
|
361
|
+
*
|
|
362
|
+
* @param {string} action - Action to check (read, create, update, delete, list)
|
|
363
|
+
* @param {object} [subject] - Optional subject for context-aware checks
|
|
364
|
+
* @returns {boolean}
|
|
365
|
+
*/
|
|
366
|
+
checkPermission(action, subject = null) {
|
|
367
|
+
// If isGranted is available, use it
|
|
368
|
+
if (this.authAdapter.isGranted && this.authAdapter._securityChecker) {
|
|
369
|
+
const perm = this._getPermissionString(action)
|
|
370
|
+
return this.authAdapter.isGranted(perm, subject)
|
|
371
|
+
}
|
|
372
|
+
// Fallback to traditional method
|
|
373
|
+
return this.canAccess(action, subject)
|
|
374
|
+
}
|
|
375
|
+
|
|
331
376
|
/**
|
|
332
377
|
* Set the auth adapter
|
|
333
378
|
* @param {AuthAdapter|null} adapter
|
|
@@ -33,6 +33,65 @@
|
|
|
33
33
|
* @interface
|
|
34
34
|
*/
|
|
35
35
|
export class AuthAdapter {
|
|
36
|
+
/**
|
|
37
|
+
* SecurityChecker instance for isGranted() delegation
|
|
38
|
+
* @type {import('./SecurityChecker.js').SecurityChecker|null}
|
|
39
|
+
* @private
|
|
40
|
+
*/
|
|
41
|
+
_securityChecker = null
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Set the SecurityChecker instance for isGranted() delegation
|
|
45
|
+
*
|
|
46
|
+
* @param {import('./SecurityChecker.js').SecurityChecker} checker
|
|
47
|
+
*/
|
|
48
|
+
setSecurityChecker(checker) {
|
|
49
|
+
this._securityChecker = checker
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if current user is granted an attribute (Symfony-like contract)
|
|
54
|
+
*
|
|
55
|
+
* This is the unified permission check method. It delegates to SecurityChecker
|
|
56
|
+
* if one is configured, otherwise returns true (permissive fallback).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} attribute - Role (ROLE_*) or permission (entity:action)
|
|
59
|
+
* @param {object} [subject] - Optional subject for context-aware checks
|
|
60
|
+
* @returns {boolean} True if user is granted the attribute
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* adapter.isGranted('ROLE_ADMIN') // Check role
|
|
64
|
+
* adapter.isGranted('entity:delete') // Check permission
|
|
65
|
+
* adapter.isGranted('books:delete', book) // Check with subject
|
|
66
|
+
*/
|
|
67
|
+
isGranted(attribute, subject = null) {
|
|
68
|
+
if (!this._securityChecker) return true // Permissive if not configured
|
|
69
|
+
return this._securityChecker.isGranted(attribute, subject)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if current user can assign a specific role
|
|
74
|
+
*
|
|
75
|
+
* Uses SecurityChecker's canAssignRole if available.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} targetRole - Role to assign
|
|
78
|
+
* @returns {boolean} True if user can assign this role
|
|
79
|
+
*/
|
|
80
|
+
canAssignRole(targetRole) {
|
|
81
|
+
if (!this._securityChecker) return true
|
|
82
|
+
return this._securityChecker.canAssignRole(targetRole)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get all roles that current user can assign
|
|
87
|
+
*
|
|
88
|
+
* @returns {string[]} Array of assignable role names
|
|
89
|
+
*/
|
|
90
|
+
getAssignableRoles() {
|
|
91
|
+
if (!this._securityChecker) return []
|
|
92
|
+
return this._securityChecker.getAssignableRoles()
|
|
93
|
+
}
|
|
94
|
+
|
|
36
95
|
/**
|
|
37
96
|
* Check if the current user can perform an action on an entity type (scope check)
|
|
38
97
|
*
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoleHierarchy - Topological role resolution for Symfony-like permission system
|
|
3
|
+
*
|
|
4
|
+
* Roles form a Directed Acyclic Graph (DAG) where higher roles inherit
|
|
5
|
+
* permissions from lower roles. This class resolves the complete set of
|
|
6
|
+
* roles that a user effectively has based on their assigned roles.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```js
|
|
10
|
+
* const hierarchy = new RoleHierarchy({
|
|
11
|
+
* ROLE_ADMIN: ['ROLE_USER'], // Admin inherits from User
|
|
12
|
+
* ROLE_SUPER_ADMIN: ['ROLE_ADMIN'], // Super admin inherits from Admin
|
|
13
|
+
* ROLE_MANAGER: ['ROLE_USER'], // Manager also inherits from User
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* hierarchy.getReachableRoles('ROLE_ADMIN')
|
|
17
|
+
* // Returns: ['ROLE_ADMIN', 'ROLE_USER']
|
|
18
|
+
*
|
|
19
|
+
* hierarchy.isGrantedRole(['ROLE_ADMIN'], 'ROLE_USER')
|
|
20
|
+
* // Returns: true (admin has user permissions)
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class RoleHierarchy {
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object<string, string[]>} hierarchy - Role inheritance map
|
|
26
|
+
* Keys are role names, values are arrays of parent roles they inherit from
|
|
27
|
+
*/
|
|
28
|
+
constructor(hierarchy = {}) {
|
|
29
|
+
this.map = hierarchy
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve all roles reachable from a given role (topological traversal)
|
|
34
|
+
*
|
|
35
|
+
* Performs BFS traversal of the role graph to find all inherited roles.
|
|
36
|
+
* Handles cycles gracefully by tracking visited nodes.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} role - Starting role to resolve
|
|
39
|
+
* @returns {string[]} All roles including the starting role and all inherited roles
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* // With hierarchy: { ROLE_ADMIN: ['ROLE_USER'] }
|
|
43
|
+
* hierarchy.getReachableRoles('ROLE_ADMIN')
|
|
44
|
+
* // Returns: ['ROLE_ADMIN', 'ROLE_USER']
|
|
45
|
+
*/
|
|
46
|
+
getReachableRoles(role) {
|
|
47
|
+
const visited = new Set()
|
|
48
|
+
const queue = [role]
|
|
49
|
+
|
|
50
|
+
while (queue.length > 0) {
|
|
51
|
+
const current = queue.shift()
|
|
52
|
+
if (visited.has(current)) continue
|
|
53
|
+
visited.add(current)
|
|
54
|
+
|
|
55
|
+
const parents = this.map[current] || []
|
|
56
|
+
queue.push(...parents)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return [...visited]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if user with given roles has the required role
|
|
64
|
+
*
|
|
65
|
+
* A user has a role if:
|
|
66
|
+
* 1. They are directly assigned that role, OR
|
|
67
|
+
* 2. They have a role that inherits from the required role
|
|
68
|
+
*
|
|
69
|
+
* @param {string|string[]} userRoles - Role(s) assigned to the user
|
|
70
|
+
* @param {string} requiredRole - The role to check for
|
|
71
|
+
* @returns {boolean} True if user has the required role (directly or inherited)
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* // With hierarchy: { ROLE_ADMIN: ['ROLE_USER'] }
|
|
75
|
+
* hierarchy.isGrantedRole(['ROLE_ADMIN'], 'ROLE_USER') // true
|
|
76
|
+
* hierarchy.isGrantedRole(['ROLE_USER'], 'ROLE_ADMIN') // false
|
|
77
|
+
*/
|
|
78
|
+
isGrantedRole(userRoles, requiredRole) {
|
|
79
|
+
const roles = Array.isArray(userRoles) ? userRoles : [userRoles]
|
|
80
|
+
|
|
81
|
+
for (const role of roles) {
|
|
82
|
+
const reachable = this.getReachableRoles(role)
|
|
83
|
+
if (reachable.includes(requiredRole)) return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get all roles that can reach a given role (reverse lookup)
|
|
91
|
+
*
|
|
92
|
+
* Useful for finding which roles would grant a specific permission.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} targetRole - The role to find grantors for
|
|
95
|
+
* @returns {string[]} All roles that have the target role in their reachable set
|
|
96
|
+
*/
|
|
97
|
+
getRolesGranting(targetRole) {
|
|
98
|
+
const grantors = []
|
|
99
|
+
|
|
100
|
+
for (const role of Object.keys(this.map)) {
|
|
101
|
+
if (this.isGrantedRole([role], targetRole)) {
|
|
102
|
+
grantors.push(role)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Also check the target role itself
|
|
107
|
+
if (!grantors.includes(targetRole)) {
|
|
108
|
+
grantors.push(targetRole)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return grantors
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate the hierarchy for cycles (optional sanity check)
|
|
116
|
+
*
|
|
117
|
+
* @returns {boolean} True if hierarchy is valid (no cycles)
|
|
118
|
+
*/
|
|
119
|
+
validate() {
|
|
120
|
+
const visiting = new Set()
|
|
121
|
+
const visited = new Set()
|
|
122
|
+
|
|
123
|
+
const hasCycle = (role) => {
|
|
124
|
+
if (visited.has(role)) return false
|
|
125
|
+
if (visiting.has(role)) return true
|
|
126
|
+
|
|
127
|
+
visiting.add(role)
|
|
128
|
+
const parents = this.map[role] || []
|
|
129
|
+
for (const parent of parents) {
|
|
130
|
+
if (hasCycle(parent)) return true
|
|
131
|
+
}
|
|
132
|
+
visiting.delete(role)
|
|
133
|
+
visited.add(role)
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const role of Object.keys(this.map)) {
|
|
138
|
+
if (hasCycle(role)) return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a RoleHierarchy instance from config
|
|
147
|
+
*
|
|
148
|
+
* @param {Object<string, string[]>} config - Role hierarchy configuration
|
|
149
|
+
* @returns {RoleHierarchy}
|
|
150
|
+
*/
|
|
151
|
+
export function createRoleHierarchy(config = {}) {
|
|
152
|
+
return new RoleHierarchy(config)
|
|
153
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { RoleHierarchy } from './RoleHierarchy.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SecurityChecker - Symfony-inspired permission checking
|
|
5
|
+
*
|
|
6
|
+
* Provides the `isGranted(attribute, subject?)` contract for checking permissions.
|
|
7
|
+
* Supports both role checks (ROLE_*) and permission checks (entity:action).
|
|
8
|
+
*
|
|
9
|
+
* Note: Symfony's full security system includes "voters" for custom business logic
|
|
10
|
+
* (e.g., "owner can edit their own posts"). This implementation focuses on
|
|
11
|
+
* role hierarchy + declarative permissions. For complex rules, override
|
|
12
|
+
* EntityManager.canRead/canWrite methods directly.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```js
|
|
16
|
+
* const checker = new SecurityChecker({
|
|
17
|
+
* roleHierarchy: new RoleHierarchy({ ROLE_ADMIN: ['ROLE_USER'] }),
|
|
18
|
+
* rolePermissions: {
|
|
19
|
+
* ROLE_USER: ['entity:read', 'entity:list'],
|
|
20
|
+
* ROLE_ADMIN: ['entity:create', 'entity:update', 'entity:delete'],
|
|
21
|
+
* },
|
|
22
|
+
* getCurrentUser: () => authStore.user
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* checker.isGranted('ROLE_ADMIN') // Check role
|
|
26
|
+
* checker.isGranted('entity:delete') // Check permission
|
|
27
|
+
* checker.isGranted('books:delete', book) // Check with subject
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class SecurityChecker {
|
|
31
|
+
/**
|
|
32
|
+
* @param {Object} options
|
|
33
|
+
* @param {RoleHierarchy} options.roleHierarchy - Role hierarchy instance
|
|
34
|
+
* @param {Object<string, string[]>} options.rolePermissions - Permissions per role
|
|
35
|
+
* @param {Function} options.getCurrentUser - Function returning current user or null
|
|
36
|
+
*/
|
|
37
|
+
constructor({ roleHierarchy, rolePermissions = {}, getCurrentUser }) {
|
|
38
|
+
this.roleHierarchy = roleHierarchy instanceof RoleHierarchy
|
|
39
|
+
? roleHierarchy
|
|
40
|
+
: new RoleHierarchy(roleHierarchy || {})
|
|
41
|
+
this.rolePermissions = rolePermissions
|
|
42
|
+
this.getCurrentUser = getCurrentUser
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if current user is granted an attribute (role or permission)
|
|
47
|
+
*
|
|
48
|
+
* This is the main contract method, similar to Symfony's isGranted().
|
|
49
|
+
*
|
|
50
|
+
* Checking flow:
|
|
51
|
+
* 1. If attribute starts with 'ROLE_' → check role hierarchy
|
|
52
|
+
* 2. Check if user has the permission (from role or direct)
|
|
53
|
+
*
|
|
54
|
+
* @param {string} attribute - Role (ROLE_*) or permission (entity:action)
|
|
55
|
+
* @param {object} [subject] - Optional subject for context-aware checks (reserved for future use)
|
|
56
|
+
* @returns {boolean} True if user is granted the attribute
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* checker.isGranted('ROLE_ADMIN') // true/false
|
|
60
|
+
* checker.isGranted('entity:delete') // true/false
|
|
61
|
+
* checker.isGranted('books:delete', book) // true/false (with subject)
|
|
62
|
+
*/
|
|
63
|
+
isGranted(attribute, subject = null) {
|
|
64
|
+
const user = this.getCurrentUser()
|
|
65
|
+
if (!user) return false
|
|
66
|
+
|
|
67
|
+
// 1. Check if it's a role (ROLE_*)
|
|
68
|
+
if (attribute.startsWith('ROLE_')) {
|
|
69
|
+
return this.roleHierarchy.isGrantedRole(
|
|
70
|
+
user.roles || [user.role],
|
|
71
|
+
attribute
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Check if it's a permission
|
|
76
|
+
const userPerms = this.getUserPermissions(user)
|
|
77
|
+
if (userPerms.includes('*')) return true
|
|
78
|
+
if (userPerms.includes(attribute)) return true
|
|
79
|
+
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get all permissions for a user (resolved from roles + user overrides)
|
|
85
|
+
*
|
|
86
|
+
* Resolves permissions by:
|
|
87
|
+
* 1. Getting all reachable roles from role hierarchy
|
|
88
|
+
* 2. Collecting permissions from each role
|
|
89
|
+
* 3. Adding any user-specific permission overrides
|
|
90
|
+
*
|
|
91
|
+
* @param {object} user - User object with role/roles and optional permissions
|
|
92
|
+
* @returns {string[]} Array of all permissions
|
|
93
|
+
*/
|
|
94
|
+
getUserPermissions(user) {
|
|
95
|
+
const roles = user.roles || [user.role]
|
|
96
|
+
const perms = new Set()
|
|
97
|
+
|
|
98
|
+
for (const role of roles) {
|
|
99
|
+
if (!role) continue
|
|
100
|
+
const reachable = this.roleHierarchy.getReachableRoles(role)
|
|
101
|
+
for (const r of reachable) {
|
|
102
|
+
const rolePerms = this.rolePermissions[r] || []
|
|
103
|
+
rolePerms.forEach(p => perms.add(p))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// User-specific permission overrides
|
|
108
|
+
if (user.permissions && Array.isArray(user.permissions)) {
|
|
109
|
+
user.permissions.forEach(p => perms.add(p))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return [...perms]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if user can assign a role to another user
|
|
117
|
+
*
|
|
118
|
+
* Rule: Can only assign roles if user has 'role:assign' permission
|
|
119
|
+
* AND has the target role (or higher) themselves.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} targetRole - Role to assign
|
|
122
|
+
* @returns {boolean} True if user can assign this role
|
|
123
|
+
*/
|
|
124
|
+
canAssignRole(targetRole) {
|
|
125
|
+
return this.isGranted('role:assign') && this.isGranted(targetRole)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Get all roles that current user can assign
|
|
130
|
+
*
|
|
131
|
+
* @returns {string[]} Array of assignable role names
|
|
132
|
+
*/
|
|
133
|
+
getAssignableRoles() {
|
|
134
|
+
if (!this.isGranted('role:assign')) return []
|
|
135
|
+
|
|
136
|
+
const user = this.getCurrentUser()
|
|
137
|
+
if (!user) return []
|
|
138
|
+
|
|
139
|
+
const userRoles = user.roles || [user.role]
|
|
140
|
+
const assignable = new Set()
|
|
141
|
+
|
|
142
|
+
for (const role of userRoles) {
|
|
143
|
+
if (!role) continue
|
|
144
|
+
const reachable = this.roleHierarchy.getReachableRoles(role)
|
|
145
|
+
reachable.forEach(r => assignable.add(r))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [...assignable]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create a SecurityChecker instance from config
|
|
154
|
+
*
|
|
155
|
+
* @param {Object} config
|
|
156
|
+
* @param {Object<string, string[]>} config.role_hierarchy - Role hierarchy config
|
|
157
|
+
* @param {Object<string, string[]>} config.role_permissions - Permissions per role
|
|
158
|
+
* @param {Function} config.getCurrentUser - User getter function
|
|
159
|
+
* @returns {SecurityChecker}
|
|
160
|
+
*/
|
|
161
|
+
export function createSecurityChecker(config) {
|
|
162
|
+
return new SecurityChecker({
|
|
163
|
+
roleHierarchy: new RoleHierarchy(config.role_hierarchy || {}),
|
|
164
|
+
rolePermissions: config.role_permissions || {},
|
|
165
|
+
getCurrentUser: config.getCurrentUser
|
|
166
|
+
})
|
|
167
|
+
}
|
package/src/entity/auth/index.js
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
* Auth Module
|
|
3
3
|
*
|
|
4
4
|
* AuthAdapter interface and implementations for scope/silo permission checks.
|
|
5
|
+
* Includes Symfony-inspired role hierarchy and security checker.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
// Interface
|
|
8
9
|
export { AuthAdapter, AuthActions } from './AuthAdapter.js'
|
|
9
10
|
|
|
11
|
+
// Role Hierarchy (topological resolution)
|
|
12
|
+
export { RoleHierarchy, createRoleHierarchy } from './RoleHierarchy.js'
|
|
13
|
+
|
|
14
|
+
// Security Checker (isGranted contract)
|
|
15
|
+
export { SecurityChecker, createSecurityChecker } from './SecurityChecker.js'
|
|
16
|
+
|
|
10
17
|
// Implementations
|
|
11
18
|
export { PermissiveAuthAdapter, createPermissiveAdapter } from './PermissiveAdapter.js'
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* A framework for building admin dashboards with Vue 3, PrimeVue, and Vue Router.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
// Version (from package.json)
|
|
8
|
+
import pkg from '../package.json'
|
|
9
|
+
export const version = pkg.version
|
|
10
|
+
|
|
7
11
|
// Kernel (simplified bootstrap)
|
|
8
12
|
export * from './kernel/index.js'
|
|
9
13
|
|
package/src/kernel/Kernel.js
CHANGED
|
@@ -51,6 +51,7 @@ import { createSignalBus } from './SignalBus.js'
|
|
|
51
51
|
import { createZoneRegistry } from '../zones/ZoneRegistry.js'
|
|
52
52
|
import { registerStandardZones } from '../zones/zones.js'
|
|
53
53
|
import { createHookRegistry } from '../hooks/HookRegistry.js'
|
|
54
|
+
import { createSecurityChecker } from '../entity/auth/SecurityChecker.js'
|
|
54
55
|
|
|
55
56
|
export class Kernel {
|
|
56
57
|
/**
|
|
@@ -71,6 +72,7 @@ export class Kernel {
|
|
|
71
72
|
* @param {object} options.features - Feature toggles { auth, poweredBy }
|
|
72
73
|
* @param {object} options.primevue - PrimeVue config { plugin, theme, options }
|
|
73
74
|
* @param {object} options.layouts - Layout components { list, form, dashboard, base }
|
|
75
|
+
* @param {object} options.security - Security config { role_hierarchy, role_permissions, entity_permissions }
|
|
74
76
|
*/
|
|
75
77
|
constructor(options) {
|
|
76
78
|
this.options = options
|
|
@@ -81,6 +83,7 @@ export class Kernel {
|
|
|
81
83
|
this.zoneRegistry = null
|
|
82
84
|
this.hookRegistry = null
|
|
83
85
|
this.layoutComponents = null
|
|
86
|
+
this.securityChecker = null
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
/**
|
|
@@ -88,12 +91,17 @@ export class Kernel {
|
|
|
88
91
|
* @returns {App} Vue app instance ready to mount
|
|
89
92
|
*/
|
|
90
93
|
createApp() {
|
|
91
|
-
|
|
92
|
-
this._createRouter()
|
|
94
|
+
// 1. Create services first (modules need them)
|
|
93
95
|
this._createSignalBus()
|
|
94
96
|
this._createHookRegistry()
|
|
95
|
-
this._createOrchestrator()
|
|
96
97
|
this._createZoneRegistry()
|
|
98
|
+
// 2. Initialize modules (can use all services, registers routes)
|
|
99
|
+
this._initModules()
|
|
100
|
+
// 3. Create router (needs routes from modules)
|
|
101
|
+
this._createRouter()
|
|
102
|
+
// 4. Create orchestrator and remaining components
|
|
103
|
+
this._createOrchestrator()
|
|
104
|
+
this._setupSecurity()
|
|
97
105
|
this._createLayoutComponents()
|
|
98
106
|
this._createVueApp()
|
|
99
107
|
this._installPlugins()
|
|
@@ -102,13 +110,19 @@ export class Kernel {
|
|
|
102
110
|
|
|
103
111
|
/**
|
|
104
112
|
* Initialize modules from glob import
|
|
113
|
+
* Passes services to modules for zone/signal/hook registration
|
|
105
114
|
*/
|
|
106
115
|
_initModules() {
|
|
107
116
|
if (this.options.sectionOrder) {
|
|
108
117
|
setSectionOrder(this.options.sectionOrder)
|
|
109
118
|
}
|
|
110
119
|
if (this.options.modules) {
|
|
111
|
-
initModules(this.options.modules,
|
|
120
|
+
initModules(this.options.modules, {
|
|
121
|
+
...this.options.modulesOptions,
|
|
122
|
+
zones: this.zoneRegistry,
|
|
123
|
+
signals: this.signals,
|
|
124
|
+
hooks: this.hookRegistry
|
|
125
|
+
})
|
|
112
126
|
}
|
|
113
127
|
}
|
|
114
128
|
|
|
@@ -211,6 +225,44 @@ export class Kernel {
|
|
|
211
225
|
})
|
|
212
226
|
}
|
|
213
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Setup security layer (role hierarchy, permissions)
|
|
230
|
+
*
|
|
231
|
+
* If security config is provided, creates a SecurityChecker and wires it
|
|
232
|
+
* into the entityAuthAdapter for isGranted() support.
|
|
233
|
+
*
|
|
234
|
+
* Security config:
|
|
235
|
+
* ```js
|
|
236
|
+
* security: {
|
|
237
|
+
* role_hierarchy: { ROLE_ADMIN: ['ROLE_USER'] },
|
|
238
|
+
* role_permissions: {
|
|
239
|
+
* ROLE_USER: ['entity:read', 'entity:list'],
|
|
240
|
+
* ROLE_ADMIN: ['entity:create', 'entity:update', 'entity:delete'],
|
|
241
|
+
* },
|
|
242
|
+
* entity_permissions: false // false | true | ['books', 'loans']
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
_setupSecurity() {
|
|
247
|
+
const { security, entityAuthAdapter } = this.options
|
|
248
|
+
if (!security) return
|
|
249
|
+
|
|
250
|
+
// Create SecurityChecker with role hierarchy and permissions
|
|
251
|
+
this.securityChecker = createSecurityChecker({
|
|
252
|
+
role_hierarchy: security.role_hierarchy || {},
|
|
253
|
+
role_permissions: security.role_permissions || {},
|
|
254
|
+
getCurrentUser: () => entityAuthAdapter?.getCurrentUser?.() || null
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// Store entity_permissions config for EntityManager to use
|
|
258
|
+
this.securityChecker.entityPermissions = security.entity_permissions ?? false
|
|
259
|
+
|
|
260
|
+
// Wire SecurityChecker into entityAuthAdapter
|
|
261
|
+
if (entityAuthAdapter?.setSecurityChecker) {
|
|
262
|
+
entityAuthAdapter.setSecurityChecker(this.securityChecker)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
214
266
|
/**
|
|
215
267
|
* Create zone registry for extensible UI composition
|
|
216
268
|
* Registers standard zones during bootstrap.
|
|
@@ -386,4 +438,21 @@ export class Kernel {
|
|
|
386
438
|
get layouts() {
|
|
387
439
|
return this.layoutComponents
|
|
388
440
|
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get the SecurityChecker instance
|
|
444
|
+
* @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
|
|
445
|
+
*/
|
|
446
|
+
getSecurityChecker() {
|
|
447
|
+
return this.securityChecker
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Shorthand accessor for security checker
|
|
452
|
+
* Allows `kernel.security.isGranted(...)` syntax
|
|
453
|
+
* @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
|
|
454
|
+
*/
|
|
455
|
+
get security() {
|
|
456
|
+
return this.securityChecker
|
|
457
|
+
}
|
|
389
458
|
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module Registry - Auto-discovery and registration system
|
|
3
3
|
*
|
|
4
|
-
* Each module
|
|
5
|
-
* - Routes
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
4
|
+
* Each module provides an init.js that registers:
|
|
5
|
+
* - Routes & Navigation (via registry)
|
|
6
|
+
* - Zone blocks (via zones)
|
|
7
|
+
* - Signal handlers (via signals)
|
|
8
|
+
* - Hooks (via hooks)
|
|
8
9
|
*
|
|
9
10
|
* Usage in module (modules/agents/init.js):
|
|
10
11
|
*
|
|
11
|
-
* export function init(registry) {
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* ])
|
|
12
|
+
* export function init({ registry, zones, signals, hooks }) {
|
|
13
|
+
* // Routes & Navigation
|
|
14
|
+
* registry.addRoutes('agents', [...])
|
|
15
|
+
* registry.addNavItem({ section: 'Simulation', route: 'agents', ... })
|
|
16
|
+
* registry.addRouteFamily('agents', ['agent-'])
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* route: 'agents',
|
|
21
|
-
* icon: 'pi pi-user',
|
|
22
|
-
* label: 'Agents'
|
|
23
|
-
* })
|
|
18
|
+
* // Zone blocks
|
|
19
|
+
* zones.registerBlock('agents-list-header', { id: 'agents-header', component: Header })
|
|
24
20
|
*
|
|
25
|
-
*
|
|
21
|
+
* // Signal handlers
|
|
22
|
+
* signals.on('agents:created', handleCreated)
|
|
23
|
+
*
|
|
24
|
+
* // Hooks
|
|
25
|
+
* hooks.register('agents:presave', validateAgent)
|
|
26
26
|
* }
|
|
27
27
|
*/
|
|
28
28
|
|
|
@@ -134,24 +134,33 @@ export function setSectionOrder(order) {
|
|
|
134
134
|
*
|
|
135
135
|
* Usage in app:
|
|
136
136
|
* const moduleInits = import.meta.glob('./modules/* /init.js', { eager: true })
|
|
137
|
-
* initModules(moduleInits)
|
|
137
|
+
* initModules(moduleInits, { zones, signals, hooks })
|
|
138
138
|
*
|
|
139
139
|
* @param {object} moduleInits - Result of import.meta.glob
|
|
140
|
-
* @param {object} options - { coreNavItems
|
|
140
|
+
* @param {object} options - { coreNavItems, zones, signals, hooks }
|
|
141
|
+
* @param {Array} options.coreNavItems - Core nav items not in modules
|
|
142
|
+
* @param {ZoneRegistry} options.zones - Zone registry for block registration
|
|
143
|
+
* @param {SignalBus} options.signals - Signal bus for event handlers
|
|
144
|
+
* @param {HookRegistry} options.hooks - Hook registry for lifecycle hooks
|
|
141
145
|
*/
|
|
142
146
|
export function initModules(moduleInits, options = {}) {
|
|
147
|
+
const { coreNavItems, zones, signals, hooks } = options
|
|
148
|
+
|
|
143
149
|
// Add core nav items (pages that aren't in modules)
|
|
144
|
-
if (
|
|
145
|
-
for (const item of
|
|
150
|
+
if (coreNavItems) {
|
|
151
|
+
for (const item of coreNavItems) {
|
|
146
152
|
registry.addNavItem(item)
|
|
147
153
|
}
|
|
148
154
|
}
|
|
149
155
|
|
|
156
|
+
// Context passed to module init functions
|
|
157
|
+
const context = { registry, zones, signals, hooks }
|
|
158
|
+
|
|
150
159
|
// Initialize all discovered modules
|
|
151
160
|
for (const path in moduleInits) {
|
|
152
161
|
const module = moduleInits[path]
|
|
153
162
|
if (typeof module.init === 'function') {
|
|
154
|
-
module.init(
|
|
163
|
+
module.init(context)
|
|
155
164
|
}
|
|
156
165
|
}
|
|
157
166
|
}
|
|
@@ -294,11 +294,18 @@ export class ZoneRegistry {
|
|
|
294
294
|
this._wrapGraph.get(zoneName).set(block.id, block.wraps)
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
// Check for duplicate ID
|
|
298
|
-
if (block.id
|
|
297
|
+
// Check for duplicate ID
|
|
298
|
+
if (block.id) {
|
|
299
299
|
const existingIndex = zone.blocks.findIndex(b => b.id === block.id)
|
|
300
300
|
if (existingIndex !== -1) {
|
|
301
|
-
//
|
|
301
|
+
// Warn in debug mode - duplicates shouldn't happen with proper module init
|
|
302
|
+
if (this._debug) {
|
|
303
|
+
console.warn(
|
|
304
|
+
`[qdadm:zones] Duplicate block ID "${block.id}" in zone "${zoneName}". ` +
|
|
305
|
+
`This may indicate a module is being initialized multiple times.`
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
// Replace existing block (backward compatibility)
|
|
302
309
|
zone.blocks[existingIndex] = block
|
|
303
310
|
} else {
|
|
304
311
|
zone.blocks.push(block)
|