qdadm 0.17.0 → 0.25.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.
@@ -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
+ }
@@ -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'
@@ -22,9 +22,26 @@
22
22
  */
23
23
  export class ApiStorage {
24
24
  /**
25
- * API calls benefit from EntityManager cache layer to reduce network requests
25
+ * Storage capabilities declaration.
26
+ * Describes what features this storage adapter supports.
27
+ *
28
+ * @type {import('./index.js').StorageCapabilities}
26
29
  */
27
- supportsCaching = true
30
+ static capabilities = {
31
+ supportsTotal: true,
32
+ supportsFilters: true,
33
+ supportsPagination: true,
34
+ supportsCaching: true
35
+ }
36
+
37
+ /**
38
+ * Backward-compatible instance getter for supportsCaching.
39
+ * @deprecated Use static ApiStorage.capabilities.supportsCaching instead
40
+ * @returns {boolean}
41
+ */
42
+ get supportsCaching() {
43
+ return ApiStorage.capabilities.supportsCaching
44
+ }
28
45
 
29
46
  constructor(options = {}) {
30
47
  const {
@@ -19,9 +19,32 @@
19
19
  */
20
20
  export class LocalStorage {
21
21
  /**
22
- * LocalStorage is already in-memory, no need for EntityManager cache layer
22
+ * Storage capabilities declaration.
23
+ * Describes what features this storage adapter supports.
24
+ *
25
+ * LocalStorage operates with browser localStorage:
26
+ * - supportsTotal: true - Returns accurate total from stored data
27
+ * - supportsFilters: true - Filters in-memory via list() params
28
+ * - supportsPagination: true - Paginates in-memory
29
+ * - supportsCaching: false - Already local, no cache benefit
30
+ *
31
+ * @type {import('./index.js').StorageCapabilities}
23
32
  */
24
- supportsCaching = false
33
+ static capabilities = {
34
+ supportsTotal: true,
35
+ supportsFilters: true,
36
+ supportsPagination: true,
37
+ supportsCaching: false
38
+ }
39
+
40
+ /**
41
+ * Backward-compatible instance getter for supportsCaching.
42
+ * @deprecated Use static LocalStorage.capabilities.supportsCaching instead
43
+ * @returns {boolean}
44
+ */
45
+ get supportsCaching() {
46
+ return LocalStorage.capabilities.supportsCaching
47
+ }
25
48
 
26
49
  constructor(options = {}) {
27
50
  const {
@@ -18,6 +18,34 @@
18
18
  * ```
19
19
  */
20
20
  export class MemoryStorage {
21
+ /**
22
+ * Storage capabilities declaration.
23
+ * Describes what features this storage adapter supports.
24
+ *
25
+ * MemoryStorage operates entirely in-memory:
26
+ * - supportsTotal: true - Returns accurate total from in-memory data
27
+ * - supportsFilters: true - Filters in-memory via list() params
28
+ * - supportsPagination: true - Paginates in-memory
29
+ * - supportsCaching: false - Already in-memory, no cache benefit
30
+ *
31
+ * @type {import('./index.js').StorageCapabilities}
32
+ */
33
+ static capabilities = {
34
+ supportsTotal: true,
35
+ supportsFilters: true,
36
+ supportsPagination: true,
37
+ supportsCaching: false
38
+ }
39
+
40
+ /**
41
+ * Backward-compatible instance getter for supportsCaching.
42
+ * @deprecated Use static MemoryStorage.capabilities.supportsCaching instead
43
+ * @returns {boolean}
44
+ */
45
+ get supportsCaching() {
46
+ return MemoryStorage.capabilities.supportsCaching
47
+ }
48
+
21
49
  constructor(options = {}) {
22
50
  const {
23
51
  idField = 'id',
@@ -20,9 +20,32 @@
20
20
  */
21
21
  export class MockApiStorage {
22
22
  /**
23
- * In-memory storage with persistence doesn't benefit from EntityManager cache
23
+ * Storage capabilities declaration.
24
+ * Describes what features this storage adapter supports.
25
+ *
26
+ * MockApiStorage operates with in-memory Map + localStorage persistence:
27
+ * - supportsTotal: true - Returns accurate total from in-memory data
28
+ * - supportsFilters: true - Filters in-memory via list() params
29
+ * - supportsPagination: true - Paginates in-memory
30
+ * - supportsCaching: false - Already in-memory, no cache benefit
31
+ *
32
+ * @type {import('./index.js').StorageCapabilities}
24
33
  */
25
- supportsCaching = false
34
+ static capabilities = {
35
+ supportsTotal: true,
36
+ supportsFilters: true,
37
+ supportsPagination: true,
38
+ supportsCaching: false
39
+ }
40
+
41
+ /**
42
+ * Backward-compatible instance getter for supportsCaching.
43
+ * @deprecated Use static MockApiStorage.capabilities.supportsCaching instead
44
+ * @returns {boolean}
45
+ */
46
+ get supportsCaching() {
47
+ return MockApiStorage.capabilities.supportsCaching
48
+ }
26
49
 
27
50
  constructor(options = {}) {
28
51
  const {
@@ -95,9 +95,24 @@
95
95
  */
96
96
  export class SdkStorage {
97
97
  /**
98
- * SDK calls benefit from EntityManager cache layer to reduce network requests
98
+ * Storage capabilities declaration
99
+ * @type {import('./index.js').StorageCapabilities}
99
100
  */
100
- supportsCaching = true
101
+ static capabilities = {
102
+ supportsTotal: true, // list() returns { items, total }
103
+ supportsFilters: true, // list() accepts filters param
104
+ supportsPagination: true, // list() accepts page/page_size
105
+ supportsCaching: true // Benefits from EntityManager cache layer
106
+ }
107
+
108
+ /**
109
+ * Backward-compat instance getter for supportsCaching
110
+ * @deprecated Use SdkStorage.capabilities.supportsCaching instead
111
+ * @returns {boolean}
112
+ */
113
+ get supportsCaching() {
114
+ return SdkStorage.capabilities.supportsCaching
115
+ }
101
116
 
102
117
  /**
103
118
  * @param {object} options
@@ -5,6 +5,111 @@
5
5
  * EntityManagers can use these or implement their own storage.
6
6
  */
7
7
 
8
+ /**
9
+ * Storage Capabilities Interface
10
+ *
11
+ * Static capabilities declaration that storage adapters expose via `static capabilities = {...}`.
12
+ * EntityManager reads these via `storage.constructor.capabilities` to determine feature support.
13
+ *
14
+ * All properties default to `false` if not declared (conservative assumption).
15
+ * Custom storage implementations that don't declare capabilities will degrade gracefully
16
+ * via fallback to empty `{}`.
17
+ *
18
+ * @typedef {object} StorageCapabilities
19
+ * @property {boolean} [supportsTotal=false] - Storage `list()` returns `{ items, total }` with accurate total count
20
+ * @property {boolean} [supportsFilters=false] - Storage `list()` accepts `filters` param and handles filtering
21
+ * @property {boolean} [supportsPagination=false] - Storage `list()` accepts `page`/`page_size` params
22
+ * @property {boolean} [supportsCaching=false] - Storage benefits from EntityManager cache layer (network-based storages)
23
+ * @property {string[]} [searchFields] - Fields to search when filtering locally. Supports own fields ('title')
24
+ * and parent entity fields ('book.title') via EntityManager.parents config. When undefined, all string
25
+ * fields are searched (default behavior). When defined, only listed fields are searched.
26
+ */
27
+
28
+ /**
29
+ * Migration Guide for Custom Storage Adapters
30
+ *
31
+ * If you have a custom storage adapter, you can add capabilities support in two ways:
32
+ *
33
+ * **Option 1: Static capabilities (recommended)**
34
+ * Add a static `capabilities` property to your class. EntityManager reads this via
35
+ * `storage.constructor.capabilities`. This is the preferred pattern for new code.
36
+ *
37
+ * ```js
38
+ * export class MyCustomStorage {
39
+ * static capabilities = {
40
+ * supportsTotal: true,
41
+ * supportsFilters: true,
42
+ * supportsPagination: true,
43
+ * supportsCaching: true // set to false for in-memory storages
44
+ * }
45
+ *
46
+ * // Backward-compat instance getter (optional, for smooth migration)
47
+ * get supportsCaching() {
48
+ * return MyCustomStorage.capabilities.supportsCaching
49
+ * }
50
+ *
51
+ * async list(params) { ... }
52
+ * async get(id) { ... }
53
+ * // ... other methods
54
+ * }
55
+ * ```
56
+ *
57
+ * **Option 2: Instance property only (legacy)**
58
+ * Keep using instance properties. EntityManager's `isCacheEnabled` check uses:
59
+ * `if (storage?.supportsCaching === false) return false`
60
+ * This works with instance properties directly.
61
+ *
62
+ * ```js
63
+ * export class MyLegacyStorage {
64
+ * supportsCaching = true // or false for in-memory
65
+ *
66
+ * async list(params) { ... }
67
+ * }
68
+ * ```
69
+ *
70
+ * **No capabilities declared**
71
+ * If your storage doesn't declare capabilities, EntityManager will:
72
+ * - Assume `supportsTotal: false` (disables auto-caching threshold check)
73
+ * - Assume `supportsCaching: undefined` (caching allowed, but threshold check fails)
74
+ * - Gracefully degrade without errors
75
+ *
76
+ * Use `getStorageCapabilities(storage)` helper to read merged capabilities with defaults.
77
+ */
78
+
79
+ /**
80
+ * Default capabilities for storages that don't declare their own.
81
+ * All capabilities default to false for safe degradation.
82
+ *
83
+ * @type {StorageCapabilities}
84
+ */
85
+ export const DEFAULT_STORAGE_CAPABILITIES = {
86
+ supportsTotal: false,
87
+ supportsFilters: false,
88
+ supportsPagination: false,
89
+ supportsCaching: false
90
+ }
91
+
92
+ /**
93
+ * Read capabilities from a storage instance.
94
+ * Accesses static `capabilities` property via constructor with fallback to defaults.
95
+ *
96
+ * @param {object} storage - Storage adapter instance
97
+ * @returns {StorageCapabilities} Merged capabilities with defaults
98
+ *
99
+ * @example
100
+ * const caps = getStorageCapabilities(myStorage)
101
+ * if (caps.supportsTotal) {
102
+ * // Storage provides accurate total count
103
+ * }
104
+ */
105
+ export function getStorageCapabilities(storage) {
106
+ const declared = storage?.constructor?.capabilities || {}
107
+ return {
108
+ ...DEFAULT_STORAGE_CAPABILITIES,
109
+ ...declared
110
+ }
111
+ }
112
+
8
113
  export { ApiStorage, createApiStorage } from './ApiStorage.js'
9
114
  export { LocalStorage, createLocalStorage } from './LocalStorage.js'
10
115
  export { MemoryStorage, createMemoryStorage } from './MemoryStorage.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
 
@@ -34,6 +38,9 @@ export * from './hooks/index.js'
34
38
  // Core (extension helpers)
35
39
  export * from './core/index.js'
36
40
 
41
+ // Query (MongoDB-like filtering)
42
+ export * from './query/index.js'
43
+
37
44
  // Utils
38
45
  export * from './utils/index.js'
39
46