serverless-plugin-module-registry 1.0.9 → 1.0.10
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/service.js +1178 -0
- package/package.json +3 -1
- package/src/service.d.ts +168 -0
- package/src/service.js +1178 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Module Registry Service Functions
|
|
3
|
+
* -------------------------------------------
|
|
4
|
+
* Service layer for querying module registry data from DynamoDB.
|
|
5
|
+
* These functions can be imported directly from the plugin package.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { listAllModules, getModuleFeatures } from 'serverless-plugin-module-registry'
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { DynamoDBClient, QueryCommand, ScanCommand, PutItemCommand, UpdateItemCommand, DeleteItemCommand } from '@aws-sdk/client-dynamodb'
|
|
12
|
+
import { unmarshall, marshall } from '@aws-sdk/util-dynamodb'
|
|
13
|
+
import { nanoid } from 'nanoid'
|
|
14
|
+
|
|
15
|
+
// Self-contained feature ID utilities (to avoid import issues in generated service)
|
|
16
|
+
const generateFeatureId = () => nanoid(8)
|
|
17
|
+
const validateFeatureId = (featureId) => {
|
|
18
|
+
if (featureId.length !== 8) return false
|
|
19
|
+
if (!/^[A-Za-z0-9_-]+$/.test(featureId)) return false
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TABLE_NAME = 'placeholder-table-name'
|
|
24
|
+
const GSI1_NAME = 'GSI1'
|
|
25
|
+
|
|
26
|
+
// Validate table name at runtime
|
|
27
|
+
if (TABLE_NAME === 'placeholder-table-name') {
|
|
28
|
+
console.warn('[module-registry] Warning: TABLE_NAME not properly injected during build. Service functions may fail at runtime.')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Client configuration with retry logic and connection pooling
|
|
32
|
+
const client = new DynamoDBClient({
|
|
33
|
+
maxAttempts: 3,
|
|
34
|
+
retryMode: 'adaptive',
|
|
35
|
+
// Enable connection pooling for better performance
|
|
36
|
+
maxSockets: 50,
|
|
37
|
+
requestHandler: {
|
|
38
|
+
requestTimeout: 30000, // 30 seconds
|
|
39
|
+
httpsAgent: {
|
|
40
|
+
keepAlive: true,
|
|
41
|
+
maxSockets: 50
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Simple in-memory cache for frequently accessed data
|
|
47
|
+
const cache = new Map()
|
|
48
|
+
const CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Simple caching utility
|
|
52
|
+
*/
|
|
53
|
+
const withCache = (key, ttl = CACHE_TTL) => {
|
|
54
|
+
return {
|
|
55
|
+
get: () => {
|
|
56
|
+
const cached = cache.get(key)
|
|
57
|
+
if (cached && Date.now() < cached.expires) {
|
|
58
|
+
return cached.value
|
|
59
|
+
}
|
|
60
|
+
cache.delete(key)
|
|
61
|
+
return null
|
|
62
|
+
},
|
|
63
|
+
set: (value) => {
|
|
64
|
+
cache.set(key, {
|
|
65
|
+
value,
|
|
66
|
+
expires: Date.now() + ttl
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a structured logger for module registry operations
|
|
74
|
+
* @param {string} context - The operation context (e.g., 'listAllModules')
|
|
75
|
+
* @returns {Object} Logger with info, warn, error methods
|
|
76
|
+
*/
|
|
77
|
+
export const createModuleRegistryLogger = (context) => {
|
|
78
|
+
const prefix = `[module-registry:${context}]`
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
info: (message, data) => console.log(prefix, message, data ? JSON.stringify(data) : ''),
|
|
82
|
+
warn: (message, data) => console.warn(prefix, message, data ? JSON.stringify(data) : ''),
|
|
83
|
+
error: (message, error) => console.error(prefix, message, error?.message || error)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sanitize feature name to only allow lowercase letters, numbers, and hyphens [a-z0-9-]
|
|
89
|
+
* Converts underscores and spaces to hyphens
|
|
90
|
+
* @param {string} featureName - The feature name to sanitize
|
|
91
|
+
* @returns {string} - Sanitized feature name
|
|
92
|
+
* @throws {Error} - If feature name is invalid after sanitization
|
|
93
|
+
*/
|
|
94
|
+
const sanitizeFeatureName = (featureName) => {
|
|
95
|
+
if (!featureName || typeof featureName !== 'string') {
|
|
96
|
+
throw new Error('Feature name is required and must be a string')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Convert to lowercase, replace underscores and spaces with hyphens, then keep only [a-z0-9-] characters
|
|
100
|
+
const sanitized = featureName.toLowerCase()
|
|
101
|
+
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
|
|
102
|
+
.replace(/[^a-z0-9-]/g, '') // Keep only letters, numbers, and hyphens
|
|
103
|
+
.replace(/--+/g, '-') // Replace multiple consecutive hyphens with single hyphen
|
|
104
|
+
.replace(/^-+|-+$/g, '') // Remove leading and trailing hyphens
|
|
105
|
+
|
|
106
|
+
if (sanitized.length === 0) {
|
|
107
|
+
throw new Error('Feature name must contain at least one alphanumeric character [a-z0-9-]')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return sanitized
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validates and sanitizes input parameters
|
|
115
|
+
* @param {Object} params - Parameters to validate
|
|
116
|
+
* @param {Array} required - Required parameter names
|
|
117
|
+
* @throws {Error} If validation fails
|
|
118
|
+
*/
|
|
119
|
+
const validateParams = (params, required) => {
|
|
120
|
+
for (const param of required) {
|
|
121
|
+
if (!params[param] || (typeof params[param] === 'string' && params[param].trim() === '')) {
|
|
122
|
+
throw new Error(`${param} is required and cannot be empty`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Sanitize string parameters to prevent injection
|
|
127
|
+
const sanitized = {}
|
|
128
|
+
for (const [key, value] of Object.entries(params)) {
|
|
129
|
+
if (typeof value === 'string') {
|
|
130
|
+
// Basic sanitization - remove potentially dangerous characters
|
|
131
|
+
let sanitizedValue = value.replace(/[<>\"']/g, '').trim()
|
|
132
|
+
|
|
133
|
+
// Apply feature name sanitization for featureName parameters
|
|
134
|
+
if (key === 'featureName') {
|
|
135
|
+
sanitizedValue = sanitizeFeatureName(sanitizedValue)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
sanitized[key] = sanitizedValue
|
|
139
|
+
} else {
|
|
140
|
+
sanitized[key] = value
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return sanitized
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Wraps DynamoDB operations with error handling and retry logic
|
|
149
|
+
* @param {Function} operation - The DynamoDB operation to execute
|
|
150
|
+
* @param {string} operationName - Name for logging
|
|
151
|
+
* @returns {Promise} Operation result
|
|
152
|
+
*/
|
|
153
|
+
const withErrorHandling = async (operation, operationName) => {
|
|
154
|
+
const logger = createModuleRegistryLogger(operationName)
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const startTime = Date.now()
|
|
158
|
+
const result = await operation()
|
|
159
|
+
const duration = Date.now() - startTime
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
} catch (error) {
|
|
163
|
+
logger.error(`Operation failed`, error)
|
|
164
|
+
|
|
165
|
+
// Provide user-friendly error messages
|
|
166
|
+
if (error.name === 'ResourceNotFoundException') {
|
|
167
|
+
throw new Error(`Module registry table not found. Please ensure the plugin is properly deployed.`)
|
|
168
|
+
} else if (error.name === 'ValidationException') {
|
|
169
|
+
throw new Error(`Invalid request parameters: ${error.message}`)
|
|
170
|
+
} else if (error.name === 'ProvisionedThroughputExceededException') {
|
|
171
|
+
throw new Error(`Registry service is temporarily overloaded. Please try again in a moment.`)
|
|
172
|
+
} else if (error.name === 'ThrottlingException') {
|
|
173
|
+
throw new Error(`Request rate exceeded. Please try again in a moment.`)
|
|
174
|
+
} else {
|
|
175
|
+
throw new Error(`Registry service error: ${error.message}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Lists all deployed modules with basic metadata
|
|
181
|
+
* @returns {Promise<Array>} Array of module objects with clean data (no DDB internals)
|
|
182
|
+
*/
|
|
183
|
+
export const listAllModules = async () => {
|
|
184
|
+
return withErrorHandling(async () => {
|
|
185
|
+
// Check cache first
|
|
186
|
+
const cacheKey = 'listAllModules'
|
|
187
|
+
const cached = withCache(cacheKey)
|
|
188
|
+
const cachedResult = cached.get()
|
|
189
|
+
if (cachedResult) {
|
|
190
|
+
return cachedResult
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const command = new QueryCommand({
|
|
194
|
+
TableName: TABLE_NAME,
|
|
195
|
+
IndexName: GSI1_NAME,
|
|
196
|
+
KeyConditionExpression: 'gsi1pk = :pk',
|
|
197
|
+
FilterExpression: 'itemType = :itemType',
|
|
198
|
+
ExpressionAttributeValues: {
|
|
199
|
+
':pk': { S: 'MODULES' },
|
|
200
|
+
':itemType': { S: 'module' }
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const response = await client.send(command)
|
|
205
|
+
|
|
206
|
+
if (!response.Items) {
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Transform DDB items to clean module objects
|
|
211
|
+
const modules = response.Items.map(item => {
|
|
212
|
+
const data = unmarshall(item)
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
moduleName: data.moduleName,
|
|
216
|
+
description: data.description,
|
|
217
|
+
version: data.version,
|
|
218
|
+
maintainer: data.maintainer,
|
|
219
|
+
tags: data.tags || [],
|
|
220
|
+
apiGatewayId: data.apiGatewayId,
|
|
221
|
+
lastUpdated: data.lastUpdated
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Cache the result
|
|
226
|
+
cached.set(modules)
|
|
227
|
+
|
|
228
|
+
return modules
|
|
229
|
+
}, 'listAllModules')
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Gets all features for a specific module
|
|
234
|
+
* @param {string} moduleName - The module name (e.g., 'sign')
|
|
235
|
+
* @returns {Promise<Array>} Array of feature objects with endpoints
|
|
236
|
+
*/
|
|
237
|
+
export const getModuleFeatures = async (moduleName) => {
|
|
238
|
+
const params = validateParams({ moduleName }, ['moduleName'])
|
|
239
|
+
|
|
240
|
+
return withErrorHandling(async () => {
|
|
241
|
+
const command = new QueryCommand({
|
|
242
|
+
TableName: TABLE_NAME,
|
|
243
|
+
KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
|
|
244
|
+
FilterExpression: 'itemType = :itemType',
|
|
245
|
+
ExpressionAttributeValues: {
|
|
246
|
+
':pk': { S: `MODULE#${params.moduleName}` },
|
|
247
|
+
':skPrefix': { S: 'FEATURE#' },
|
|
248
|
+
':itemType': { S: 'feature' }
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const response = await client.send(command)
|
|
253
|
+
|
|
254
|
+
if (!response.Items) {
|
|
255
|
+
return []
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Transform DDB items to clean feature objects
|
|
259
|
+
const features = response.Items.map(item => {
|
|
260
|
+
const data = unmarshall(item)
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
featureName: data.featureName,
|
|
264
|
+
featureId: data.featureId, // Include featureId for ABAC tag generation
|
|
265
|
+
description: data.description,
|
|
266
|
+
version: data.version,
|
|
267
|
+
endpoints: data.endpoints || [],
|
|
268
|
+
customPolicies: data.customPolicies || [],
|
|
269
|
+
endpointCount: (data.endpoints || []).length,
|
|
270
|
+
lastUpdated: data.lastUpdated
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
return features
|
|
275
|
+
}, 'getModuleFeatures')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Gets detailed information about a specific feature
|
|
280
|
+
* @param {string} moduleName - The module name
|
|
281
|
+
* @param {string} featureId - The feature ID (featureId is KING!)
|
|
282
|
+
* @returns {Promise<Object|null>} Feature details with endpoints and policies
|
|
283
|
+
*/
|
|
284
|
+
export const getFeatureDetails = async (moduleName, featureId) => {
|
|
285
|
+
const params = validateParams({ moduleName, featureId }, ['moduleName', 'featureId'])
|
|
286
|
+
|
|
287
|
+
return withErrorHandling(async () => {
|
|
288
|
+
const command = new QueryCommand({
|
|
289
|
+
TableName: TABLE_NAME,
|
|
290
|
+
KeyConditionExpression: 'pk = :pk AND sk = :sk',
|
|
291
|
+
ExpressionAttributeValues: {
|
|
292
|
+
':pk': { S: `MODULE#${params.moduleName}` },
|
|
293
|
+
':sk': { S: `FEATURE#${params.featureId}` }
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
const response = await client.send(command)
|
|
298
|
+
|
|
299
|
+
if (!response.Items || response.Items.length === 0) {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Transform DDB item to clean feature object
|
|
304
|
+
return unmarshall(response.Items[0])
|
|
305
|
+
}, 'getFeatureDetails')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Gets module metadata (basic info without features)
|
|
311
|
+
* @param {string} moduleName - The module name
|
|
312
|
+
* @returns {Promise<Object|null>} Module metadata or null if not found
|
|
313
|
+
*/
|
|
314
|
+
export const getModuleMetadata = async (moduleName) => {
|
|
315
|
+
const params = validateParams({ moduleName }, ['moduleName'])
|
|
316
|
+
|
|
317
|
+
return withErrorHandling(async () => {
|
|
318
|
+
const command = new QueryCommand({
|
|
319
|
+
TableName: TABLE_NAME,
|
|
320
|
+
KeyConditionExpression: 'pk = :pk AND sk = :sk',
|
|
321
|
+
ExpressionAttributeValues: {
|
|
322
|
+
':pk': { S: `MODULE#${params.moduleName}` },
|
|
323
|
+
':sk': { S: 'MODULE' }
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const response = await client.send(command)
|
|
328
|
+
|
|
329
|
+
if (!response.Items || response.Items.length === 0) {
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Transform DDB item to clean module object
|
|
334
|
+
const data = unmarshall(response.Items[0])
|
|
335
|
+
|
|
336
|
+
const moduleMetadata = {
|
|
337
|
+
moduleName: data.moduleName,
|
|
338
|
+
description: data.description,
|
|
339
|
+
version: data.version,
|
|
340
|
+
maintainer: data.maintainer,
|
|
341
|
+
tags: data.tags || [],
|
|
342
|
+
apiGatewayId: data.apiGatewayId,
|
|
343
|
+
lastUpdated: data.lastUpdated
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return moduleMetadata
|
|
347
|
+
}, 'getModuleMetadata')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Gets feature details by featureId only (without knowing module name)
|
|
352
|
+
* Uses GSI2 to lookup feature directly by featureId
|
|
353
|
+
* @param {string} featureId - The unique feature identifier
|
|
354
|
+
* @returns {Promise<Object|null>} Feature details or null if not found
|
|
355
|
+
*/
|
|
356
|
+
export const getFeatureById = async (featureId) => {
|
|
357
|
+
const params = validateParams({ featureId }, ['featureId'])
|
|
358
|
+
|
|
359
|
+
return withErrorHandling(async () => {
|
|
360
|
+
const command = new QueryCommand({
|
|
361
|
+
TableName: TABLE_NAME,
|
|
362
|
+
IndexName: 'GSI2',
|
|
363
|
+
KeyConditionExpression: 'gsi2pk = :gsi2pk AND gsi2sk = :gsi2sk',
|
|
364
|
+
ExpressionAttributeValues: {
|
|
365
|
+
':gsi2pk': { S: 'FEATURES' },
|
|
366
|
+
':gsi2sk': { S: `FEATURE#${params.featureId}` }
|
|
367
|
+
}
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
const response = await client.send(command)
|
|
371
|
+
|
|
372
|
+
if (!response.Items || response.Items.length === 0) {
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Transform DDB item to clean feature object
|
|
377
|
+
const data = unmarshall(response.Items[0])
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
moduleName: data.moduleName,
|
|
381
|
+
featureName: data.featureName,
|
|
382
|
+
featureId: data.featureId,
|
|
383
|
+
description: data.description,
|
|
384
|
+
version: data.version,
|
|
385
|
+
endpoints: data.endpoints || [],
|
|
386
|
+
customPolicies: data.customPolicies || [],
|
|
387
|
+
customFeature: data.customFeature || false,
|
|
388
|
+
endpointCount: (data.endpoints || []).length,
|
|
389
|
+
policyCount: (data.customPolicies || []).length,
|
|
390
|
+
lastUpdated: data.lastUpdated
|
|
391
|
+
}
|
|
392
|
+
}, 'getFeatureById')
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Gets endpoint summary across all modules for discovery
|
|
397
|
+
* @returns {Promise<Array>} Array of endpoints with module/feature context
|
|
398
|
+
*/
|
|
399
|
+
export const getAllEndpoints = async () => {
|
|
400
|
+
return withErrorHandling(async () => {
|
|
401
|
+
const command = new ScanCommand({
|
|
402
|
+
TableName: TABLE_NAME,
|
|
403
|
+
FilterExpression: 'itemType = :itemType',
|
|
404
|
+
ExpressionAttributeValues: {
|
|
405
|
+
':itemType': { S: 'feature' }
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
const response = await client.send(command)
|
|
410
|
+
|
|
411
|
+
if (!response.Items) {
|
|
412
|
+
return []
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Transform to flat endpoint list with context
|
|
416
|
+
const endpoints = []
|
|
417
|
+
|
|
418
|
+
for (const item of response.Items) {
|
|
419
|
+
const data = unmarshall(item)
|
|
420
|
+
|
|
421
|
+
if (data.endpoints && Array.isArray(data.endpoints)) {
|
|
422
|
+
for (const endpoint of data.endpoints) {
|
|
423
|
+
endpoints.push({
|
|
424
|
+
endpoint,
|
|
425
|
+
moduleName: data.moduleName,
|
|
426
|
+
featureName: data.featureName,
|
|
427
|
+
featureDescription: data.description,
|
|
428
|
+
version: data.version,
|
|
429
|
+
customFeature: data.customFeature || false
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return endpoints
|
|
436
|
+
}, 'getAllEndpoints')
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Gets endpoint summary from non-custom features only
|
|
441
|
+
* @returns {Promise<Array>} Array of endpoints from regular (non-custom) features
|
|
442
|
+
*/
|
|
443
|
+
export const getNonCustomEndpoints = async () => {
|
|
444
|
+
return withErrorHandling(async () => {
|
|
445
|
+
const command = new ScanCommand({
|
|
446
|
+
TableName: TABLE_NAME,
|
|
447
|
+
FilterExpression: 'itemType = :itemType AND (attribute_not_exists(customFeature) OR customFeature = :customFeature)',
|
|
448
|
+
ExpressionAttributeValues: {
|
|
449
|
+
':itemType': { S: 'feature' },
|
|
450
|
+
':customFeature': { BOOL: false }
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const response = await client.send(command)
|
|
455
|
+
|
|
456
|
+
if (!response.Items) {
|
|
457
|
+
return []
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Transform to flat endpoint list with context
|
|
461
|
+
const endpoints = []
|
|
462
|
+
|
|
463
|
+
for (const item of response.Items) {
|
|
464
|
+
const data = unmarshall(item)
|
|
465
|
+
|
|
466
|
+
if (data.endpoints && Array.isArray(data.endpoints)) {
|
|
467
|
+
for (const endpoint of data.endpoints) {
|
|
468
|
+
endpoints.push({
|
|
469
|
+
endpoint,
|
|
470
|
+
moduleName: data.moduleName,
|
|
471
|
+
featureName: data.featureName,
|
|
472
|
+
featureDescription: data.description,
|
|
473
|
+
version: data.version,
|
|
474
|
+
customFeature: false
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return endpoints
|
|
481
|
+
}, 'getNonCustomEndpoints')
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Creates a new custom feature in the module registry
|
|
486
|
+
* @param {Object} featureData - The feature data
|
|
487
|
+
* @param {string} featureData.moduleName - The module name
|
|
488
|
+
* @param {string} featureData.featureName - The feature name
|
|
489
|
+
* @param {string} featureData.description - Feature description
|
|
490
|
+
* @param {string} featureData.version - Feature version (defaults to '1.0.0')
|
|
491
|
+
* @param {Array} featureData.endpoints - Array of endpoints
|
|
492
|
+
* @param {Array} featureData.customPolicies - Array of custom policies
|
|
493
|
+
* @param {string} featureData.tenantId - Required tenant ID for tenant-specific custom features
|
|
494
|
+
* @param {string} featureData.policyArn - Optional policy ARN for the feature
|
|
495
|
+
* @param {string} featureData.policyName - Optional policy name for the feature
|
|
496
|
+
* @returns {Promise<Object>} Created feature object
|
|
497
|
+
*/
|
|
498
|
+
export const createCustomFeature = async (featureData) => {
|
|
499
|
+
const params = validateParams(featureData, ['moduleName', 'featureName', 'description', 'tenantId'])
|
|
500
|
+
|
|
501
|
+
return withErrorHandling(async () => {
|
|
502
|
+
const logger = createModuleRegistryLogger('createCustomFeature')
|
|
503
|
+
|
|
504
|
+
// Use tenant-isolated partition key for custom features
|
|
505
|
+
const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
|
|
506
|
+
|
|
507
|
+
// Generate feature ID if not provided
|
|
508
|
+
const featureId = params.featureId || generateFeatureId()
|
|
509
|
+
|
|
510
|
+
// Validate feature ID
|
|
511
|
+
if (!validateFeatureId(featureId)) {
|
|
512
|
+
throw new Error(`Invalid feature ID: ${featureId} for feature: ${params.featureName}`)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Prepare the feature item with tenant-isolated keys
|
|
516
|
+
const featureItem = {
|
|
517
|
+
pk: tenantModulePk,
|
|
518
|
+
sk: `FEATURE#${featureId}`,
|
|
519
|
+
gsi1pk: `TENANT#${params.tenantId}`, // For querying all tenant's custom features
|
|
520
|
+
gsi1sk: `MODULE#${params.moduleName}#FEATURE#${featureId}`,
|
|
521
|
+
itemType: 'feature',
|
|
522
|
+
moduleName: params.moduleName,
|
|
523
|
+
featureName: params.featureName,
|
|
524
|
+
featureId: featureId,
|
|
525
|
+
description: params.description,
|
|
526
|
+
version: params.version || '1.0.0',
|
|
527
|
+
endpoints: params.endpoints || [],
|
|
528
|
+
customPolicies: params.customPolicies || [],
|
|
529
|
+
customFeature: true, // Mark as custom feature
|
|
530
|
+
tenantId: params.tenantId, // Always required for custom features
|
|
531
|
+
lastUpdated: new Date().toISOString()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (params.policyArn) {
|
|
535
|
+
featureItem.policyArn = params.policyArn
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (params.policyName) {
|
|
539
|
+
featureItem.policyName = params.policyName
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
logger.info(`Creating tenant-isolated custom feature ${params.featureName} in module ${params.moduleName} for tenant ${params.tenantId}`)
|
|
543
|
+
|
|
544
|
+
// Use PutItem to create the feature with tenant-isolated key
|
|
545
|
+
const command = new PutItemCommand({
|
|
546
|
+
TableName: TABLE_NAME,
|
|
547
|
+
Item: marshall(featureItem, { removeUndefinedValues: true }),
|
|
548
|
+
ConditionExpression: 'attribute_not_exists(pk)', // Prevent overwriting existing features
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
await client.send(command)
|
|
552
|
+
|
|
553
|
+
logger.info(`Tenant-isolated custom feature ${params.featureName} created successfully for tenant ${params.tenantId}`)
|
|
554
|
+
|
|
555
|
+
// Return clean feature object (without DDB internals)
|
|
556
|
+
return {
|
|
557
|
+
moduleName: featureItem.moduleName,
|
|
558
|
+
featureName: featureItem.featureName,
|
|
559
|
+
featureId: featureItem.featureId,
|
|
560
|
+
description: featureItem.description,
|
|
561
|
+
version: featureItem.version,
|
|
562
|
+
endpoints: featureItem.endpoints,
|
|
563
|
+
customPolicies: featureItem.customPolicies,
|
|
564
|
+
customFeature: featureItem.customFeature,
|
|
565
|
+
tenantId: featureItem.tenantId,
|
|
566
|
+
endpointCount: featureItem.endpoints.length,
|
|
567
|
+
lastUpdated: featureItem.lastUpdated,
|
|
568
|
+
...(featureItem.policyArn && { policyArn: featureItem.policyArn }),
|
|
569
|
+
...(featureItem.policyName && { policyName: featureItem.policyName })
|
|
570
|
+
}
|
|
571
|
+
}, 'createCustomFeature')
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Creates a new regular (non-custom) feature in the module registry
|
|
576
|
+
* @param {Object} featureData - The feature data
|
|
577
|
+
* @param {string} featureData.moduleName - The module name
|
|
578
|
+
* @param {string} featureData.featureName - The feature name
|
|
579
|
+
* @param {string} featureData.description - Feature description
|
|
580
|
+
* @param {string} featureData.version - Feature version (defaults to '1.0.0')
|
|
581
|
+
* @param {Array} featureData.endpoints - Array of endpoints
|
|
582
|
+
* @param {Array} featureData.customPolicies - Array of custom policies
|
|
583
|
+
* @param {string} featureData.policyArn - Optional policy ARN for the feature
|
|
584
|
+
* @param {string} featureData.policyName - Optional policy name for the feature
|
|
585
|
+
* @returns {Promise<Object>} Created feature object
|
|
586
|
+
*/
|
|
587
|
+
export const createFeature = async (featureData) => {
|
|
588
|
+
const params = validateParams(featureData, ['moduleName', 'featureName', 'description'])
|
|
589
|
+
|
|
590
|
+
return withErrorHandling(async () => {
|
|
591
|
+
const logger = createModuleRegistryLogger('createFeature')
|
|
592
|
+
|
|
593
|
+
// Generate feature ID if not provided
|
|
594
|
+
const featureId = params.featureId || generateFeatureId()
|
|
595
|
+
|
|
596
|
+
// Validate feature ID
|
|
597
|
+
if (!validateFeatureId(featureId)) {
|
|
598
|
+
throw new Error(`Invalid feature ID: ${featureId} for feature: ${params.featureName}`)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Prepare the feature item
|
|
602
|
+
const featureItem = {
|
|
603
|
+
pk: `MODULE#${params.moduleName}`,
|
|
604
|
+
sk: `FEATURE#${featureId}`,
|
|
605
|
+
gsi1pk: `MODULE#${params.moduleName}`,
|
|
606
|
+
gsi1sk: `FEATURE#${featureId}`,
|
|
607
|
+
itemType: 'feature',
|
|
608
|
+
moduleName: params.moduleName,
|
|
609
|
+
featureName: params.featureName,
|
|
610
|
+
featureId: featureId,
|
|
611
|
+
description: params.description,
|
|
612
|
+
version: params.version || '1.0.0',
|
|
613
|
+
endpoints: params.endpoints || [],
|
|
614
|
+
customPolicies: params.customPolicies || [],
|
|
615
|
+
customFeature: false, // Mark as regular feature
|
|
616
|
+
lastUpdated: new Date().toISOString()
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Add optional fields if provided
|
|
620
|
+
if (params.policyArn) {
|
|
621
|
+
featureItem.policyArn = params.policyArn
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (params.policyName) {
|
|
625
|
+
featureItem.policyName = params.policyName
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
logger.info(`Creating feature ${params.featureName} in module ${params.moduleName}`)
|
|
629
|
+
|
|
630
|
+
// Use PutItem to create the feature
|
|
631
|
+
const command = new PutItemCommand({
|
|
632
|
+
TableName: TABLE_NAME,
|
|
633
|
+
Item: marshall(featureItem, { removeUndefinedValues: true }),
|
|
634
|
+
ConditionExpression: 'attribute_not_exists(pk)', // Prevent overwriting existing features
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
await client.send(command)
|
|
638
|
+
|
|
639
|
+
logger.info(`Feature ${params.featureName} created successfully`)
|
|
640
|
+
|
|
641
|
+
// Return clean feature object (without DDB internals)
|
|
642
|
+
return {
|
|
643
|
+
moduleName: featureItem.moduleName,
|
|
644
|
+
featureName: featureItem.featureName,
|
|
645
|
+
featureId: featureItem.featureId,
|
|
646
|
+
description: featureItem.description,
|
|
647
|
+
version: featureItem.version,
|
|
648
|
+
endpoints: featureItem.endpoints,
|
|
649
|
+
customPolicies: featureItem.customPolicies,
|
|
650
|
+
customFeature: featureItem.customFeature,
|
|
651
|
+
endpointCount: featureItem.endpoints.length,
|
|
652
|
+
lastUpdated: featureItem.lastUpdated,
|
|
653
|
+
...(featureItem.policyArn && { policyArn: featureItem.policyArn }),
|
|
654
|
+
...(featureItem.policyName && { policyName: featureItem.policyName })
|
|
655
|
+
}
|
|
656
|
+
}, 'createFeature')
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Updates a custom feature in the module registry
|
|
661
|
+
* @param {string} moduleName - The module name
|
|
662
|
+
* @param {string} featureId - The feature ID (featureId is KING!)
|
|
663
|
+
* @param {Object} updates - Fields to update
|
|
664
|
+
* @param {string} updates.description - Updated description
|
|
665
|
+
* @param {Array} updates.endpoints - Updated endpoints array
|
|
666
|
+
* @param {Array} updates.customPolicies - Updated custom policies
|
|
667
|
+
* @param {string} tenantId - Required tenant ID for tenant-isolated custom features
|
|
668
|
+
* @returns {Promise<Object>} Updated feature object
|
|
669
|
+
*/
|
|
670
|
+
export const updateCustomFeature = async (moduleName, featureId, updates, tenantId) => {
|
|
671
|
+
const params = validateParams({ moduleName, featureId, tenantId }, ['moduleName', 'featureId', 'tenantId'])
|
|
672
|
+
|
|
673
|
+
return withErrorHandling(async () => {
|
|
674
|
+
const logger = createModuleRegistryLogger('updateCustomFeature')
|
|
675
|
+
|
|
676
|
+
const allowedUpdates = ['description', 'endpoints', 'customPolicies']
|
|
677
|
+
const filteredUpdates = {}
|
|
678
|
+
|
|
679
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
680
|
+
if (allowedUpdates.includes(key)) {
|
|
681
|
+
filteredUpdates[key] = value
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (Object.keys(filteredUpdates).length === 0) {
|
|
686
|
+
throw new Error(`No valid fields to update. Allowed fields: ${allowedUpdates.join(', ')}`)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Add lastUpdated timestamp
|
|
690
|
+
filteredUpdates.lastUpdated = new Date().toISOString()
|
|
691
|
+
|
|
692
|
+
logger.info(`Updating tenant-isolated custom feature ${params.featureId} in module ${params.moduleName} for tenant ${params.tenantId}`)
|
|
693
|
+
|
|
694
|
+
const expressionParts = []
|
|
695
|
+
const expressionAttributeNames = {}
|
|
696
|
+
const expressionAttributeValues = {}
|
|
697
|
+
|
|
698
|
+
for (const [key, value] of Object.entries(filteredUpdates)) {
|
|
699
|
+
expressionParts.push(`#${key} = :${key}`)
|
|
700
|
+
expressionAttributeNames[`#${key}`] = key
|
|
701
|
+
expressionAttributeValues[`:${key}`] = value
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Condition to ensure it exists, is a custom feature, and belongs to the tenant
|
|
705
|
+
const conditionExpression = 'attribute_exists(pk) AND customFeature = :customFeature AND tenantId = :tenantId'
|
|
706
|
+
expressionAttributeValues[':customFeature'] = true
|
|
707
|
+
expressionAttributeValues[':tenantId'] = params.tenantId
|
|
708
|
+
|
|
709
|
+
// Use tenant-isolated partition key
|
|
710
|
+
const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
|
|
711
|
+
|
|
712
|
+
const command = new UpdateItemCommand({
|
|
713
|
+
TableName: TABLE_NAME,
|
|
714
|
+
Key: marshall({
|
|
715
|
+
pk: tenantModulePk,
|
|
716
|
+
sk: `FEATURE#${params.featureId}`
|
|
717
|
+
}),
|
|
718
|
+
UpdateExpression: `SET ${expressionParts.join(', ')}`,
|
|
719
|
+
ExpressionAttributeNames: expressionAttributeNames,
|
|
720
|
+
ExpressionAttributeValues: marshall(expressionAttributeValues),
|
|
721
|
+
ConditionExpression: conditionExpression,
|
|
722
|
+
ReturnValues: 'ALL_NEW'
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
const { Attributes } = await client.send(command)
|
|
726
|
+
|
|
727
|
+
logger.info(`Tenant-isolated custom feature ${params.featureId} updated successfully for tenant ${params.tenantId}`)
|
|
728
|
+
|
|
729
|
+
// Return clean feature object (without DDB internals)
|
|
730
|
+
const updatedFeature = unmarshall(Attributes)
|
|
731
|
+
return {
|
|
732
|
+
moduleName: updatedFeature.moduleName,
|
|
733
|
+
featureName: updatedFeature.featureName,
|
|
734
|
+
description: updatedFeature.description,
|
|
735
|
+
version: updatedFeature.version,
|
|
736
|
+
endpoints: updatedFeature.endpoints,
|
|
737
|
+
customPolicies: updatedFeature.customPolicies,
|
|
738
|
+
customFeature: updatedFeature.customFeature,
|
|
739
|
+
tenantId: updatedFeature.tenantId,
|
|
740
|
+
endpointCount: updatedFeature.endpoints.length,
|
|
741
|
+
lastUpdated: updatedFeature.lastUpdated,
|
|
742
|
+
...(updatedFeature.policyArn && { policyArn: updatedFeature.policyArn }),
|
|
743
|
+
...(updatedFeature.policyName && { policyName: updatedFeature.policyName })
|
|
744
|
+
}
|
|
745
|
+
}, 'updateCustomFeature')
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* Deletes a custom feature from the module registry
|
|
750
|
+
* @param {string} moduleName - The module name
|
|
751
|
+
* @param {string} featureId - The feature ID (featureId is KING!)
|
|
752
|
+
* @param {string} tenantId - Required tenant ID for tenant-isolated custom features
|
|
753
|
+
* @returns {Promise<boolean>} True if deleted successfully
|
|
754
|
+
*/
|
|
755
|
+
export const deleteCustomFeature = async (moduleName, featureId, tenantId) => {
|
|
756
|
+
const params = validateParams({ moduleName, featureId, tenantId }, ['moduleName', 'featureId', 'tenantId'])
|
|
757
|
+
|
|
758
|
+
return withErrorHandling(async () => {
|
|
759
|
+
const logger = createModuleRegistryLogger('deleteCustomFeature')
|
|
760
|
+
|
|
761
|
+
logger.info(`Deleting tenant-isolated custom feature ${params.featureId} from module ${params.moduleName} for tenant ${params.tenantId}`)
|
|
762
|
+
|
|
763
|
+
// Condition to ensure it exists, is a custom feature, and belongs to the tenant
|
|
764
|
+
const conditionExpression = 'attribute_exists(pk) AND customFeature = :customFeature AND tenantId = :tenantId'
|
|
765
|
+
const expressionAttributeValues = {
|
|
766
|
+
':customFeature': true,
|
|
767
|
+
':tenantId': params.tenantId
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Use tenant-isolated partition key
|
|
771
|
+
const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
|
|
772
|
+
|
|
773
|
+
const command = new DeleteItemCommand({
|
|
774
|
+
TableName: TABLE_NAME,
|
|
775
|
+
Key: marshall({
|
|
776
|
+
pk: tenantModulePk,
|
|
777
|
+
sk: `FEATURE#${params.featureId}`
|
|
778
|
+
}),
|
|
779
|
+
ConditionExpression: conditionExpression,
|
|
780
|
+
ExpressionAttributeValues: marshall(expressionAttributeValues)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
await client.send(command)
|
|
784
|
+
|
|
785
|
+
logger.info(`Tenant-isolated custom feature ${params.featureId} deleted successfully for tenant ${params.tenantId}`)
|
|
786
|
+
return true
|
|
787
|
+
}, 'deleteCustomFeature')
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Lists custom features for a specific tenant using efficient GSI query
|
|
792
|
+
* @param {string} tenantId - The tenant ID
|
|
793
|
+
* @returns {Promise<Array>} Array of custom features for the tenant
|
|
794
|
+
*/
|
|
795
|
+
export const listCustomFeaturesByTenant = async (tenantId) => {
|
|
796
|
+
const params = validateParams({ tenantId }, ['tenantId'])
|
|
797
|
+
|
|
798
|
+
return withErrorHandling(async () => {
|
|
799
|
+
const logger = createModuleRegistryLogger('listCustomFeaturesByTenant')
|
|
800
|
+
|
|
801
|
+
logger.info(`Listing tenant-isolated custom features for tenant ${params.tenantId}`)
|
|
802
|
+
|
|
803
|
+
// Use GSI1 for efficient tenant-specific query
|
|
804
|
+
const command = new QueryCommand({
|
|
805
|
+
TableName: TABLE_NAME,
|
|
806
|
+
IndexName: GSI1_NAME,
|
|
807
|
+
KeyConditionExpression: 'gsi1pk = :tenantPk',
|
|
808
|
+
FilterExpression: 'itemType = :itemType AND customFeature = :customFeature',
|
|
809
|
+
ExpressionAttributeValues: {
|
|
810
|
+
':tenantPk': { S: `TENANT#${params.tenantId}` },
|
|
811
|
+
':itemType': { S: 'feature' },
|
|
812
|
+
':customFeature': { BOOL: true }
|
|
813
|
+
}
|
|
814
|
+
})
|
|
815
|
+
|
|
816
|
+
const response = await client.send(command)
|
|
817
|
+
|
|
818
|
+
if (!response.Items) {
|
|
819
|
+
return []
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Transform to clean feature objects
|
|
823
|
+
const customFeatures = response.Items.map(item => {
|
|
824
|
+
const data = unmarshall(item)
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
moduleName: data.moduleName,
|
|
828
|
+
featureName: data.featureName,
|
|
829
|
+
description: data.description,
|
|
830
|
+
version: data.version,
|
|
831
|
+
endpoints: data.endpoints || [],
|
|
832
|
+
customPolicies: data.customPolicies || [],
|
|
833
|
+
customFeature: data.customFeature,
|
|
834
|
+
tenantId: data.tenantId,
|
|
835
|
+
endpointCount: (data.endpoints || []).length,
|
|
836
|
+
lastUpdated: data.lastUpdated,
|
|
837
|
+
...(data.policyArn && { policyArn: data.policyArn }),
|
|
838
|
+
...(data.policyName && { policyName: data.policyName })
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
logger.info(`Found ${customFeatures.length} tenant-isolated custom features for tenant ${params.tenantId}`)
|
|
843
|
+
return customFeatures
|
|
844
|
+
}, 'listCustomFeaturesByTenant')
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Gets detailed information about a specific tenant custom feature
|
|
849
|
+
* @param {string} moduleName - The module name
|
|
850
|
+
* @param {string} featureId - The feature ID (featureId is KING!)
|
|
851
|
+
* @param {string} tenantId - The tenant ID
|
|
852
|
+
* @returns {Promise<Object|null>} Tenant custom feature details or null if not found
|
|
853
|
+
*/
|
|
854
|
+
export const getTenantCustomFeature = async (moduleName, featureId, tenantId) => {
|
|
855
|
+
const params = validateParams({ moduleName, featureId, tenantId }, ['moduleName', 'featureId', 'tenantId'])
|
|
856
|
+
|
|
857
|
+
return withErrorHandling(async () => {
|
|
858
|
+
const logger = createModuleRegistryLogger('getTenantCustomFeature')
|
|
859
|
+
|
|
860
|
+
logger.info(`Getting tenant-isolated custom feature ${params.featureId} from module ${params.moduleName} for tenant ${params.tenantId}`)
|
|
861
|
+
|
|
862
|
+
// Use tenant-isolated partition key
|
|
863
|
+
const tenantModulePk = `TENANT#${params.tenantId}#MODULE#${params.moduleName}`
|
|
864
|
+
|
|
865
|
+
const command = new QueryCommand({
|
|
866
|
+
TableName: TABLE_NAME,
|
|
867
|
+
KeyConditionExpression: 'pk = :pk AND sk = :sk',
|
|
868
|
+
ExpressionAttributeValues: {
|
|
869
|
+
':pk': { S: tenantModulePk },
|
|
870
|
+
':sk': { S: `FEATURE#${params.featureId}` }
|
|
871
|
+
}
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
const response = await client.send(command)
|
|
875
|
+
|
|
876
|
+
if (!response.Items || response.Items.length === 0) {
|
|
877
|
+
logger.info(`Tenant custom feature ${params.featureId} not found for tenant ${params.tenantId}`)
|
|
878
|
+
return null
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Transform DDB item to clean feature object
|
|
882
|
+
const data = unmarshall(response.Items[0])
|
|
883
|
+
|
|
884
|
+
return {
|
|
885
|
+
moduleName: data.moduleName,
|
|
886
|
+
featureName: data.featureName,
|
|
887
|
+
description: data.description,
|
|
888
|
+
version: data.version,
|
|
889
|
+
endpoints: data.endpoints || [],
|
|
890
|
+
customPolicies: data.customPolicies || [],
|
|
891
|
+
customFeature: data.customFeature,
|
|
892
|
+
tenantId: data.tenantId,
|
|
893
|
+
endpointCount: (data.endpoints || []).length,
|
|
894
|
+
lastUpdated: data.lastUpdated,
|
|
895
|
+
...(data.policyArn && { policyArn: data.policyArn }),
|
|
896
|
+
...(data.policyName && { policyName: data.policyName })
|
|
897
|
+
}
|
|
898
|
+
}, 'getTenantCustomFeature')
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// =============================================================================
|
|
902
|
+
// ABAC (Attribute-Based Access Control) Functions
|
|
903
|
+
// =============================================================================
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Converts an endpoint string to API Gateway ARN format
|
|
907
|
+
* @param {string} endpoint - Endpoint in format "METHOD - /path/pattern"
|
|
908
|
+
* @param {string} region - AWS region (defaults to *)
|
|
909
|
+
* @param {string} accountId - AWS account ID (defaults to *)
|
|
910
|
+
* @param {string} apiId - API Gateway ID (defaults to *)
|
|
911
|
+
* @returns {string} API Gateway ARN
|
|
912
|
+
*/
|
|
913
|
+
const endpointToArn = (endpoint, region = '*', accountId = '*', apiId = '*') => {
|
|
914
|
+
// Parse endpoint format: "POST /admin/tenants" or "POST admin/tenants"
|
|
915
|
+
const trimmedEndpoint = endpoint.trim()
|
|
916
|
+
const spaceIndex = trimmedEndpoint.indexOf(' ')
|
|
917
|
+
if (spaceIndex === -1) {
|
|
918
|
+
throw new Error(`Invalid endpoint format: ${endpoint}. Expected "METHOD path"`)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const method = trimmedEndpoint.substring(0, spaceIndex).trim().toUpperCase()
|
|
922
|
+
let path = trimmedEndpoint.substring(spaceIndex + 1).trim()
|
|
923
|
+
|
|
924
|
+
// Normalize path to ensure it starts with /
|
|
925
|
+
if (!path.startsWith('/')) {
|
|
926
|
+
path = `/${path}`
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Convert path parameters from {param} to * for ARN matching
|
|
930
|
+
const arnPath = path.replace(/\{[^}]+\}/g, '*')
|
|
931
|
+
|
|
932
|
+
return `arn:aws:execute-api:${region}:${accountId}:${apiId}/*/${method}${arnPath}`
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Creates an ABAC condition for feature access
|
|
937
|
+
* @param {string} moduleName - Module name (e.g., 'tenants')
|
|
938
|
+
* @param {string} featureName - Feature name (e.g., 'criar-tenant')
|
|
939
|
+
* @returns {Object} IAM condition object
|
|
940
|
+
*/
|
|
941
|
+
const createAbacCondition = (moduleName, featureName) => {
|
|
942
|
+
return {
|
|
943
|
+
StringLike: {
|
|
944
|
+
[`aws:PrincipalTag/${moduleName}Features`]: `*${featureName}*`
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Generates a single module-level managed policy with ABAC conditions
|
|
951
|
+
* @param {string} moduleName - The module name (e.g., 'tenants')
|
|
952
|
+
* @param {Object} options - Generation options
|
|
953
|
+
* @param {string} options.region - AWS region for ARN generation (defaults to *)
|
|
954
|
+
* @param {string} options.accountId - AWS account ID for ARN generation (defaults to *)
|
|
955
|
+
* @param {string} options.apiId - API Gateway ID for ARN generation (defaults to *)
|
|
956
|
+
* @returns {Promise<Object>} Complete IAM policy document with ABAC conditions
|
|
957
|
+
*/
|
|
958
|
+
export const generateModuleAbacPolicy = async (moduleName, options = {}) => {
|
|
959
|
+
const params = validateParams({ moduleName }, ['moduleName'])
|
|
960
|
+
const { region = '*', accountId = '*', apiId = '*', selectedFeatures } = options
|
|
961
|
+
|
|
962
|
+
return withErrorHandling(async () => {
|
|
963
|
+
const logger = createModuleRegistryLogger('generateModuleAbacPolicy')
|
|
964
|
+
|
|
965
|
+
logger.info(`Generating ABAC policy for module ${params.moduleName}`)
|
|
966
|
+
|
|
967
|
+
// Use provided features if available, otherwise query the database
|
|
968
|
+
let features
|
|
969
|
+
if (selectedFeatures && Array.isArray(selectedFeatures)) {
|
|
970
|
+
// Load full feature details for each selected feature to get endpoints
|
|
971
|
+
logger.info(`Loading full details for ${selectedFeatures.length} selected features`)
|
|
972
|
+
|
|
973
|
+
features = await Promise.all(
|
|
974
|
+
selectedFeatures.map(async (sf) => {
|
|
975
|
+
const featureDetails = await getFeatureDetails(sf.moduleName, sf.featureId)
|
|
976
|
+
if (!featureDetails) {
|
|
977
|
+
throw new Error(`Feature '${sf.featureId}' not found in module '${sf.moduleName}'. Make sure the feature exists in the registry.`)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return {
|
|
981
|
+
featureName: featureDetails.featureName,
|
|
982
|
+
moduleName: featureDetails.moduleName || sf.moduleName,
|
|
983
|
+
endpoints: featureDetails.endpoints || [],
|
|
984
|
+
customPolicies: featureDetails.customPolicies || []
|
|
985
|
+
}
|
|
986
|
+
})
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
logger.info(`Loaded feature details for ${features.length} features`)
|
|
990
|
+
} else {
|
|
991
|
+
// Get all features for this module (both regular and custom) from database
|
|
992
|
+
features = await getModuleFeatures(params.moduleName)
|
|
993
|
+
logger.info(`Retrieved ${features?.length || 0} features from database`)
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
if (!features || features.length === 0) {
|
|
997
|
+
logger.warn(`No features found for module ${params.moduleName}`)
|
|
998
|
+
return {
|
|
999
|
+
Version: '2012-10-17',
|
|
1000
|
+
Statement: []
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Generate policy statements for each feature
|
|
1005
|
+
const statements = features.map(feature => {
|
|
1006
|
+
// Sanitize both module name and feature name for AWS IAM SID requirements (alphanumeric only)
|
|
1007
|
+
const moduleNameSafeId = params.moduleName.replace(/[^a-zA-Z0-9]/g, '')
|
|
1008
|
+
const featureNameSafeId = feature.featureName.replace(/[^a-zA-Z0-9]/g, '')
|
|
1009
|
+
|
|
1010
|
+
// Collect all actions (API Gateway + custom policies)
|
|
1011
|
+
const actions = ['execute-api:Invoke']
|
|
1012
|
+
const resources = []
|
|
1013
|
+
|
|
1014
|
+
// Add API Gateway resources from endpoints
|
|
1015
|
+
if (feature.endpoints && feature.endpoints.length > 0) {
|
|
1016
|
+
feature.endpoints.forEach(endpoint => {
|
|
1017
|
+
resources.push(endpointToArn(endpoint, region, accountId, apiId))
|
|
1018
|
+
})
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// Add custom policy actions and resources
|
|
1022
|
+
if (feature.customPolicies && feature.customPolicies.length > 0) {
|
|
1023
|
+
feature.customPolicies.forEach(policy => {
|
|
1024
|
+
if (policy.Action) {
|
|
1025
|
+
const policyActions = Array.isArray(policy.Action) ? policy.Action : [policy.Action]
|
|
1026
|
+
actions.push(...policyActions)
|
|
1027
|
+
}
|
|
1028
|
+
if (policy.Resource) {
|
|
1029
|
+
const policyResources = Array.isArray(policy.Resource) ? policy.Resource : [policy.Resource]
|
|
1030
|
+
resources.push(...policyResources)
|
|
1031
|
+
}
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Remove duplicates
|
|
1036
|
+
const uniqueActions = [...new Set(actions)]
|
|
1037
|
+
const uniqueResources = [...new Set(resources)]
|
|
1038
|
+
|
|
1039
|
+
// If no resources defined, default to all
|
|
1040
|
+
if (uniqueResources.length === 0) {
|
|
1041
|
+
uniqueResources.push('*')
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
Sid: `${moduleNameSafeId}${featureNameSafeId}Access`,
|
|
1046
|
+
Effect: 'Allow',
|
|
1047
|
+
Action: uniqueActions,
|
|
1048
|
+
Resource: uniqueResources,
|
|
1049
|
+
Condition: createAbacCondition(params.moduleName, feature.featureName)
|
|
1050
|
+
}
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
const policyDocument = {
|
|
1054
|
+
Version: '2012-10-17',
|
|
1055
|
+
Statement: statements
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
logger.info(`Generated ABAC policy for module ${params.moduleName} with ${statements.length} feature statements`)
|
|
1059
|
+
|
|
1060
|
+
return policyDocument
|
|
1061
|
+
}, 'generateModuleAbacPolicy')
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Generates ABAC policy documents for all modules
|
|
1066
|
+
* @param {Object} options - Generation options
|
|
1067
|
+
* @param {string} options.region - AWS region for ARN generation
|
|
1068
|
+
* @param {string} options.accountId - AWS account ID for ARN generation
|
|
1069
|
+
* @param {string} options.apiId - API Gateway ID for ARN generation
|
|
1070
|
+
* @returns {Promise<Object>} Object with module names as keys and policy documents as values
|
|
1071
|
+
*/
|
|
1072
|
+
export const generateAllModuleAbacPolicies = async (options = {}) => {
|
|
1073
|
+
return withErrorHandling(async () => {
|
|
1074
|
+
const logger = createModuleRegistryLogger('generateAllModuleAbacPolicies')
|
|
1075
|
+
|
|
1076
|
+
logger.info('Generating ABAC policies for all modules')
|
|
1077
|
+
|
|
1078
|
+
// Get all modules
|
|
1079
|
+
const modules = await listAllModules()
|
|
1080
|
+
|
|
1081
|
+
if (!modules || modules.length === 0) {
|
|
1082
|
+
logger.warn('No modules found')
|
|
1083
|
+
return {}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Generate policy for each module
|
|
1087
|
+
const moduleAbacPolicies = {}
|
|
1088
|
+
|
|
1089
|
+
for (const module of modules) {
|
|
1090
|
+
logger.info(`Processing module: ${module.moduleName}`)
|
|
1091
|
+
moduleAbacPolicies[module.moduleName] = await generateModuleAbacPolicy(module.moduleName, options)
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
logger.info(`Generated ABAC policies for ${Object.keys(moduleAbacPolicies).length} modules`)
|
|
1095
|
+
|
|
1096
|
+
return moduleAbacPolicies
|
|
1097
|
+
}, 'generateAllModuleAbacPolicies')
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* Creates resource tags for a role based on selected features
|
|
1102
|
+
* @param {Array} selectedFeatures - Array of feature objects with moduleName and featureId
|
|
1103
|
+
* @returns {Promise<Array>} Array of AWS resource tags for IAM role
|
|
1104
|
+
*/
|
|
1105
|
+
export const generateAbacTags = async (selectedFeatures) => {
|
|
1106
|
+
const params = validateParams({ selectedFeatures }, ['selectedFeatures'])
|
|
1107
|
+
|
|
1108
|
+
return withErrorHandling(async () => {
|
|
1109
|
+
const logger = createModuleRegistryLogger('generateAbacTags')
|
|
1110
|
+
|
|
1111
|
+
if (!Array.isArray(params.selectedFeatures)) {
|
|
1112
|
+
throw new Error('selectedFeatures must be an array')
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
logger.info(`Generating ABAC tags for ${params.selectedFeatures.length} selected features`)
|
|
1116
|
+
|
|
1117
|
+
// Group features by module
|
|
1118
|
+
const featuresByModule = {}
|
|
1119
|
+
|
|
1120
|
+
params.selectedFeatures.forEach(feature => {
|
|
1121
|
+
if (!feature.moduleName || !feature.featureId) {
|
|
1122
|
+
logger.warn('Skipping invalid feature - missing moduleName or featureId', feature)
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (!featuresByModule[feature.moduleName]) {
|
|
1127
|
+
featuresByModule[feature.moduleName] = []
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
featuresByModule[feature.moduleName].push(feature.featureId)
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
// Convert to AWS tags format - using short featureIds for IAM compliance
|
|
1134
|
+
const tags = Object.entries(featuresByModule).map(([moduleName, featureIds]) => ({
|
|
1135
|
+
Key: `${moduleName}Features`,
|
|
1136
|
+
Value: featureIds.join(':')
|
|
1137
|
+
}))
|
|
1138
|
+
logger.info(`ABAC tags: ${JSON.stringify(tags)}`)
|
|
1139
|
+
logger.info(`Generated ${tags.length} ABAC tags for modules: ${Object.keys(featuresByModule).join(', ')}`)
|
|
1140
|
+
|
|
1141
|
+
return tags
|
|
1142
|
+
}, 'generateAbacTags')
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* Gets required managed policy ARNs for a set of selected features
|
|
1147
|
+
* @param {Array} selectedFeatures - Array of feature objects with moduleName and featureName
|
|
1148
|
+
* @param {string} accountId - AWS account ID for ARN generation
|
|
1149
|
+
* @param {string} service - Service name (e.g., 'api')
|
|
1150
|
+
* @param {string} stage - Stage name (e.g., 'live')
|
|
1151
|
+
* @returns {Promise<Array>} Array of managed policy ARNs needed for the selected features
|
|
1152
|
+
*/
|
|
1153
|
+
export const getRequiredModulePolicyArns = async (selectedFeatures, accountId, service, stage) => {
|
|
1154
|
+
const params = validateParams({ selectedFeatures, accountId, service, stage }, ['selectedFeatures', 'accountId', 'service', 'stage'])
|
|
1155
|
+
|
|
1156
|
+
return withErrorHandling(async () => {
|
|
1157
|
+
const logger = createModuleRegistryLogger('getRequiredModulePolicyArns')
|
|
1158
|
+
|
|
1159
|
+
if (!Array.isArray(params.selectedFeatures)) {
|
|
1160
|
+
throw new Error('selectedFeatures must be an array')
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
logger.info(`Getting required module policy ARNs for ${params.selectedFeatures.length} selected features`)
|
|
1164
|
+
|
|
1165
|
+
// Get unique module names from selected features
|
|
1166
|
+
const moduleNames = [...new Set(params.selectedFeatures.map(f => f.moduleName).filter(Boolean))]
|
|
1167
|
+
|
|
1168
|
+
// Generate module-specific ABAC policy ARNs
|
|
1169
|
+
const policyArns = moduleNames.map(moduleName =>
|
|
1170
|
+
`arn:aws:iam::${params.accountId}:policy/${params.service}-${moduleName}-${params.stage}-abac`
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
logger.info(`Required module-specific ABAC policy ARNs for modules [${moduleNames.join(', ')}]: ${JSON.stringify(policyArns)}`)
|
|
1174
|
+
|
|
1175
|
+
return policyArns
|
|
1176
|
+
}, 'getRequiredModulePolicyArns')
|
|
1177
|
+
}
|
|
1178
|
+
|