@quazardous/qdadm 2.20.0 → 2.22.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/dist/node/vite/qdadmDebugPlugin.d.ts +9 -4
- package/dist/node/vite/qdadmDebugPlugin.js +36 -10
- package/dist/node/vite/qdadmPlugin.js +2 -0
- package/dist/styles/qdadm.css +1 -0
- package/package.json +10 -4
- package/src/components/edit/FormField.vue +51 -4
- package/src/components/edit/FormInput.vue +44 -2
- package/src/components/edit/formFieldIds.ts +11 -0
- package/src/components/editors/RoleGrantsEditor.vue +355 -0
- package/src/components/index.ts +1 -0
- package/src/components/show/ShowPage.vue +4 -2
- package/src/composables/confirmDelete.ts +35 -0
- package/src/composables/index.ts +1 -0
- package/src/composables/useEntityItemFormPage.ts +2 -3
- package/src/composables/useEntityItemFormPage.types.ts +1 -0
- package/src/composables/useEntityItemShowPage.ts +3 -3
- package/src/composables/useListPage.ts +3 -6
- package/src/composables/useListPage.types.ts +1 -0
- package/src/composables/useSecurity.ts +41 -0
- package/src/entity/auth/SecurityChecker.ts +88 -2
- package/src/entity/auth/index.ts +2 -0
- package/src/i18n/defaults/DefaultCoreProvider.ts +4 -2
- package/src/i18n/defaults/__tests__/generated-sync.test.ts +9 -0
- package/src/i18n/defaults/core.en.generated.ts +20 -0
- package/src/i18n/defaults/core.en.yml +20 -0
- package/src/i18n/defaults/core.fr.generated.ts +20 -0
- package/src/i18n/defaults/core.fr.yml +20 -0
- package/src/index.ts +1 -0
- package/src/kernel/Kernel.auth.ts +4 -0
- package/src/kernel/Kernel.registries.ts +24 -5
- package/src/kernel/Kernel.routing.ts +20 -4
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.types.ts +7 -1
- package/src/kernel/Kernel.vue.ts +3 -0
- package/src/kernel/SignalBus.ts +6 -0
- package/src/kernel/chunkReload.ts +110 -0
- package/src/kernel/vitePluginCheck.ts +61 -0
- package/src/modules/debug/AuthCollector.ts +15 -1
- package/src/modules/debug/DebugModule.ts +9 -0
- package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
- package/src/notifications/NotificationListener.vue +2 -1
- package/src/security/RolesProvider.ts +8 -0
- package/src/security/index.ts +17 -0
- package/src/security/pages/RoleForm.vue +42 -191
- package/src/security/roleGrants.ts +210 -0
- package/src/styles/_forms.scss +10 -2
- package/src/styles/index.d.ts +3 -4
- package/src/toast/ToastListener.vue +2 -1
- package/src/types/index.ts +10 -0
- package/src/vite/qdadmDebugPlugin.ts +46 -10
- package/src/vite/qdadmPlugin.ts +2 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is `qdadmVitePlugin()` in the app's vite config? (#2259)
|
|
3
|
+
*
|
|
4
|
+
* qdadm ships raw sources (ADR 0003), and `qdadmVitePlugin()` is what makes the app and qdadm share one copy
|
|
5
|
+
* of PrimeVue. Without it the app dies at boot on PrimeVue's own `No PrimeVue Toast provided!`, which never
|
|
6
|
+
* names qdadm. The plugin defines `__QDADM_VITE_PLUGIN__`; the kernel checks it once, in dev, and says what to
|
|
7
|
+
* add — a log, not a throw, so an app that wires the same config by hand still boots.
|
|
8
|
+
*/
|
|
9
|
+
declare const __QDADM_VITE_PLUGIN__: boolean | undefined
|
|
10
|
+
|
|
11
|
+
export const MISSING_VITE_PLUGIN_HINT =
|
|
12
|
+
'qdadmVitePlugin() is missing from vite.config: without it the app and qdadm can load two copies of PrimeVue, ' +
|
|
13
|
+
"and the app then fails at boot. Add it — import { qdadmVitePlugin } from '@quazardous/qdadm/vite', then " +
|
|
14
|
+
'plugins: [vue(), qdadmVitePlugin()] — see https://github.com/quazardous/qdadm#quick-start'
|
|
15
|
+
|
|
16
|
+
/** Whether the app was built with the plugin: the define it sets. */
|
|
17
|
+
export function hasQdadmVitePlugin(): boolean {
|
|
18
|
+
try {
|
|
19
|
+
return typeof __QDADM_VITE_PLUGIN__ !== 'undefined' && __QDADM_VITE_PLUGIN__ === true
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isDev = (): boolean => (import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV === true
|
|
26
|
+
|
|
27
|
+
let warned = false
|
|
28
|
+
|
|
29
|
+
export interface VitePluginCheckOptions {
|
|
30
|
+
/** Test seams: what the build says, and where the hint goes. */
|
|
31
|
+
present?: boolean
|
|
32
|
+
dev?: boolean
|
|
33
|
+
log?: (message: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Log the hint once, in dev, when the plugin is missing. True when it did. */
|
|
37
|
+
export function warnWithoutQdadmVitePlugin(options: VitePluginCheckOptions = {}): boolean {
|
|
38
|
+
const present = options.present ?? hasQdadmVitePlugin()
|
|
39
|
+
const dev = options.dev ?? isDev()
|
|
40
|
+
if (present || !dev || warned) return false
|
|
41
|
+
warned = true
|
|
42
|
+
;(options.log ?? console.error)(`[qdadm] ${MISSING_VITE_PLUGIN_HINT}`)
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Test seam: forget that the hint was logged. */
|
|
47
|
+
export function resetVitePluginWarning(): void {
|
|
48
|
+
warned = false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** PrimeVue's "No PrimeVue Toast provided!", with its usual cause named. Any other error passes through. */
|
|
52
|
+
export function explainMissingToast<T>(use: () => T): T {
|
|
53
|
+
try {
|
|
54
|
+
return use()
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e instanceof Error && /No PrimeVue Toast provided/i.test(e.message)) {
|
|
57
|
+
throw new Error(`${e.message} The usual cause: ${MISSING_VITE_PLUGIN_HINT}`)
|
|
58
|
+
}
|
|
59
|
+
throw e
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -41,7 +41,7 @@ export interface AuthEvent {
|
|
|
41
41
|
* Auth entry types
|
|
42
42
|
*/
|
|
43
43
|
export interface AuthEntry extends CollectorEntry {
|
|
44
|
-
type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'adapter' | 'error'
|
|
44
|
+
type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'delegated-verdicts' | 'adapter' | 'error'
|
|
45
45
|
label?: string
|
|
46
46
|
message?: string
|
|
47
47
|
data?: unknown
|
|
@@ -372,6 +372,20 @@ export class AuthCollector extends Collector<AuthEntry> {
|
|
|
372
372
|
label: 'Permissions',
|
|
373
373
|
data: permissions
|
|
374
374
|
})
|
|
375
|
+
|
|
376
|
+
// With a delegated judge (#2225) the role matrix above is not the
|
|
377
|
+
// authority, and showing it alone would mislead. Show what the
|
|
378
|
+
// checker actually answers for every registered key — the judge's
|
|
379
|
+
// verdicts, with the matrix only where the judge abstains.
|
|
380
|
+
const judged = (this._securityChecker ?? authCtx?.security) as SecurityChecker | null | undefined
|
|
381
|
+
if (judged?.grant) {
|
|
382
|
+
entries.push({
|
|
383
|
+
timestamp: Date.now(),
|
|
384
|
+
type: 'delegated-verdicts',
|
|
385
|
+
label: 'Delegated Judgement (security.grant)',
|
|
386
|
+
data: Object.fromEntries(permissions.map((key: string) => [key, judged.isGranted(key)]))
|
|
387
|
+
})
|
|
388
|
+
}
|
|
375
389
|
}
|
|
376
390
|
} catch (e) {
|
|
377
391
|
console.warn('[AuthCollector] Error accessing permissionRegistry:', e)
|
|
@@ -22,6 +22,7 @@ import { AuthCollector } from './AuthCollector'
|
|
|
22
22
|
import { EntitiesCollector } from './EntitiesCollector'
|
|
23
23
|
import { RouterCollector } from './RouterCollector'
|
|
24
24
|
import { I18nCollector } from './I18nCollector'
|
|
25
|
+
import { RelayCollector, findRelayController } from '@quazardous/qddebug'
|
|
25
26
|
import DebugBar from './components/DebugBar.vue'
|
|
26
27
|
import type { KernelContext } from '../../kernel/KernelContext'
|
|
27
28
|
|
|
@@ -67,6 +68,8 @@ export interface DebugModuleOptions extends ModuleOptions {
|
|
|
67
68
|
entitiesCollector?: boolean
|
|
68
69
|
routerCollector?: boolean
|
|
69
70
|
i18nCollector?: boolean
|
|
71
|
+
/** MCP tab (#2231) — only shown where the relay connector is installed */
|
|
72
|
+
relayCollector?: boolean
|
|
70
73
|
_kernelManaged?: boolean
|
|
71
74
|
/**
|
|
72
75
|
* Existing DebugBridge to register collectors onto. When provided,
|
|
@@ -152,6 +155,12 @@ export class DebugModule extends Module {
|
|
|
152
155
|
this._bridge.addCollector(new I18nCollector(collectorOptions))
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
// The MCP tab (#2231), after i18n — and only where the app installed the
|
|
159
|
+
// relay connector: no connector, nothing to pair, no tab.
|
|
160
|
+
if (this.options.relayCollector !== false && findRelayController()) {
|
|
161
|
+
this._bridge.addCollector(new RelayCollector(collectorOptions))
|
|
162
|
+
}
|
|
163
|
+
|
|
155
164
|
// Install collectors with context — only when WE own the bridge.
|
|
156
165
|
// External bridges are installed by the host later with a merged
|
|
157
166
|
// context that may include data from other frameworks (e.g. cms).
|
|
@@ -12,6 +12,7 @@ import { useToast } from 'primevue/usetoast'
|
|
|
12
12
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
13
13
|
import { useNotifications } from './NotificationStore'
|
|
14
14
|
import type { NotificationSeverity } from './NotificationStore'
|
|
15
|
+
import { explainMissingToast } from '../kernel/vitePluginCheck'
|
|
15
16
|
|
|
16
17
|
interface ToastEventData {
|
|
17
18
|
summary?: string
|
|
@@ -21,7 +22,7 @@ interface ToastEventData {
|
|
|
21
22
|
forceToast?: boolean
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const toast = useToast
|
|
25
|
+
const toast = explainMissingToast(useToast)
|
|
25
26
|
const signals = inject<SignalBus | null>('qdadmSignals', null)
|
|
26
27
|
const store = useNotifications()
|
|
27
28
|
|
|
@@ -70,6 +70,14 @@ export interface RoleProviderContext {
|
|
|
70
70
|
orchestrator?: {
|
|
71
71
|
get: (name: string) => unknown
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* The live permission registry (#2225) — a reference, not a snapshot:
|
|
75
|
+
* `install` runs before modules register their entities, so read
|
|
76
|
+
* `getKeys()` later, when you actually fetch.
|
|
77
|
+
*/
|
|
78
|
+
permissionRegistry?: {
|
|
79
|
+
getKeys: () => string[]
|
|
80
|
+
}
|
|
73
81
|
[key: string]: unknown
|
|
74
82
|
}
|
|
75
83
|
|
package/src/security/index.ts
CHANGED
|
@@ -69,6 +69,23 @@ export {
|
|
|
69
69
|
type RoleListResult,
|
|
70
70
|
type RoleInput,
|
|
71
71
|
} from './RolesProviderStorage'
|
|
72
|
+
export {
|
|
73
|
+
composeGrants,
|
|
74
|
+
inheritedGrants,
|
|
75
|
+
originOf,
|
|
76
|
+
setGrant,
|
|
77
|
+
type GrantDefinition,
|
|
78
|
+
type InheritedGrant,
|
|
79
|
+
type RoleComposition,
|
|
80
|
+
type InheritableRole,
|
|
81
|
+
type GrantOrigin,
|
|
82
|
+
type GrantCell,
|
|
83
|
+
type GrantRow,
|
|
84
|
+
type GrantGroup,
|
|
85
|
+
type OtherGrant,
|
|
86
|
+
type GrantsModel,
|
|
87
|
+
type RoleHierarchySource,
|
|
88
|
+
} from './roleGrants'
|
|
72
89
|
|
|
73
90
|
/**
|
|
74
91
|
* Standard entity actions for CRUD operations
|
|
@@ -1,59 +1,35 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/**
|
|
3
3
|
* RoleForm - Role create/edit form (standard FormPage pattern)
|
|
4
|
+
*
|
|
5
|
+
* The composition is edited with RoleGrantsEditor (#2313), fed by the roles provider: here qdadm judges roles in
|
|
6
|
+
* the front, so it computes what the inherited roles bring itself.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
import {
|
|
7
|
-
import { useEntityItemFormPage, FormPage, useOrchestrator,
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
import { useEntityItemFormPage, FormPage, useOrchestrator, RoleGrantsEditor } from '../../index.js'
|
|
8
11
|
import InputText from 'primevue/inputtext'
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
import {
|
|
13
|
+
inheritedGrants,
|
|
14
|
+
type InheritableRole,
|
|
15
|
+
type RoleComposition,
|
|
16
|
+
type RoleHierarchySource,
|
|
17
|
+
} from '../roleGrants'
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
20
|
+
* What the form reads from the roles provider
|
|
15
21
|
*/
|
|
16
|
-
interface
|
|
17
|
-
name: string
|
|
18
|
-
label: string
|
|
19
|
-
display: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Role data from roles provider
|
|
24
|
-
*/
|
|
25
|
-
interface RoleData {
|
|
26
|
-
label?: string
|
|
27
|
-
[key: string]: unknown
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* RolesProvider interface
|
|
32
|
-
*/
|
|
33
|
-
interface RolesProviderInterface {
|
|
22
|
+
interface RolesProviderInterface extends RoleHierarchySource {
|
|
34
23
|
getRoles: () => string[]
|
|
35
|
-
getRole?: (name: string) =>
|
|
24
|
+
getRole?: (name: string) => { label?: string } | null
|
|
25
|
+
getRoleMeta?: (name: string) => { label?: string; description?: string } | null
|
|
36
26
|
}
|
|
37
27
|
|
|
38
28
|
/**
|
|
39
29
|
* Roles manager interface
|
|
40
30
|
*/
|
|
41
31
|
interface RolesManager {
|
|
42
|
-
rolesProvider?: RolesProviderInterface
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Autocomplete search event
|
|
47
|
-
*/
|
|
48
|
-
interface AutocompleteSearchEvent {
|
|
49
|
-
query?: string
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Autocomplete select event
|
|
54
|
-
*/
|
|
55
|
-
interface AutocompleteSelectEvent {
|
|
56
|
-
value: RoleOption
|
|
32
|
+
rolesProvider?: RolesProviderInterface | null
|
|
57
33
|
}
|
|
58
34
|
|
|
59
35
|
/**
|
|
@@ -73,61 +49,27 @@ const form = useEntityItemFormPage({ entity: 'roles' })
|
|
|
73
49
|
// ============ HELPERS ============
|
|
74
50
|
const { getManager } = useOrchestrator()
|
|
75
51
|
const manager = getManager('roles') as RolesManager | null
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
52
|
+
const provider = computed(() => manager?.rolesProvider ?? null)
|
|
53
|
+
|
|
54
|
+
const roles = computed<InheritableRole[]>(() =>
|
|
55
|
+
(provider.value?.getRoles() ?? []).map((name: string) => {
|
|
56
|
+
const meta = provider.value?.getRoleMeta?.(name)
|
|
57
|
+
return { name, label: provider.value?.getRole?.(name)?.label || meta?.label || name, description: meta?.description }
|
|
58
|
+
})
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const composition = computed<RoleComposition>({
|
|
62
|
+
get: () => {
|
|
63
|
+
const data = form.data.value as RoleFormData
|
|
64
|
+
return { inherits: data.inherits || [], permissions: data.permissions || [] }
|
|
65
|
+
},
|
|
66
|
+
set: (value: RoleComposition) => {
|
|
67
|
+
form.data.value.inherits = value.inherits
|
|
68
|
+
form.data.value.permissions = value.permissions
|
|
69
|
+
},
|
|
94
70
|
})
|
|
95
71
|
|
|
96
|
-
|
|
97
|
-
const roleInput = ref<string>('')
|
|
98
|
-
const roleSuggestions = ref<RoleOption[]>([])
|
|
99
|
-
|
|
100
|
-
function searchRoles(event: AutocompleteSearchEvent): void {
|
|
101
|
-
const query = (event.query || '').toLowerCase()
|
|
102
|
-
const selected = (form.data.value.inherits as string[]) || []
|
|
103
|
-
|
|
104
|
-
roleSuggestions.value = allRoles.value
|
|
105
|
-
.filter((r: RoleOption) => !selected.includes(r.name))
|
|
106
|
-
.filter((r: RoleOption) =>
|
|
107
|
-
query === '' ||
|
|
108
|
-
r.name.toLowerCase().includes(query) ||
|
|
109
|
-
r.label.toLowerCase().includes(query)
|
|
110
|
-
)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function onRoleSelect(event: AutocompleteSelectEvent): void {
|
|
114
|
-
const role = event.value
|
|
115
|
-
const inherits = (form.data.value.inherits as string[]) || []
|
|
116
|
-
if (role && !inherits.includes(role.name)) {
|
|
117
|
-
form.data.value.inherits = [...inherits, role.name]
|
|
118
|
-
}
|
|
119
|
-
roleInput.value = ''
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function removeRole(roleName: string): void {
|
|
123
|
-
const inherits = (form.data.value.inherits as string[]) || []
|
|
124
|
-
form.data.value.inherits = inherits.filter((r: string) => r !== roleName)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function getRoleLabel(roleName: string): string {
|
|
128
|
-
const role = allRoles.value.find((r: RoleOption) => r.name === roleName)
|
|
129
|
-
return role?.label || roleName
|
|
130
|
-
}
|
|
72
|
+
const inherited = computed(() => inheritedGrants(provider.value, composition.value.inherits))
|
|
131
73
|
</script>
|
|
132
74
|
|
|
133
75
|
<template>
|
|
@@ -156,56 +98,14 @@ function getRoleLabel(roleName: string): string {
|
|
|
156
98
|
/>
|
|
157
99
|
</div>
|
|
158
100
|
|
|
159
|
-
<!-- Inherits -->
|
|
160
|
-
<div class="form-field">
|
|
161
|
-
<label class="font-medium">Inherits From</label>
|
|
162
|
-
<div class="inherits-editor">
|
|
163
|
-
<div v-if="((form.data.value as RoleFormData).inherits || []).length > 0" class="inherits-chips">
|
|
164
|
-
<Chip
|
|
165
|
-
v-for="roleName in (form.data.value as RoleFormData).inherits"
|
|
166
|
-
:key="roleName"
|
|
167
|
-
removable
|
|
168
|
-
@remove="removeRole(roleName)"
|
|
169
|
-
class="inherits-chip"
|
|
170
|
-
>
|
|
171
|
-
<span class="chip-name">{{ roleName }}</span>
|
|
172
|
-
<span class="chip-label">({{ getRoleLabel(roleName) }})</span>
|
|
173
|
-
</Chip>
|
|
174
|
-
</div>
|
|
175
|
-
<AutoComplete
|
|
176
|
-
v-model="roleInput"
|
|
177
|
-
:suggestions="roleSuggestions"
|
|
178
|
-
optionLabel="display"
|
|
179
|
-
placeholder="Type role name..."
|
|
180
|
-
:minLength="0"
|
|
181
|
-
completeOnFocus
|
|
182
|
-
@complete="searchRoles"
|
|
183
|
-
@item-select="onRoleSelect"
|
|
184
|
-
dropdown
|
|
185
|
-
class="w-full"
|
|
186
|
-
>
|
|
187
|
-
<template #option="{ option }">
|
|
188
|
-
<div class="role-option">
|
|
189
|
-
<span class="role-name">{{ option.name }}</span>
|
|
190
|
-
<span class="role-label">{{ option.label }}</span>
|
|
191
|
-
</div>
|
|
192
|
-
</template>
|
|
193
|
-
</AutoComplete>
|
|
194
|
-
</div>
|
|
195
|
-
<small class="field-hint">
|
|
196
|
-
This role inherits all permissions from selected roles
|
|
197
|
-
</small>
|
|
198
|
-
</div>
|
|
199
|
-
|
|
200
|
-
<!-- Permissions -->
|
|
101
|
+
<!-- Inherits + permissions -->
|
|
201
102
|
<div class="form-field">
|
|
202
|
-
<label class="font-medium">
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
:
|
|
208
|
-
placeholder="Type namespace:action..."
|
|
103
|
+
<label class="font-medium">Permissions</label>
|
|
104
|
+
<RoleGrantsEditor
|
|
105
|
+
v-model="composition"
|
|
106
|
+
:roles="roles"
|
|
107
|
+
:inherited="inherited"
|
|
108
|
+
:self="(form.data.value as RoleFormData).name"
|
|
209
109
|
/>
|
|
210
110
|
</div>
|
|
211
111
|
</div>
|
|
@@ -224,53 +124,4 @@ function getRoleLabel(roleName: string): string {
|
|
|
224
124
|
flex-direction: column;
|
|
225
125
|
gap: 1.5rem;
|
|
226
126
|
}
|
|
227
|
-
|
|
228
|
-
/* Inherits editor - uses .editor-box pattern */
|
|
229
|
-
.inherits-editor {
|
|
230
|
-
border: 1px solid var(--p-surface-200);
|
|
231
|
-
border-radius: 0.5rem;
|
|
232
|
-
padding: 0.75rem;
|
|
233
|
-
background: var(--p-surface-50);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/* Inherits chips - uses .editor-chips pattern */
|
|
237
|
-
.inherits-chips {
|
|
238
|
-
display: flex;
|
|
239
|
-
flex-wrap: wrap;
|
|
240
|
-
gap: 0.5rem;
|
|
241
|
-
margin-bottom: 0.75rem;
|
|
242
|
-
padding-bottom: 0.75rem;
|
|
243
|
-
border-bottom: 1px solid var(--p-surface-200);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
.inherits-chip {
|
|
247
|
-
font-family: monospace;
|
|
248
|
-
font-size: 0.875rem;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
.chip-name {
|
|
252
|
-
font-weight: 500;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
.chip-label {
|
|
256
|
-
color: var(--p-surface-500);
|
|
257
|
-
margin-left: 0.25rem;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
.role-option {
|
|
261
|
-
display: flex;
|
|
262
|
-
justify-content: space-between;
|
|
263
|
-
align-items: center;
|
|
264
|
-
gap: 1rem;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
.role-name {
|
|
268
|
-
font-family: monospace;
|
|
269
|
-
font-weight: 500;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
.role-label {
|
|
273
|
-
color: var(--p-surface-500);
|
|
274
|
-
font-size: 0.875rem;
|
|
275
|
-
}
|
|
276
127
|
</style>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A role's composition, as RoleGrantsEditor draws it (#2313): which registered grants the role holds, and where
|
|
3
|
+
* each one comes from.
|
|
4
|
+
*
|
|
5
|
+
* Pure and judge-free. What inherited roles bring is given, never resolved here (the app's server may be the one
|
|
6
|
+
* that judges); wildcards are read with PermissionMatcher, `*` being one segment and `**` any number.
|
|
7
|
+
*/
|
|
8
|
+
import { PermissionMatcher } from './PermissionMatcher'
|
|
9
|
+
|
|
10
|
+
/** A registry entry, or anything shaped like one (an app may pass its server's list). */
|
|
11
|
+
export interface GrantDefinition {
|
|
12
|
+
key: string
|
|
13
|
+
namespace: string
|
|
14
|
+
action: string
|
|
15
|
+
label?: string
|
|
16
|
+
description?: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A grant an inherited role brings, and the role that declares it. */
|
|
20
|
+
export interface InheritedGrant {
|
|
21
|
+
permission: string
|
|
22
|
+
via: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What RoleGrantsEditor edits: the role's own composition, never what it inherits. */
|
|
26
|
+
export interface RoleComposition {
|
|
27
|
+
inherits: string[]
|
|
28
|
+
permissions: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InheritableRole {
|
|
32
|
+
name: string
|
|
33
|
+
label?: string
|
|
34
|
+
description?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type GrantOrigin =
|
|
38
|
+
| { kind: 'none' }
|
|
39
|
+
| { kind: 'own' }
|
|
40
|
+
/** Covered by a wildcard the role holds. */
|
|
41
|
+
| { kind: 'wildcard'; via: string }
|
|
42
|
+
/** Brought by an inherited role. */
|
|
43
|
+
| { kind: 'inherited'; via: string }
|
|
44
|
+
|
|
45
|
+
export interface GrantCell {
|
|
46
|
+
key: string
|
|
47
|
+
action: string
|
|
48
|
+
label: string
|
|
49
|
+
description: string | null
|
|
50
|
+
origin: GrantOrigin
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One entity of the matrix: `entity:books`, or its ownership grants `entity-own:books`. */
|
|
54
|
+
export interface GrantRow {
|
|
55
|
+
namespace: string
|
|
56
|
+
entity: string
|
|
57
|
+
ownRecords: boolean
|
|
58
|
+
/** `entity:books:*` */
|
|
59
|
+
wildcard: string
|
|
60
|
+
wildcardOrigin: GrantOrigin
|
|
61
|
+
/** By action; null where the entity registers no such grant. */
|
|
62
|
+
cells: Record<string, GrantCell | null>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Named grants of one namespace, as registered: `offers:debug:read` belongs to `offers:debug`, not `offers`. */
|
|
66
|
+
export interface GrantGroup {
|
|
67
|
+
namespace: string
|
|
68
|
+
wildcard: string
|
|
69
|
+
wildcardOrigin: GrantOrigin
|
|
70
|
+
grants: GrantCell[]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A key of the role that is neither a registered grant nor a row or group wildcard. */
|
|
74
|
+
export interface OtherGrant {
|
|
75
|
+
key: string
|
|
76
|
+
/** Registered grants it covers: 0 for a key the registry does not know. */
|
|
77
|
+
covers: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface GrantsModel {
|
|
81
|
+
actions: string[]
|
|
82
|
+
rows: GrantRow[]
|
|
83
|
+
groups: GrantGroup[]
|
|
84
|
+
others: OtherGrant[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const STANDARD_ACTIONS = ['read', 'list', 'create', 'update', 'delete']
|
|
88
|
+
|
|
89
|
+
const isWildcard = (key: string) => key.includes('*')
|
|
90
|
+
|
|
91
|
+
/** Where a grant comes from for the role: its own key first, then its wildcards, then what it inherits. */
|
|
92
|
+
export function originOf(
|
|
93
|
+
key: string,
|
|
94
|
+
permissions: readonly string[],
|
|
95
|
+
inherited: readonly InheritedGrant[] = []
|
|
96
|
+
): GrantOrigin {
|
|
97
|
+
if (permissions.includes(key)) return { kind: 'own' }
|
|
98
|
+
const wildcard = permissions.find((p) => isWildcard(p) && PermissionMatcher.matches(p, key))
|
|
99
|
+
if (wildcard) return { kind: 'wildcard', via: wildcard }
|
|
100
|
+
const brought = inherited.find((g) => PermissionMatcher.matches(g.permission, key))
|
|
101
|
+
if (brought) return { kind: 'inherited', via: brought.via }
|
|
102
|
+
return { kind: 'none' }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function composeGrants(input: {
|
|
106
|
+
grants: readonly GrantDefinition[]
|
|
107
|
+
permissions: readonly string[]
|
|
108
|
+
inherited?: readonly InheritedGrant[]
|
|
109
|
+
}): GrantsModel {
|
|
110
|
+
const { grants, permissions } = input
|
|
111
|
+
const inherited = input.inherited ?? []
|
|
112
|
+
const cellOf = (g: GrantDefinition): GrantCell => ({
|
|
113
|
+
key: g.key,
|
|
114
|
+
action: g.action,
|
|
115
|
+
label: g.label || g.key,
|
|
116
|
+
description: g.description ?? null,
|
|
117
|
+
origin: originOf(g.key, permissions, inherited),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const entityNamespaces = new Map<string, GrantDefinition[]>()
|
|
121
|
+
const namedNamespaces = new Map<string, GrantDefinition[]>()
|
|
122
|
+
for (const g of grants) {
|
|
123
|
+
const target = /^entity(-own)?:/.test(g.namespace) ? entityNamespaces : namedNamespaces
|
|
124
|
+
target.set(g.namespace, [...(target.get(g.namespace) ?? []), g])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const registeredActions = new Set<string>()
|
|
128
|
+
for (const list of entityNamespaces.values()) for (const g of list) registeredActions.add(g.action)
|
|
129
|
+
const actions = [
|
|
130
|
+
...STANDARD_ACTIONS.filter((a) => registeredActions.has(a)),
|
|
131
|
+
...[...registeredActions].filter((a) => !STANDARD_ACTIONS.includes(a)).sort(),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
const rows: GrantRow[] = [...entityNamespaces.entries()]
|
|
135
|
+
.map(([namespace, list]) => {
|
|
136
|
+
const cells: Record<string, GrantCell | null> = {}
|
|
137
|
+
for (const action of actions) {
|
|
138
|
+
const g = list.find((d) => d.action === action)
|
|
139
|
+
cells[action] = g ? cellOf(g) : null
|
|
140
|
+
}
|
|
141
|
+
const wildcard = `${namespace}:*`
|
|
142
|
+
return {
|
|
143
|
+
namespace,
|
|
144
|
+
entity: namespace.slice(namespace.indexOf(':') + 1),
|
|
145
|
+
ownRecords: namespace.startsWith('entity-own:'),
|
|
146
|
+
wildcard,
|
|
147
|
+
wildcardOrigin: originOf(wildcard, permissions, inherited),
|
|
148
|
+
cells,
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
.sort((a, b) => a.entity.localeCompare(b.entity) || Number(a.ownRecords) - Number(b.ownRecords))
|
|
152
|
+
|
|
153
|
+
const groups: GrantGroup[] = [...namedNamespaces.entries()]
|
|
154
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
155
|
+
.map(([namespace, list]) => ({
|
|
156
|
+
namespace,
|
|
157
|
+
wildcard: `${namespace}:*`,
|
|
158
|
+
wildcardOrigin: originOf(`${namespace}:*`, permissions, inherited),
|
|
159
|
+
grants: list.map(cellOf),
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
const registered = new Set(grants.map((g) => g.key))
|
|
163
|
+
const shown = new Set([...rows.map((r) => r.wildcard), ...groups.map((g) => g.wildcard)])
|
|
164
|
+
const keys = grants.map((g) => g.key)
|
|
165
|
+
const others = [...new Set(permissions)]
|
|
166
|
+
.filter((p) => !registered.has(p) && !shown.has(p))
|
|
167
|
+
.map((key) => ({ key, covers: isWildcard(key) ? PermissionMatcher.expand(key, keys).length : 0 }))
|
|
168
|
+
|
|
169
|
+
return { actions, rows, groups, others }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The role's own keys with one added or removed; the others keep their order. */
|
|
173
|
+
export function setGrant(permissions: readonly string[], key: string, on: boolean): string[] {
|
|
174
|
+
if (on) return permissions.includes(key) ? [...permissions] : [...permissions, key]
|
|
175
|
+
return permissions.filter((p) => p !== key)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** What inheritedGrants needs from a roles provider: qdadm's RoleProvider has it. */
|
|
179
|
+
export interface RoleHierarchySource {
|
|
180
|
+
getPermissions(role: string): string[]
|
|
181
|
+
getHierarchy(): Record<string, string[]>
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* What a role inherits, for apps whose front knows the roles (qdadm's own RoleProvider): each grant with the role
|
|
186
|
+
* that declares it, the nearest one first across a chain. An app whose server judges sends this list instead.
|
|
187
|
+
*/
|
|
188
|
+
export function inheritedGrants(
|
|
189
|
+
provider: RoleHierarchySource | null | undefined,
|
|
190
|
+
inherits: readonly string[]
|
|
191
|
+
): InheritedGrant[] {
|
|
192
|
+
if (!provider) return []
|
|
193
|
+
const hierarchy = provider.getHierarchy() ?? {}
|
|
194
|
+
const visited = new Set<string>()
|
|
195
|
+
const known = new Set<string>()
|
|
196
|
+
const out: InheritedGrant[] = []
|
|
197
|
+
const queue = [...inherits]
|
|
198
|
+
while (queue.length > 0) {
|
|
199
|
+
const role = queue.shift()!
|
|
200
|
+
if (visited.has(role)) continue
|
|
201
|
+
visited.add(role)
|
|
202
|
+
for (const permission of provider.getPermissions(role) ?? []) {
|
|
203
|
+
if (known.has(permission)) continue
|
|
204
|
+
known.add(permission)
|
|
205
|
+
out.push({ permission, via: role })
|
|
206
|
+
}
|
|
207
|
+
queue.push(...(hierarchy[role] ?? []))
|
|
208
|
+
}
|
|
209
|
+
return out
|
|
210
|
+
}
|