qdadm 0.36.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.
- package/README.md +27 -174
- package/package.json +2 -1
- package/src/auth/SessionAuthAdapter.js +114 -3
- package/src/components/editors/PermissionEditor.vue +535 -0
- package/src/components/forms/FormField.vue +1 -11
- package/src/components/index.js +1 -0
- package/src/components/layout/AppLayout.vue +20 -8
- package/src/components/layout/defaults/DefaultToaster.vue +3 -3
- package/src/components/pages/LoginPage.vue +26 -5
- package/src/composables/useCurrentEntity.js +26 -17
- package/src/composables/useForm.js +7 -0
- package/src/composables/useFormPageBuilder.js +7 -0
- package/src/composables/useNavContext.js +30 -16
- package/src/core/index.js +0 -3
- package/src/debug/AuthCollector.js +175 -33
- package/src/debug/Collector.js +24 -2
- package/src/debug/EntitiesCollector.js +8 -0
- package/src/debug/SignalCollector.js +60 -2
- package/src/debug/components/panels/AuthPanel.vue +157 -27
- package/src/debug/components/panels/EntitiesPanel.vue +17 -1
- package/src/entity/EntityManager.js +183 -34
- package/src/entity/auth/EntityAuthAdapter.js +54 -46
- package/src/entity/auth/SecurityChecker.js +110 -42
- package/src/entity/auth/factory.js +11 -2
- package/src/entity/auth/factory.test.js +29 -0
- package/src/entity/storage/factory.test.js +6 -5
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +132 -21
- package/src/kernel/KernelContext.js +158 -0
- package/src/security/EntityRoleGranterAdapter.js +350 -0
- package/src/security/PermissionMatcher.js +148 -0
- package/src/security/PermissionRegistry.js +263 -0
- package/src/security/PersistableRoleGranterAdapter.js +618 -0
- package/src/security/RoleGranterAdapter.js +123 -0
- package/src/security/RoleGranterStorage.js +161 -0
- package/src/security/RolesManager.js +81 -0
- package/src/security/SecurityModule.js +73 -0
- package/src/security/StaticRoleGranterAdapter.js +114 -0
- package/src/security/UsersManager.js +122 -0
- package/src/security/index.js +45 -0
- package/src/security/pages/RoleForm.vue +212 -0
- package/src/security/pages/RoleList.vue +106 -0
- package/src/styles/main.css +62 -2
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
/**
|
|
3
3
|
* AuthPanel - Auth collector display with activity indicator
|
|
4
|
+
* Each event has its own timer and fades out before destruction
|
|
4
5
|
*/
|
|
5
|
-
import { onMounted,
|
|
6
|
+
import { onMounted, ref, onUnmounted } from 'vue'
|
|
6
7
|
import ObjectTree from '../ObjectTree.vue'
|
|
7
8
|
|
|
8
9
|
const props = defineProps({
|
|
@@ -10,19 +11,101 @@ const props = defineProps({
|
|
|
10
11
|
entries: { type: Array, required: true }
|
|
11
12
|
})
|
|
12
13
|
|
|
13
|
-
//
|
|
14
|
+
// Local events with fade state
|
|
15
|
+
const localEvents = ref([])
|
|
16
|
+
const timers = new Map()
|
|
17
|
+
let unsubscribe = null
|
|
18
|
+
|
|
19
|
+
// Mark events as seen when panel is viewed
|
|
14
20
|
onMounted(() => {
|
|
15
21
|
props.collector.markSeen?.()
|
|
22
|
+
syncEvents()
|
|
23
|
+
|
|
24
|
+
// Subscribe to collector changes
|
|
25
|
+
if (props.collector.onNotify) {
|
|
26
|
+
unsubscribe = props.collector.onNotify(syncEvents)
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
onUnmounted(() => {
|
|
31
|
+
// Unsubscribe from collector
|
|
32
|
+
if (unsubscribe) unsubscribe()
|
|
33
|
+
|
|
34
|
+
// Clear all timers
|
|
35
|
+
for (const timer of timers.values()) {
|
|
36
|
+
clearTimeout(timer.fade)
|
|
37
|
+
clearTimeout(timer.destroy)
|
|
38
|
+
}
|
|
39
|
+
timers.clear()
|
|
16
40
|
})
|
|
17
41
|
|
|
18
|
-
|
|
19
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Sync local events with collector and setup timers
|
|
44
|
+
*/
|
|
45
|
+
function syncEvents() {
|
|
46
|
+
const collectorEvents = props.collector.getRecentEvents?.() || []
|
|
47
|
+
const ttl = props.collector._eventTtl || 60000
|
|
48
|
+
const fadeTime = 3000 // Start fading 3s before destruction
|
|
49
|
+
const now = Date.now()
|
|
50
|
+
|
|
51
|
+
// Add new events
|
|
52
|
+
for (const event of collectorEvents) {
|
|
53
|
+
if (!localEvents.value.find(e => e.id === event.id)) {
|
|
54
|
+
const age = now - event.timestamp.getTime()
|
|
55
|
+
const remaining = ttl - age
|
|
56
|
+
|
|
57
|
+
if (remaining > 0) {
|
|
58
|
+
const localEvent = { ...event, fading: false }
|
|
59
|
+
localEvents.value.unshift(localEvent)
|
|
60
|
+
|
|
61
|
+
// Setup fade timer
|
|
62
|
+
const fadeDelay = Math.max(0, remaining - fadeTime)
|
|
63
|
+
const fadeTimer = setTimeout(() => {
|
|
64
|
+
localEvent.fading = true
|
|
65
|
+
}, fadeDelay)
|
|
66
|
+
|
|
67
|
+
// Setup destroy timer
|
|
68
|
+
const destroyTimer = setTimeout(() => {
|
|
69
|
+
removeEvent(event.id)
|
|
70
|
+
}, remaining)
|
|
71
|
+
|
|
72
|
+
timers.set(event.id, { fade: fadeTimer, destroy: destroyTimer })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Remove events that no longer exist in collector
|
|
78
|
+
const collectorIds = new Set(collectorEvents.map(e => e.id))
|
|
79
|
+
localEvents.value = localEvents.value.filter(e => {
|
|
80
|
+
if (!collectorIds.has(e.id)) {
|
|
81
|
+
clearEventTimers(e.id)
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
84
|
+
return true
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function removeEvent(id) {
|
|
89
|
+
clearEventTimers(id)
|
|
90
|
+
localEvents.value = localEvents.value.filter(e => e.id !== id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clearEventTimers(id) {
|
|
94
|
+
const timer = timers.get(id)
|
|
95
|
+
if (timer) {
|
|
96
|
+
clearTimeout(timer.fade)
|
|
97
|
+
clearTimeout(timer.destroy)
|
|
98
|
+
timers.delete(id)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
20
101
|
|
|
21
102
|
function getIcon(type) {
|
|
22
103
|
const icons = {
|
|
23
104
|
user: 'pi-user',
|
|
105
|
+
impersonated: 'pi-user-edit',
|
|
24
106
|
token: 'pi-key',
|
|
25
|
-
permissions: 'pi-shield',
|
|
107
|
+
'user-permissions': 'pi-shield',
|
|
108
|
+
permissions: 'pi-list',
|
|
26
109
|
hierarchy: 'pi-sitemap',
|
|
27
110
|
'role-permissions': 'pi-lock',
|
|
28
111
|
adapter: 'pi-cog'
|
|
@@ -39,7 +122,8 @@ function getEventIcon(type) {
|
|
|
39
122
|
login: 'pi-sign-in',
|
|
40
123
|
logout: 'pi-sign-out',
|
|
41
124
|
impersonate: 'pi-user-edit',
|
|
42
|
-
'impersonate-stop': 'pi-user'
|
|
125
|
+
'impersonate-stop': 'pi-user',
|
|
126
|
+
'login-error': 'pi-times-circle'
|
|
43
127
|
}
|
|
44
128
|
return icons[type] || 'pi-info-circle'
|
|
45
129
|
}
|
|
@@ -53,8 +137,6 @@ function getEventLabel(event) {
|
|
|
53
137
|
}
|
|
54
138
|
}
|
|
55
139
|
if (event.type === 'impersonate' && event.data) {
|
|
56
|
-
// Payload structure: { target: { username }, original: { username } }
|
|
57
|
-
// Or signal object: { data: { target: { username } } }
|
|
58
140
|
const data = event.data.data || event.data
|
|
59
141
|
const username = data.target?.username
|
|
60
142
|
|| data.username
|
|
@@ -70,11 +152,20 @@ function getEventLabel(event) {
|
|
|
70
152
|
return `Back to ${username}`
|
|
71
153
|
}
|
|
72
154
|
}
|
|
155
|
+
if (event.type === 'login-error' && event.data) {
|
|
156
|
+
const username = event.data.username
|
|
157
|
+
const error = event.data.error || 'Invalid credentials'
|
|
158
|
+
if (username) {
|
|
159
|
+
return `Login failed: ${username} - ${error}`
|
|
160
|
+
}
|
|
161
|
+
return `Login failed: ${error}`
|
|
162
|
+
}
|
|
73
163
|
const labels = {
|
|
74
164
|
login: 'User logged in',
|
|
75
165
|
logout: 'User logged out',
|
|
76
166
|
impersonate: 'Impersonating user',
|
|
77
|
-
'impersonate-stop': 'Stopped impersonation'
|
|
167
|
+
'impersonate-stop': 'Stopped impersonation',
|
|
168
|
+
'login-error': 'Login failed'
|
|
78
169
|
}
|
|
79
170
|
return labels[event.type] || event.type
|
|
80
171
|
}
|
|
@@ -83,8 +174,13 @@ function getEventLabel(event) {
|
|
|
83
174
|
<template>
|
|
84
175
|
<div class="auth-panel">
|
|
85
176
|
<!-- Invariant entries (user, token, permissions, adapter) -->
|
|
86
|
-
<div
|
|
87
|
-
|
|
177
|
+
<div
|
|
178
|
+
v-for="(entry, idx) in entries"
|
|
179
|
+
:key="idx"
|
|
180
|
+
class="auth-item"
|
|
181
|
+
:class="{ 'auth-item--impersonated': entry.type === 'impersonated' }"
|
|
182
|
+
>
|
|
183
|
+
<div class="auth-header" :class="{ 'auth-header--impersonated': entry.type === 'impersonated' }">
|
|
88
184
|
<i :class="['pi', getIcon(entry.type)]" />
|
|
89
185
|
<span class="auth-label">{{ entry.label || entry.type }}</span>
|
|
90
186
|
</div>
|
|
@@ -92,17 +188,19 @@ function getEventLabel(event) {
|
|
|
92
188
|
<ObjectTree v-else-if="entry.data" :data="entry.data" :maxDepth="4" />
|
|
93
189
|
</div>
|
|
94
190
|
|
|
95
|
-
<!-- Recent auth events
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
191
|
+
<!-- Recent auth events with individual timers -->
|
|
192
|
+
<TransitionGroup name="event">
|
|
193
|
+
<div
|
|
194
|
+
v-for="event in localEvents"
|
|
195
|
+
:key="event.id"
|
|
196
|
+
class="auth-activity"
|
|
197
|
+
:class="[event.type, { fading: event.fading }]"
|
|
198
|
+
>
|
|
199
|
+
<i :class="['pi', getEventIcon(event.type)]" />
|
|
200
|
+
<span>{{ getEventLabel(event) }}</span>
|
|
201
|
+
<span class="auth-time">{{ formatTime(event.timestamp) }}</span>
|
|
202
|
+
</div>
|
|
203
|
+
</TransitionGroup>
|
|
106
204
|
</div>
|
|
107
205
|
</template>
|
|
108
206
|
|
|
@@ -113,6 +211,7 @@ function getEventLabel(event) {
|
|
|
113
211
|
flex-direction: column;
|
|
114
212
|
gap: 8px;
|
|
115
213
|
}
|
|
214
|
+
|
|
116
215
|
/* Activity indicator */
|
|
117
216
|
.auth-activity {
|
|
118
217
|
display: flex;
|
|
@@ -122,8 +221,29 @@ function getEventLabel(event) {
|
|
|
122
221
|
border-radius: 4px;
|
|
123
222
|
font-size: 12px;
|
|
124
223
|
font-weight: 600;
|
|
125
|
-
|
|
224
|
+
transition: opacity 3s ease-out, transform 0.3s ease-out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.auth-activity.fading {
|
|
228
|
+
opacity: 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* TransitionGroup animations */
|
|
232
|
+
.event-enter-active {
|
|
233
|
+
transition: all 0.3s ease-out;
|
|
234
|
+
}
|
|
235
|
+
.event-leave-active {
|
|
236
|
+
transition: all 0.5s ease-in;
|
|
237
|
+
}
|
|
238
|
+
.event-enter-from {
|
|
239
|
+
opacity: 0;
|
|
240
|
+
transform: translateY(-10px);
|
|
126
241
|
}
|
|
242
|
+
.event-leave-to {
|
|
243
|
+
opacity: 0;
|
|
244
|
+
transform: translateY(30px);
|
|
245
|
+
}
|
|
246
|
+
|
|
127
247
|
.auth-activity.login {
|
|
128
248
|
background: linear-gradient(90deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%);
|
|
129
249
|
border-left: 3px solid #22c55e;
|
|
@@ -144,21 +264,28 @@ function getEventLabel(event) {
|
|
|
144
264
|
border-left: 3px solid #a1a1aa;
|
|
145
265
|
color: #a1a1aa;
|
|
146
266
|
}
|
|
267
|
+
.auth-activity.login-error {
|
|
268
|
+
background: linear-gradient(90deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.05) 100%);
|
|
269
|
+
border-left: 3px solid #ef4444;
|
|
270
|
+
color: #ef4444;
|
|
271
|
+
}
|
|
272
|
+
|
|
147
273
|
.auth-time {
|
|
148
274
|
margin-left: auto;
|
|
149
275
|
font-size: 10px;
|
|
150
276
|
opacity: 0.6;
|
|
151
277
|
font-weight: 400;
|
|
152
278
|
}
|
|
153
|
-
|
|
154
|
-
0%, 100% { opacity: 1; }
|
|
155
|
-
50% { opacity: 0.7; }
|
|
156
|
-
}
|
|
279
|
+
|
|
157
280
|
.auth-item {
|
|
158
281
|
background: #27272a;
|
|
159
282
|
border-radius: 4px;
|
|
160
283
|
padding: 8px;
|
|
161
284
|
}
|
|
285
|
+
.auth-item--impersonated {
|
|
286
|
+
background: linear-gradient(90deg, rgba(249, 115, 22, 0.15) 0%, rgba(249, 115, 22, 0.05) 100%);
|
|
287
|
+
border-left: 3px solid #f97316;
|
|
288
|
+
}
|
|
162
289
|
.auth-header {
|
|
163
290
|
display: flex;
|
|
164
291
|
align-items: center;
|
|
@@ -166,6 +293,9 @@ function getEventLabel(event) {
|
|
|
166
293
|
margin-bottom: 6px;
|
|
167
294
|
color: #10b981;
|
|
168
295
|
}
|
|
296
|
+
.auth-header--impersonated {
|
|
297
|
+
color: #f97316;
|
|
298
|
+
}
|
|
169
299
|
.auth-label {
|
|
170
300
|
font-weight: 600;
|
|
171
301
|
}
|
|
@@ -110,7 +110,7 @@ function getCapabilityLabel(cap) {
|
|
|
110
110
|
<div v-if="entries[0]?.type === 'status'" class="entities-status">
|
|
111
111
|
{{ entries[0].message }}
|
|
112
112
|
</div>
|
|
113
|
-
<div v-else v-for="entity in entries" :key="entity.name" class="entity-item" :class="{ 'entity-active': entity.hasActivity }">
|
|
113
|
+
<div v-else v-for="entity in entries" :key="entity.name" class="entity-item" :class="{ 'entity-active': entity.hasActivity, 'entity-system': entity.system }">
|
|
114
114
|
<div class="entity-header" @click="toggleExpand(entity.name)">
|
|
115
115
|
<button class="entity-expand">
|
|
116
116
|
<i :class="['pi', isExpanded(entity.name) ? 'pi-chevron-down' : 'pi-chevron-right']" />
|
|
@@ -353,6 +353,22 @@ function getCapabilityLabel(cap) {
|
|
|
353
353
|
border-left-color: #f59e0b;
|
|
354
354
|
background: linear-gradient(90deg, rgba(245, 158, 11, 0.1) 0%, #27272a 30%);
|
|
355
355
|
}
|
|
356
|
+
.entity-system {
|
|
357
|
+
border-left-color: #ef4444;
|
|
358
|
+
background: linear-gradient(90deg, rgba(239, 68, 68, 0.1) 0%, #27272a 30%);
|
|
359
|
+
}
|
|
360
|
+
.entity-system .entity-header {
|
|
361
|
+
color: #ef4444;
|
|
362
|
+
}
|
|
363
|
+
.entity-system .entity-header:hover {
|
|
364
|
+
color: #f87171;
|
|
365
|
+
}
|
|
366
|
+
.entity-system .entity-name::after {
|
|
367
|
+
content: ' (system)';
|
|
368
|
+
font-size: 9px;
|
|
369
|
+
color: #f87171;
|
|
370
|
+
font-weight: normal;
|
|
371
|
+
}
|
|
356
372
|
.entity-header {
|
|
357
373
|
display: flex;
|
|
358
374
|
align-items: center;
|
|
@@ -60,8 +60,11 @@ export class EntityManager {
|
|
|
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
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' }
|
|
@@ -88,10 +91,14 @@ export class EntityManager {
|
|
|
88
91
|
this._warmup = warmup
|
|
89
92
|
// Auto-infer authSensitive from storage.requiresAuth if not explicitly set
|
|
90
93
|
this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
|
|
94
|
+
this._system = system
|
|
91
95
|
|
|
92
96
|
// Scope control
|
|
93
97
|
this._scopeWhitelist = scopeWhitelist
|
|
94
98
|
|
|
99
|
+
// Ownership
|
|
100
|
+
this._isOwn = isOwn
|
|
101
|
+
|
|
95
102
|
// Relations
|
|
96
103
|
this._children = children
|
|
97
104
|
this._parent = parent
|
|
@@ -368,48 +375,54 @@ export class EntityManager {
|
|
|
368
375
|
}
|
|
369
376
|
|
|
370
377
|
/**
|
|
371
|
-
* Build permission string for an action
|
|
378
|
+
* Build permission string for an entity action
|
|
379
|
+
*
|
|
380
|
+
* Format: entity:{name}:{action}
|
|
381
|
+
* Examples: entity:books:read, entity:users:create
|
|
372
382
|
*
|
|
373
|
-
*
|
|
374
|
-
* -
|
|
375
|
-
* -
|
|
376
|
-
* -
|
|
383
|
+
* Role permissions can use wildcards to match:
|
|
384
|
+
* - entity:*:read → read any entity
|
|
385
|
+
* - entity:books:* → any action on books
|
|
386
|
+
* - entity:** → all entity permissions
|
|
377
387
|
*
|
|
378
388
|
* @param {string} action - Action name (read, create, update, delete, list)
|
|
379
389
|
* @returns {string} - Permission string
|
|
380
390
|
* @private
|
|
381
391
|
*/
|
|
382
392
|
_getPermissionString(action) {
|
|
383
|
-
|
|
384
|
-
if (!checker) return `entity:${action}`
|
|
385
|
-
|
|
386
|
-
const config = checker.entityPermissions
|
|
387
|
-
if (config === false) return `entity:${action}`
|
|
388
|
-
if (config === true) return `${this.name}:${action}`
|
|
389
|
-
if (Array.isArray(config) && config.includes(this.name)) {
|
|
390
|
-
return `${this.name}:${action}`
|
|
391
|
-
}
|
|
392
|
-
return `entity:${action}`
|
|
393
|
+
return `entity:${this.name}:${action}`
|
|
393
394
|
}
|
|
394
395
|
|
|
395
396
|
/**
|
|
396
|
-
*
|
|
397
|
+
* Get the current authenticated user
|
|
397
398
|
*
|
|
398
|
-
*
|
|
399
|
-
* This method respects the entity_permissions config for granular permissions.
|
|
399
|
+
* Tries authAdapter.getCurrentUser() first, then falls back to kernel's authAdapter.
|
|
400
400
|
*
|
|
401
|
-
* @
|
|
402
|
-
* @
|
|
403
|
-
* @returns {boolean}
|
|
401
|
+
* @returns {object|null} Current user or null
|
|
402
|
+
* @private
|
|
404
403
|
*/
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
if (this.authAdapter
|
|
408
|
-
|
|
409
|
-
|
|
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
|
+
}
|
|
410
412
|
}
|
|
411
|
-
// Fallback to
|
|
412
|
-
return this.
|
|
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
|
|
413
426
|
}
|
|
414
427
|
|
|
415
428
|
/**
|
|
@@ -424,9 +437,22 @@ export class EntityManager {
|
|
|
424
437
|
* Check if the current user can perform an action, optionally on a specific record
|
|
425
438
|
*
|
|
426
439
|
* This is the primary permission check method. It combines:
|
|
427
|
-
* 1. Local restrictions (readOnly
|
|
428
|
-
* 2.
|
|
429
|
-
* 3.
|
|
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
|
|
430
456
|
*
|
|
431
457
|
* @param {string} action - Action to check: 'read', 'create', 'update', 'delete', 'list'
|
|
432
458
|
* @param {object} [record] - Optional: specific record to check (for silo validation)
|
|
@@ -442,6 +468,16 @@ export class EntityManager {
|
|
|
442
468
|
* manager.canAccess('read', item) // Can user see this specific item?
|
|
443
469
|
* manager.canAccess('update', item) // Can user edit this specific item?
|
|
444
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
|
|
445
481
|
*/
|
|
446
482
|
canAccess(action, record = null) {
|
|
447
483
|
// 1. Check readOnly restriction for write actions
|
|
@@ -449,13 +485,31 @@ export class EntityManager {
|
|
|
449
485
|
return false
|
|
450
486
|
}
|
|
451
487
|
|
|
452
|
-
// 2.
|
|
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()
|
|
453
507
|
const canPerformAction = this.authAdapter.canPerform(this.name, action)
|
|
454
508
|
if (!canPerformAction) {
|
|
455
509
|
return false
|
|
456
510
|
}
|
|
457
511
|
|
|
458
|
-
//
|
|
512
|
+
// 5. Silo check: if record provided, can user access this specific record?
|
|
459
513
|
if (record !== null) {
|
|
460
514
|
return this.authAdapter.canAccessRecord(this.name, record)
|
|
461
515
|
}
|
|
@@ -485,6 +539,14 @@ export class EntityManager {
|
|
|
485
539
|
return this._readOnly
|
|
486
540
|
}
|
|
487
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Check if entity is system-provided (roles, users)
|
|
544
|
+
* @returns {boolean}
|
|
545
|
+
*/
|
|
546
|
+
get system() {
|
|
547
|
+
return this._system
|
|
548
|
+
}
|
|
549
|
+
|
|
488
550
|
/**
|
|
489
551
|
* Check if user can create new entities
|
|
490
552
|
* Delegates to canAccess('create')
|
|
@@ -643,6 +705,91 @@ export class EntityManager {
|
|
|
643
705
|
.map(([name, config]) => ({ name, ...config }))
|
|
644
706
|
}
|
|
645
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
|
+
|
|
646
793
|
// ============ SEVERITY MAPS ============
|
|
647
794
|
|
|
648
795
|
/**
|
|
@@ -1263,7 +1410,9 @@ export class EntityManager {
|
|
|
1263
1410
|
*/
|
|
1264
1411
|
get isCacheEnabled() {
|
|
1265
1412
|
if (this.effectiveThreshold <= 0) return false
|
|
1266
|
-
|
|
1413
|
+
// Check capabilities (instance getter or static)
|
|
1414
|
+
const caps = this.storage?.capabilities || this.storage?.constructor?.capabilities
|
|
1415
|
+
if (caps?.supportsCaching === false) return false
|
|
1267
1416
|
if (!this.storageSupportsTotal) return false
|
|
1268
1417
|
return true
|
|
1269
1418
|
}
|