qdadm 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/composables/useAuth.js +2 -1
- package/src/debug/AuthCollector.js +104 -18
- 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/auth/CompositeAuthAdapter.js +212 -0
- package/src/entity/auth/factory.js +207 -0
- package/src/entity/auth/factory.test.js +257 -0
- package/src/entity/auth/index.js +14 -0
- package/src/entity/storage/MockApiStorage.js +34 -1
- package/src/kernel/Kernel.js +32 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompositeAuthAdapter - Routes to sub-adapters based on entity patterns
|
|
3
|
+
*
|
|
4
|
+
* Enables multi-source authentication where different entities may use
|
|
5
|
+
* different auth backends (internal JWT, external API keys, OAuth, etc.)
|
|
6
|
+
*
|
|
7
|
+
* Pattern matching:
|
|
8
|
+
* - Exact match: 'products' matches only 'products'
|
|
9
|
+
* - Prefix glob: 'external-*' matches 'external-products', 'external-orders'
|
|
10
|
+
* - Suffix glob: '*-readonly' matches 'products-readonly'
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```js
|
|
14
|
+
* const composite = new CompositeAuthAdapter({
|
|
15
|
+
* default: internalAuthAdapter, // JWT for most entities
|
|
16
|
+
* mapping: {
|
|
17
|
+
* 'products': apiKeyAdapter, // API key for products
|
|
18
|
+
* 'external-*': externalAuth, // OAuth for external-* entities
|
|
19
|
+
* 'readonly-*': readonlyAuth // Read-only adapter
|
|
20
|
+
* }
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* composite.canPerform('books', 'read') // → uses default (internal)
|
|
24
|
+
* composite.canPerform('products', 'read') // → uses apiKeyAdapter
|
|
25
|
+
* composite.canPerform('external-orders', 'read') // → uses externalAuth
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { AuthAdapter } from './AuthAdapter.js'
|
|
30
|
+
import { authFactory } from './factory.js'
|
|
31
|
+
|
|
32
|
+
export class CompositeAuthAdapter extends AuthAdapter {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} config - Composite auth configuration
|
|
35
|
+
* @param {AuthAdapter|string|object} config.default - Default adapter (required)
|
|
36
|
+
* @param {Object<string, AuthAdapter|string|object>} [config.mapping={}] - Entity pattern to adapter mapping
|
|
37
|
+
* @param {object} [context={}] - Factory context with authTypes
|
|
38
|
+
*/
|
|
39
|
+
constructor(config, context = {}) {
|
|
40
|
+
super()
|
|
41
|
+
|
|
42
|
+
const { default: defaultConfig, mapping = {} } = config
|
|
43
|
+
|
|
44
|
+
if (!defaultConfig) {
|
|
45
|
+
throw new Error('[CompositeAuthAdapter] "default" adapter is required')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve default adapter via factory
|
|
49
|
+
this._default = authFactory(defaultConfig, context)
|
|
50
|
+
|
|
51
|
+
// Resolve mapped adapters
|
|
52
|
+
this._exactMatches = new Map()
|
|
53
|
+
this._patterns = []
|
|
54
|
+
|
|
55
|
+
for (const [pattern, adapterConfig] of Object.entries(mapping)) {
|
|
56
|
+
const adapter = authFactory(adapterConfig, context)
|
|
57
|
+
|
|
58
|
+
if (pattern.includes('*')) {
|
|
59
|
+
// Glob pattern: convert to regex
|
|
60
|
+
const regexPattern = pattern
|
|
61
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
|
|
62
|
+
.replace(/\*/g, '.*') // * → .*
|
|
63
|
+
const regex = new RegExp(`^${regexPattern}$`)
|
|
64
|
+
this._patterns.push({ pattern, regex, adapter })
|
|
65
|
+
} else {
|
|
66
|
+
// Exact match
|
|
67
|
+
this._exactMatches.set(pattern, adapter)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the adapter for a specific entity
|
|
74
|
+
*
|
|
75
|
+
* Resolution order:
|
|
76
|
+
* 1. Exact match in mapping
|
|
77
|
+
* 2. First matching glob pattern
|
|
78
|
+
* 3. Default adapter
|
|
79
|
+
*
|
|
80
|
+
* @param {string} entity - Entity name
|
|
81
|
+
* @returns {AuthAdapter}
|
|
82
|
+
*/
|
|
83
|
+
_getAdapter(entity) {
|
|
84
|
+
// 1. Exact match
|
|
85
|
+
if (this._exactMatches.has(entity)) {
|
|
86
|
+
return this._exactMatches.get(entity)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Pattern match (first wins)
|
|
90
|
+
for (const { regex, adapter } of this._patterns) {
|
|
91
|
+
if (regex.test(entity)) {
|
|
92
|
+
return adapter
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3. Default
|
|
97
|
+
return this._default
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if user can perform action on entity type (scope check)
|
|
102
|
+
* Delegates to the appropriate adapter based on entity name
|
|
103
|
+
*
|
|
104
|
+
* @param {string} entity - Entity name
|
|
105
|
+
* @param {string} action - Action: read, create, update, delete, list
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
canPerform(entity, action) {
|
|
109
|
+
return this._getAdapter(entity).canPerform(entity, action)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if user can access specific record (silo check)
|
|
114
|
+
* Delegates to the appropriate adapter based on entity name
|
|
115
|
+
*
|
|
116
|
+
* @param {string} entity - Entity name
|
|
117
|
+
* @param {object} record - The record to check
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
canAccessRecord(entity, record) {
|
|
121
|
+
return this._getAdapter(entity).canAccessRecord(entity, record)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get current user from the default adapter
|
|
126
|
+
* The "user" concept comes from the primary auth source
|
|
127
|
+
*
|
|
128
|
+
* @returns {object|null}
|
|
129
|
+
*/
|
|
130
|
+
getCurrentUser() {
|
|
131
|
+
return this._default.getCurrentUser()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if user is granted an attribute (role or permission)
|
|
136
|
+
* Delegates to default adapter for global permissions
|
|
137
|
+
*
|
|
138
|
+
* @param {string} attribute - Role or permission to check
|
|
139
|
+
* @param {object} [subject] - Optional subject for context
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
isGranted(attribute, subject = null) {
|
|
143
|
+
// For entity-specific permissions (entity:action), route to appropriate adapter
|
|
144
|
+
if (attribute.includes(':') && !attribute.startsWith('ROLE_')) {
|
|
145
|
+
const [entity] = attribute.split(':')
|
|
146
|
+
const adapter = this._getAdapter(entity)
|
|
147
|
+
if (adapter.isGranted) {
|
|
148
|
+
return adapter.isGranted(attribute, subject)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Global permissions use default adapter
|
|
153
|
+
if (this._default.isGranted) {
|
|
154
|
+
return this._default.isGranted(attribute, subject)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Fallback for adapters without isGranted
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get the default adapter
|
|
163
|
+
* @returns {AuthAdapter}
|
|
164
|
+
*/
|
|
165
|
+
get defaultAdapter() {
|
|
166
|
+
return this._default
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get adapter info for debugging
|
|
171
|
+
* @returns {object}
|
|
172
|
+
*/
|
|
173
|
+
getAdapterInfo() {
|
|
174
|
+
const info = {
|
|
175
|
+
default: this._getAdapterName(this._default),
|
|
176
|
+
exactMatches: {},
|
|
177
|
+
patterns: []
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const [entity, adapter] of this._exactMatches) {
|
|
181
|
+
info.exactMatches[entity] = this._getAdapterName(adapter)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const { pattern, adapter } of this._patterns) {
|
|
185
|
+
info.patterns.push({
|
|
186
|
+
pattern,
|
|
187
|
+
adapter: this._getAdapterName(adapter)
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return info
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get adapter name for debugging
|
|
196
|
+
* @private
|
|
197
|
+
*/
|
|
198
|
+
_getAdapterName(adapter) {
|
|
199
|
+
return adapter.constructor?.name || 'Unknown'
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Factory function to create CompositeAuthAdapter
|
|
205
|
+
*
|
|
206
|
+
* @param {object} config - { default, mapping }
|
|
207
|
+
* @param {object} [context] - Factory context
|
|
208
|
+
* @returns {CompositeAuthAdapter}
|
|
209
|
+
*/
|
|
210
|
+
export function createCompositeAuthAdapter(config, context = {}) {
|
|
211
|
+
return new CompositeAuthAdapter(config, context)
|
|
212
|
+
}
|
|
@@ -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 { AuthAdapter } from './AuthAdapter.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 AuthAdapter>}
|
|
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 {AuthAdapter} 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 {AuthAdapter | string | object} config - Auth config
|
|
113
|
+
* @param {object} [context={}] - Context with authTypes, authResolver
|
|
114
|
+
* @returns {AuthAdapter} 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 AuthAdapter instance → return directly (backward compatible)
|
|
142
|
+
if (config instanceof AuthAdapter) {
|
|
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 { AuthAdapter } from './AuthAdapter.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 AuthAdapter {
|
|
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 AuthAdapter {
|
|
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 AuthAdapter 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,6 +3,10 @@
|
|
|
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
|
|
@@ -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'
|