qdadm 0.31.0 → 0.34.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 +2 -1
- package/src/auth/SessionAuthAdapter.js +254 -0
- package/src/auth/index.js +10 -0
- package/src/components/layout/AppLayout.vue +2 -0
- package/src/components/pages/LoginPage.vue +3 -2
- package/src/composables/useAuth.js +2 -1
- package/src/composables/useFormPageBuilder.js +7 -1
- package/src/composables/useListPageBuilder.js +7 -1
- package/src/debug/AuthCollector.js +104 -18
- package/src/debug/Collector.js +2 -1
- package/src/debug/EntitiesCollector.js +28 -1
- package/src/debug/components/panels/AuthPanel.vue +81 -10
- package/src/debug/components/panels/EntitiesPanel.vue +100 -4
- package/src/entity/EntityManager.js +1 -1
- package/src/entity/auth/CompositeAuthAdapter.js +212 -0
- package/src/entity/auth/{AuthAdapter.js → EntityAuthAdapter.js} +10 -37
- package/src/entity/auth/PermissiveAdapter.js +4 -6
- package/src/entity/auth/factory.js +207 -0
- package/src/entity/auth/factory.test.js +257 -0
- package/src/entity/auth/index.js +15 -1
- package/src/entity/storage/MockApiStorage.js +34 -1
- package/src/index.js +3 -0
- package/src/kernel/Kernel.js +50 -18
- package/src/kernel/KernelContext.js +142 -0
- package/src/orchestrator/Orchestrator.js +21 -1
- package/src/orchestrator/useOrchestrator.js +7 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Factory/Resolver Pattern
|
|
3
|
+
*
|
|
4
|
+
* Enables declarative auth adapter configuration with auto-resolution.
|
|
5
|
+
* Follows the same pattern as storage/factory.js for consistency.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Backward compatible - passing an AuthAdapter instance
|
|
8
|
+
* works exactly as before (simple passthrough).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* ```js
|
|
12
|
+
* // Instance passthrough (current behavior, unchanged)
|
|
13
|
+
* authFactory(myAdapter) // → myAdapter
|
|
14
|
+
*
|
|
15
|
+
* // String pattern → factory resolves from registry
|
|
16
|
+
* authFactory('permissive') // → PermissiveAuthAdapter
|
|
17
|
+
* authFactory('jwt:internal') // → JwtAuthAdapter (if registered)
|
|
18
|
+
*
|
|
19
|
+
* // Config object → factory resolves
|
|
20
|
+
* authFactory({ type: 'jwt', tokenKey: 'auth_token' })
|
|
21
|
+
*
|
|
22
|
+
* // Composite config → creates CompositeAuthAdapter
|
|
23
|
+
* authFactory({
|
|
24
|
+
* default: myAdapter,
|
|
25
|
+
* mapping: { 'external-*': externalAdapter }
|
|
26
|
+
* })
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { EntityAuthAdapter } from './EntityAuthAdapter.js'
|
|
31
|
+
import { PermissiveAuthAdapter } from './PermissiveAdapter.js'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Built-in auth adapter types
|
|
35
|
+
* Extended via context.authTypes for custom adapters
|
|
36
|
+
* @type {Record<string, typeof EntityAuthAdapter>}
|
|
37
|
+
*/
|
|
38
|
+
export const authTypes = {
|
|
39
|
+
permissive: PermissiveAuthAdapter
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse auth pattern 'type' or 'type:scope'
|
|
44
|
+
*
|
|
45
|
+
* @param {string} pattern - Auth pattern (e.g., 'jwt', 'apikey:external')
|
|
46
|
+
* @returns {{type: string, scope?: string} | null} Parsed config or null
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* parseAuthPattern('jwt') // → { type: 'jwt' }
|
|
50
|
+
* parseAuthPattern('apikey:external') // → { type: 'apikey', scope: 'external' }
|
|
51
|
+
*/
|
|
52
|
+
export function parseAuthPattern(pattern) {
|
|
53
|
+
if (typeof pattern !== 'string') return null
|
|
54
|
+
|
|
55
|
+
const match = pattern.match(/^(\w+)(?::(.+))?$/)
|
|
56
|
+
if (match) {
|
|
57
|
+
const [, type, scope] = match
|
|
58
|
+
return scope ? { type, scope } : { type }
|
|
59
|
+
}
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Default auth resolver - creates adapter instance from config
|
|
65
|
+
*
|
|
66
|
+
* @param {object} config - Normalized auth config with `type` property
|
|
67
|
+
* @param {object} context - Context with authTypes registry
|
|
68
|
+
* @returns {EntityAuthAdapter} Adapter instance
|
|
69
|
+
*/
|
|
70
|
+
export function defaultAuthResolver(config, context = {}) {
|
|
71
|
+
const { type, ...rest } = config
|
|
72
|
+
|
|
73
|
+
// Merge built-in types with custom types from context
|
|
74
|
+
const allTypes = { ...authTypes, ...context.authTypes }
|
|
75
|
+
const AdapterClass = allTypes[type]
|
|
76
|
+
|
|
77
|
+
if (!AdapterClass) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`[authFactory] Unknown auth type: "${type}". ` +
|
|
80
|
+
`Available: ${Object.keys(allTypes).join(', ')}`
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new AdapterClass(rest)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if config is a composite auth config
|
|
89
|
+
* Composite config has 'default' + optional 'mapping'
|
|
90
|
+
*
|
|
91
|
+
* @param {any} config
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
function isCompositeConfig(config) {
|
|
95
|
+
return (
|
|
96
|
+
config &&
|
|
97
|
+
typeof config === 'object' &&
|
|
98
|
+
'default' in config &&
|
|
99
|
+
!('type' in config)
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Auth factory - normalizes input and resolves to adapter
|
|
105
|
+
*
|
|
106
|
+
* Handles:
|
|
107
|
+
* - AuthAdapter instance → return directly (backward compatible)
|
|
108
|
+
* - String pattern 'type' → parse and resolve
|
|
109
|
+
* - Config object with 'type' → resolve via registry
|
|
110
|
+
* - Config object with 'default' → create CompositeAuthAdapter
|
|
111
|
+
*
|
|
112
|
+
* @param {EntityAuthAdapter | string | object} config - Auth config
|
|
113
|
+
* @param {object} [context={}] - Context with authTypes, authResolver
|
|
114
|
+
* @returns {EntityAuthAdapter} Adapter instance
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* // Instance passthrough (most common, backward compatible)
|
|
118
|
+
* authFactory(myAdapter) // → myAdapter
|
|
119
|
+
*
|
|
120
|
+
* // String patterns
|
|
121
|
+
* authFactory('permissive') // → PermissiveAuthAdapter
|
|
122
|
+
* authFactory('jwt') // → JwtAuthAdapter (if registered)
|
|
123
|
+
*
|
|
124
|
+
* // Config objects
|
|
125
|
+
* authFactory({ type: 'jwt', tokenKey: 'token' })
|
|
126
|
+
*
|
|
127
|
+
* // Composite (multi-source)
|
|
128
|
+
* authFactory({
|
|
129
|
+
* default: myAdapter,
|
|
130
|
+
* mapping: { 'products': apiKeyAdapter }
|
|
131
|
+
* })
|
|
132
|
+
*/
|
|
133
|
+
export function authFactory(config, context = {}) {
|
|
134
|
+
const { authResolver = defaultAuthResolver } = context
|
|
135
|
+
|
|
136
|
+
// Null/undefined → permissive (safe default)
|
|
137
|
+
if (config == null) {
|
|
138
|
+
return new PermissiveAuthAdapter()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Already an EntityAuthAdapter instance → return directly (backward compatible)
|
|
142
|
+
if (config instanceof EntityAuthAdapter) {
|
|
143
|
+
return config
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Duck-typed adapter (has canPerform method)
|
|
147
|
+
if (typeof config.canPerform === 'function') {
|
|
148
|
+
return config
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// String pattern → parse and resolve
|
|
152
|
+
if (typeof config === 'string') {
|
|
153
|
+
const parsed = parseAuthPattern(config)
|
|
154
|
+
if (!parsed) {
|
|
155
|
+
throw new Error(`[authFactory] Invalid auth pattern: "${config}"`)
|
|
156
|
+
}
|
|
157
|
+
return authResolver(parsed, context)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Config object
|
|
161
|
+
if (typeof config === 'object') {
|
|
162
|
+
// Composite config: { default: ..., mapping: { ... } }
|
|
163
|
+
// Handled separately to avoid circular dependency
|
|
164
|
+
// The CompositeAuthAdapter is resolved via authTypes registry
|
|
165
|
+
if (isCompositeConfig(config)) {
|
|
166
|
+
const CompositeClass = context.authTypes?.composite || context.CompositeAuthAdapter
|
|
167
|
+
if (!CompositeClass) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
'[authFactory] Composite config requires CompositeAuthAdapter. ' +
|
|
170
|
+
'Pass it via context.CompositeAuthAdapter or register as context.authTypes.composite'
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
return new CompositeClass(config, context)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Simple config with type: { type: 'jwt', ... }
|
|
177
|
+
if (config.type) {
|
|
178
|
+
return authResolver(config, context)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw new Error(
|
|
182
|
+
'[authFactory] Config object requires either "type" or "default" property'
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw new Error(
|
|
187
|
+
`[authFactory] Invalid auth config: ${typeof config}. ` +
|
|
188
|
+
'Expected AuthAdapter instance, string, or config object.'
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create a custom auth factory with bound context
|
|
194
|
+
*
|
|
195
|
+
* @param {object} context - Context with authTypes, authResolver
|
|
196
|
+
* @returns {function} Auth factory with bound context
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* const myAuthFactory = createAuthFactory({
|
|
200
|
+
* authTypes: { jwt: JwtAuthAdapter, apikey: ApiKeyAuthAdapter }
|
|
201
|
+
* })
|
|
202
|
+
*
|
|
203
|
+
* const adapter = myAuthFactory('jwt')
|
|
204
|
+
*/
|
|
205
|
+
export function createAuthFactory(context) {
|
|
206
|
+
return (config) => authFactory(config, context)
|
|
207
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for auth factory and CompositeAuthAdapter
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { EntityAuthAdapter } from './EntityAuthAdapter.js'
|
|
6
|
+
import { PermissiveAuthAdapter } from './PermissiveAdapter.js'
|
|
7
|
+
import { CompositeAuthAdapter } from './CompositeAuthAdapter.js'
|
|
8
|
+
import { authFactory, parseAuthPattern, authTypes } from './factory.js'
|
|
9
|
+
|
|
10
|
+
// Test adapter for custom types
|
|
11
|
+
class TestAuthAdapter extends EntityAuthAdapter {
|
|
12
|
+
constructor(options = {}) {
|
|
13
|
+
super()
|
|
14
|
+
this.options = options
|
|
15
|
+
}
|
|
16
|
+
canPerform() { return true }
|
|
17
|
+
canAccessRecord() { return true }
|
|
18
|
+
getCurrentUser() { return { id: 'test' } }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Adapter that tracks which entity was checked
|
|
22
|
+
class TrackingAdapter extends EntityAuthAdapter {
|
|
23
|
+
constructor(name) {
|
|
24
|
+
super()
|
|
25
|
+
this.name = name
|
|
26
|
+
this.checkedEntities = []
|
|
27
|
+
}
|
|
28
|
+
canPerform(entity, action) {
|
|
29
|
+
this.checkedEntities.push({ entity, action })
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
canAccessRecord() { return true }
|
|
33
|
+
getCurrentUser() { return { name: this.name } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('parseAuthPattern', () => {
|
|
37
|
+
it('parses simple type', () => {
|
|
38
|
+
expect(parseAuthPattern('jwt')).toEqual({ type: 'jwt' })
|
|
39
|
+
expect(parseAuthPattern('permissive')).toEqual({ type: 'permissive' })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('parses type with scope', () => {
|
|
43
|
+
expect(parseAuthPattern('jwt:internal')).toEqual({ type: 'jwt', scope: 'internal' })
|
|
44
|
+
expect(parseAuthPattern('apikey:external')).toEqual({ type: 'apikey', scope: 'external' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null for invalid input', () => {
|
|
48
|
+
expect(parseAuthPattern(null)).toBeNull()
|
|
49
|
+
expect(parseAuthPattern(123)).toBeNull()
|
|
50
|
+
expect(parseAuthPattern('')).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('authFactory', () => {
|
|
55
|
+
describe('instance passthrough', () => {
|
|
56
|
+
it('returns EntityAuthAdapter instance as-is', () => {
|
|
57
|
+
const adapter = new PermissiveAuthAdapter()
|
|
58
|
+
const result = authFactory(adapter)
|
|
59
|
+
expect(result).toBe(adapter)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns duck-typed adapter as-is', () => {
|
|
63
|
+
const duckAdapter = {
|
|
64
|
+
canPerform: () => true,
|
|
65
|
+
canAccessRecord: () => true,
|
|
66
|
+
getCurrentUser: () => null
|
|
67
|
+
}
|
|
68
|
+
const result = authFactory(duckAdapter)
|
|
69
|
+
expect(result).toBe(duckAdapter)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('null/undefined', () => {
|
|
74
|
+
it('returns PermissiveAuthAdapter for null', () => {
|
|
75
|
+
const result = authFactory(null)
|
|
76
|
+
expect(result).toBeInstanceOf(PermissiveAuthAdapter)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns PermissiveAuthAdapter for undefined', () => {
|
|
80
|
+
const result = authFactory(undefined)
|
|
81
|
+
expect(result).toBeInstanceOf(PermissiveAuthAdapter)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('string patterns', () => {
|
|
86
|
+
it('resolves built-in types', () => {
|
|
87
|
+
const result = authFactory('permissive')
|
|
88
|
+
expect(result).toBeInstanceOf(PermissiveAuthAdapter)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('resolves custom types from context', () => {
|
|
92
|
+
const result = authFactory('test', {
|
|
93
|
+
authTypes: { test: TestAuthAdapter }
|
|
94
|
+
})
|
|
95
|
+
expect(result).toBeInstanceOf(TestAuthAdapter)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('throws for unknown type', () => {
|
|
99
|
+
expect(() => authFactory('unknown')).toThrow(/Unknown auth type/)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('config objects', () => {
|
|
104
|
+
it('resolves config with type', () => {
|
|
105
|
+
const result = authFactory(
|
|
106
|
+
{ type: 'test', foo: 'bar' },
|
|
107
|
+
{ authTypes: { test: TestAuthAdapter } }
|
|
108
|
+
)
|
|
109
|
+
expect(result).toBeInstanceOf(TestAuthAdapter)
|
|
110
|
+
expect(result.options.foo).toBe('bar')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('throws for config without type or default', () => {
|
|
114
|
+
expect(() => authFactory({ foo: 'bar' })).toThrow(/requires either "type" or "default"/)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('composite config', () => {
|
|
119
|
+
it('creates CompositeAuthAdapter from config', () => {
|
|
120
|
+
const defaultAdapter = new PermissiveAuthAdapter()
|
|
121
|
+
const result = authFactory(
|
|
122
|
+
{ default: defaultAdapter },
|
|
123
|
+
{ CompositeAuthAdapter }
|
|
124
|
+
)
|
|
125
|
+
expect(result).toBeInstanceOf(CompositeAuthAdapter)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('throws without CompositeAuthAdapter in context', () => {
|
|
129
|
+
const defaultAdapter = new PermissiveAuthAdapter()
|
|
130
|
+
expect(() => authFactory({ default: defaultAdapter })).toThrow(/CompositeAuthAdapter/)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('CompositeAuthAdapter', () => {
|
|
136
|
+
describe('routing', () => {
|
|
137
|
+
it('uses default adapter for unmatched entities', () => {
|
|
138
|
+
const defaultAdapter = new TrackingAdapter('default')
|
|
139
|
+
const composite = new CompositeAuthAdapter({ default: defaultAdapter })
|
|
140
|
+
|
|
141
|
+
composite.canPerform('books', 'read')
|
|
142
|
+
composite.canPerform('users', 'create')
|
|
143
|
+
|
|
144
|
+
expect(defaultAdapter.checkedEntities).toEqual([
|
|
145
|
+
{ entity: 'books', action: 'read' },
|
|
146
|
+
{ entity: 'users', action: 'create' }
|
|
147
|
+
])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('routes exact matches to mapped adapter', () => {
|
|
151
|
+
const defaultAdapter = new TrackingAdapter('default')
|
|
152
|
+
const productsAdapter = new TrackingAdapter('products')
|
|
153
|
+
|
|
154
|
+
const composite = new CompositeAuthAdapter({
|
|
155
|
+
default: defaultAdapter,
|
|
156
|
+
mapping: { products: productsAdapter }
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
composite.canPerform('books', 'read')
|
|
160
|
+
composite.canPerform('products', 'read')
|
|
161
|
+
|
|
162
|
+
expect(defaultAdapter.checkedEntities).toHaveLength(1)
|
|
163
|
+
expect(defaultAdapter.checkedEntities[0].entity).toBe('books')
|
|
164
|
+
|
|
165
|
+
expect(productsAdapter.checkedEntities).toHaveLength(1)
|
|
166
|
+
expect(productsAdapter.checkedEntities[0].entity).toBe('products')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('routes glob patterns to mapped adapter', () => {
|
|
170
|
+
const defaultAdapter = new TrackingAdapter('default')
|
|
171
|
+
const externalAdapter = new TrackingAdapter('external')
|
|
172
|
+
|
|
173
|
+
const composite = new CompositeAuthAdapter({
|
|
174
|
+
default: defaultAdapter,
|
|
175
|
+
mapping: { 'external-*': externalAdapter }
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
composite.canPerform('books', 'read')
|
|
179
|
+
composite.canPerform('external-products', 'read')
|
|
180
|
+
composite.canPerform('external-orders', 'list')
|
|
181
|
+
|
|
182
|
+
expect(defaultAdapter.checkedEntities).toHaveLength(1)
|
|
183
|
+
expect(externalAdapter.checkedEntities).toHaveLength(2)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('supports suffix glob patterns', () => {
|
|
187
|
+
const defaultAdapter = new TrackingAdapter('default')
|
|
188
|
+
const readonlyAdapter = new TrackingAdapter('readonly')
|
|
189
|
+
|
|
190
|
+
const composite = new CompositeAuthAdapter({
|
|
191
|
+
default: defaultAdapter,
|
|
192
|
+
mapping: { '*-readonly': readonlyAdapter }
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
composite.canPerform('products-readonly', 'read')
|
|
196
|
+
composite.canPerform('products', 'read')
|
|
197
|
+
|
|
198
|
+
expect(readonlyAdapter.checkedEntities).toHaveLength(1)
|
|
199
|
+
expect(defaultAdapter.checkedEntities).toHaveLength(1)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('getCurrentUser', () => {
|
|
204
|
+
it('returns user from default adapter', () => {
|
|
205
|
+
const defaultAdapter = new TrackingAdapter('admin')
|
|
206
|
+
const externalAdapter = new TrackingAdapter('service')
|
|
207
|
+
|
|
208
|
+
const composite = new CompositeAuthAdapter({
|
|
209
|
+
default: defaultAdapter,
|
|
210
|
+
mapping: { 'external-*': externalAdapter }
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
expect(composite.getCurrentUser()).toEqual({ name: 'admin' })
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe('factory integration', () => {
|
|
218
|
+
it('resolves adapters in mapping via factory', () => {
|
|
219
|
+
const defaultAdapter = new PermissiveAuthAdapter()
|
|
220
|
+
|
|
221
|
+
const composite = new CompositeAuthAdapter(
|
|
222
|
+
{
|
|
223
|
+
default: defaultAdapter,
|
|
224
|
+
mapping: {
|
|
225
|
+
products: { type: 'test' }
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
{ authTypes: { test: TestAuthAdapter } }
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
// Products should use TestAuthAdapter
|
|
232
|
+
expect(composite._getAdapter('products')).toBeInstanceOf(TestAuthAdapter)
|
|
233
|
+
// Others use default
|
|
234
|
+
expect(composite._getAdapter('books')).toBe(defaultAdapter)
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('getAdapterInfo', () => {
|
|
239
|
+
it('returns debug info about adapters', () => {
|
|
240
|
+
const composite = new CompositeAuthAdapter({
|
|
241
|
+
default: new PermissiveAuthAdapter(),
|
|
242
|
+
mapping: {
|
|
243
|
+
products: new TestAuthAdapter(),
|
|
244
|
+
'external-*': new TrackingAdapter('ext')
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
const info = composite.getAdapterInfo()
|
|
249
|
+
|
|
250
|
+
expect(info.default).toBe('PermissiveAuthAdapter')
|
|
251
|
+
expect(info.exactMatches.products).toBe('TestAuthAdapter')
|
|
252
|
+
expect(info.patterns).toHaveLength(1)
|
|
253
|
+
expect(info.patterns[0].pattern).toBe('external-*')
|
|
254
|
+
expect(info.patterns[0].adapter).toBe('TrackingAdapter')
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
})
|
package/src/entity/auth/index.js
CHANGED
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
*
|
|
4
4
|
* AuthAdapter interface and implementations for scope/silo permission checks.
|
|
5
5
|
* Includes Symfony-inspired role hierarchy and security checker.
|
|
6
|
+
*
|
|
7
|
+
* Multi-source auth:
|
|
8
|
+
* - CompositeAuthAdapter routes to different adapters based on entity patterns
|
|
9
|
+
* - authFactory normalizes config (instance, string, or object)
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
// Interface
|
|
9
|
-
export {
|
|
13
|
+
export { EntityAuthAdapter, AuthActions } from './EntityAuthAdapter.js'
|
|
10
14
|
|
|
11
15
|
// Role Hierarchy (topological resolution)
|
|
12
16
|
export { RoleHierarchy, createRoleHierarchy } from './RoleHierarchy.js'
|
|
@@ -16,3 +20,13 @@ export { SecurityChecker, createSecurityChecker } from './SecurityChecker.js'
|
|
|
16
20
|
|
|
17
21
|
// Implementations
|
|
18
22
|
export { PermissiveAuthAdapter, createPermissiveAdapter } from './PermissiveAdapter.js'
|
|
23
|
+
export { CompositeAuthAdapter, createCompositeAuthAdapter } from './CompositeAuthAdapter.js'
|
|
24
|
+
|
|
25
|
+
// Factory/Resolver pattern (like storage/factory.js)
|
|
26
|
+
export {
|
|
27
|
+
authFactory,
|
|
28
|
+
createAuthFactory,
|
|
29
|
+
authTypes,
|
|
30
|
+
parseAuthPattern,
|
|
31
|
+
defaultAuthResolver
|
|
32
|
+
} from './factory.js'
|
|
@@ -65,13 +65,26 @@ export class MockApiStorage extends IStorage {
|
|
|
65
65
|
return MockApiStorage.capabilities.supportsCaching
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Instance capabilities getter.
|
|
70
|
+
* Merges static capabilities with instance-specific ones like requiresAuth.
|
|
71
|
+
* @returns {object}
|
|
72
|
+
*/
|
|
73
|
+
get capabilities() {
|
|
74
|
+
return {
|
|
75
|
+
...MockApiStorage.capabilities,
|
|
76
|
+
requiresAuth: !!this._authCheck
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
68
80
|
constructor(options = {}) {
|
|
69
81
|
super()
|
|
70
82
|
const {
|
|
71
83
|
entityName,
|
|
72
84
|
idField = 'id',
|
|
73
85
|
generateId = () => Date.now().toString(36) + Math.random().toString(36).substr(2),
|
|
74
|
-
initialData = null
|
|
86
|
+
initialData = null,
|
|
87
|
+
authCheck = null // Optional: () => boolean - throws 401 if returns false
|
|
75
88
|
} = options
|
|
76
89
|
|
|
77
90
|
if (!entityName) {
|
|
@@ -83,11 +96,24 @@ export class MockApiStorage extends IStorage {
|
|
|
83
96
|
this.generateId = generateId
|
|
84
97
|
this._storageKey = `mockapi_${entityName}_data`
|
|
85
98
|
this._data = new Map()
|
|
99
|
+
this._authCheck = authCheck
|
|
86
100
|
|
|
87
101
|
// Load from localStorage or seed with initialData
|
|
88
102
|
this._loadFromStorage(initialData)
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Check authentication if authCheck is configured
|
|
107
|
+
* @throws {Error} 401 error if not authenticated
|
|
108
|
+
*/
|
|
109
|
+
_checkAuth() {
|
|
110
|
+
if (this._authCheck && !this._authCheck()) {
|
|
111
|
+
const error = new Error(`Unauthorized: Authentication required to access ${this.entityName}`)
|
|
112
|
+
error.status = 401
|
|
113
|
+
throw error
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
/**
|
|
92
118
|
* Load data from localStorage, seed with initialData if empty
|
|
93
119
|
* @param {Array|null} initialData - Data to seed if storage is empty
|
|
@@ -158,6 +184,7 @@ export class MockApiStorage extends IStorage {
|
|
|
158
184
|
* @returns {Promise<{ items: Array, total: number }>}
|
|
159
185
|
*/
|
|
160
186
|
async list(params = {}) {
|
|
187
|
+
this._checkAuth()
|
|
161
188
|
const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
|
|
162
189
|
|
|
163
190
|
let items = this._getAll()
|
|
@@ -213,6 +240,7 @@ export class MockApiStorage extends IStorage {
|
|
|
213
240
|
* @returns {Promise<object>}
|
|
214
241
|
*/
|
|
215
242
|
async get(id) {
|
|
243
|
+
this._checkAuth()
|
|
216
244
|
const item = this._data.get(String(id))
|
|
217
245
|
if (!item) {
|
|
218
246
|
const error = new Error(`Entity not found: ${id}`)
|
|
@@ -228,6 +256,7 @@ export class MockApiStorage extends IStorage {
|
|
|
228
256
|
* @returns {Promise<Array<object>>}
|
|
229
257
|
*/
|
|
230
258
|
async getMany(ids) {
|
|
259
|
+
this._checkAuth()
|
|
231
260
|
if (!ids || ids.length === 0) return []
|
|
232
261
|
const results = []
|
|
233
262
|
for (const id of ids) {
|
|
@@ -282,6 +311,7 @@ export class MockApiStorage extends IStorage {
|
|
|
282
311
|
* @returns {Promise<object>}
|
|
283
312
|
*/
|
|
284
313
|
async create(data) {
|
|
314
|
+
this._checkAuth()
|
|
285
315
|
const id = data[this.idField] || this.generateId()
|
|
286
316
|
const newItem = {
|
|
287
317
|
...data,
|
|
@@ -300,6 +330,7 @@ export class MockApiStorage extends IStorage {
|
|
|
300
330
|
* @returns {Promise<object>}
|
|
301
331
|
*/
|
|
302
332
|
async update(id, data) {
|
|
333
|
+
this._checkAuth()
|
|
303
334
|
if (!this._data.has(String(id))) {
|
|
304
335
|
const error = new Error(`Entity not found: ${id}`)
|
|
305
336
|
error.status = 404
|
|
@@ -322,6 +353,7 @@ export class MockApiStorage extends IStorage {
|
|
|
322
353
|
* @returns {Promise<object>}
|
|
323
354
|
*/
|
|
324
355
|
async patch(id, data) {
|
|
356
|
+
this._checkAuth()
|
|
325
357
|
const existing = this._data.get(String(id))
|
|
326
358
|
if (!existing) {
|
|
327
359
|
const error = new Error(`Entity not found: ${id}`)
|
|
@@ -344,6 +376,7 @@ export class MockApiStorage extends IStorage {
|
|
|
344
376
|
* @returns {Promise<void>}
|
|
345
377
|
*/
|
|
346
378
|
async delete(id) {
|
|
379
|
+
this._checkAuth()
|
|
347
380
|
if (!this._data.has(String(id))) {
|
|
348
381
|
const error = new Error(`Entity not found: ${id}`)
|
|
349
382
|
error.status = 404
|
package/src/index.js
CHANGED