serverless-plugin-module-registry 1.0.12 → 1.0.13

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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Custom CloudFormation Resource Handler for Enabling DynamoDB Streams
3
+ *
4
+ * This Lambda function enables DynamoDB Streams on an existing table.
5
+ * It's invoked by CloudFormation as a custom resource.
6
+ */
7
+
8
+ import {
9
+ DynamoDBClient,
10
+ DescribeTableCommand,
11
+ UpdateTableCommand,
12
+ StreamViewType
13
+ } from '@aws-sdk/client-dynamodb'
14
+ import * as https from 'https'
15
+ import * as url from 'url'
16
+
17
+ interface CloudFormationCustomResourceEvent {
18
+ RequestType: 'Create' | 'Update' | 'Delete'
19
+ ResponseURL: string
20
+ StackId: string
21
+ RequestId: string
22
+ ResourceType: string
23
+ LogicalResourceId: string
24
+ PhysicalResourceId?: string
25
+ ResourceProperties: {
26
+ ServiceToken: string
27
+ TableName: string
28
+ StreamViewType: StreamViewType
29
+ }
30
+ }
31
+
32
+ interface CloudFormationContext {
33
+ logStreamName: string
34
+ }
35
+
36
+ const client = new DynamoDBClient({
37
+ maxAttempts: 3,
38
+ retryMode: 'adaptive'
39
+ })
40
+
41
+ /**
42
+ * Send response to CloudFormation
43
+ */
44
+ async function sendResponse(
45
+ event: CloudFormationCustomResourceEvent,
46
+ context: CloudFormationContext,
47
+ status: 'SUCCESS' | 'FAILED',
48
+ data?: Record<string, any>,
49
+ physicalResourceId?: string
50
+ ): Promise<void> {
51
+ const responseBody = JSON.stringify({
52
+ Status: status,
53
+ Reason: `See CloudWatch Log Stream: ${context.logStreamName}`,
54
+ PhysicalResourceId: physicalResourceId || context.logStreamName,
55
+ StackId: event.StackId,
56
+ RequestId: event.RequestId,
57
+ LogicalResourceId: event.LogicalResourceId,
58
+ Data: data || {}
59
+ })
60
+
61
+ const parsedUrl = url.parse(event.ResponseURL)
62
+ const options = {
63
+ hostname: parsedUrl.hostname,
64
+ port: 443,
65
+ path: parsedUrl.path,
66
+ method: 'PUT',
67
+ headers: {
68
+ 'Content-Type': '',
69
+ 'Content-Length': responseBody.length
70
+ }
71
+ }
72
+
73
+ return new Promise((resolve, reject) => {
74
+ const req = https.request(options, () => resolve())
75
+ req.on('error', reject)
76
+ req.write(responseBody)
77
+ req.end()
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Enable DynamoDB Streams on a table (idempotent)
83
+ */
84
+ async function enableStreams(
85
+ tableName: string,
86
+ streamViewType: StreamViewType
87
+ ): Promise<string | undefined> {
88
+ // Check current stream status
89
+ const describeResult = await client.send(
90
+ new DescribeTableCommand({ TableName: tableName })
91
+ )
92
+
93
+ const streamEnabled = describeResult.Table?.StreamSpecification?.StreamEnabled
94
+ const currentStreamArn = describeResult.Table?.LatestStreamArn
95
+
96
+ if (streamEnabled) {
97
+ console.log(`Streams already enabled on table: ${tableName}`)
98
+ return currentStreamArn
99
+ }
100
+
101
+ console.log(`Enabling streams on table: ${tableName}`)
102
+
103
+ // Enable streams
104
+ await client.send(new UpdateTableCommand({
105
+ TableName: tableName,
106
+ StreamSpecification: {
107
+ StreamEnabled: true,
108
+ StreamViewType: streamViewType
109
+ }
110
+ }))
111
+
112
+ // Wait for stream to be created
113
+ await new Promise(resolve => setTimeout(resolve, 2000))
114
+
115
+ // Get updated table info with stream ARN
116
+ const updatedTable = await client.send(
117
+ new DescribeTableCommand({ TableName: tableName })
118
+ )
119
+
120
+ return updatedTable.Table?.LatestStreamArn
121
+ }
122
+
123
+ /**
124
+ * Lambda handler for Custom CloudFormation Resource
125
+ */
126
+ export async function handler(
127
+ event: CloudFormationCustomResourceEvent,
128
+ context: CloudFormationContext
129
+ ): Promise<void> {
130
+ console.log('Event:', JSON.stringify(event, null, 2))
131
+
132
+ try {
133
+ const { TableName, StreamViewType } = event.ResourceProperties
134
+ const requestType = event.RequestType
135
+
136
+ if (requestType === 'Delete') {
137
+ // On delete, leave streams enabled for safety
138
+ console.log('Delete request - leaving streams enabled for safety')
139
+ await sendResponse(event, context, 'SUCCESS', {}, TableName)
140
+ return
141
+ }
142
+
143
+ // Create or Update: Enable streams (idempotent)
144
+ const streamArn = await enableStreams(TableName, StreamViewType)
145
+
146
+ await sendResponse(
147
+ event,
148
+ context,
149
+ 'SUCCESS',
150
+ { StreamArn: streamArn },
151
+ TableName
152
+ )
153
+ } catch (error) {
154
+ console.error('Error enabling streams:', error)
155
+ await sendResponse(
156
+ event,
157
+ context,
158
+ 'FAILED',
159
+ {},
160
+ event.PhysicalResourceId
161
+ )
162
+ }
163
+ }
@@ -0,0 +1,402 @@
1
+ import { SQSEvent } from 'aws-lambda'
2
+ import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'
3
+ import {
4
+ IAMClient,
5
+ ListRolesCommand,
6
+ GetRoleCommand,
7
+ TagRoleCommand,
8
+ ListAttachedRolePoliciesCommand,
9
+ AttachRolePolicyCommand,
10
+ ListPoliciesCommand,
11
+ } from '@aws-sdk/client-iam'
12
+ import { CloudWatchClient, PutMetricDataCommand } from '@aws-sdk/client-cloudwatch'
13
+ import { createLogger } from './shared/logger'
14
+ import { RoleUpdateMessage } from './shared/types'
15
+
16
+ const logger = createLogger('role-updater')
17
+
18
+ const dynamoClient = new DynamoDBClient({ maxAttempts: 3, retryMode: 'adaptive' })
19
+ const iamClient = new IAMClient({ maxAttempts: 3, retryMode: 'adaptive' })
20
+ const cloudwatchClient = new CloudWatchClient({})
21
+
22
+ /**
23
+ * Parse module and feature from SK
24
+ * SK Format: MODULE#{moduleName}#FEATURE#{featureId}
25
+ */
26
+ function parseFeatureId(sk: string): string | null {
27
+ const parts = sk.split('#')
28
+ if (parts.length === 4 && parts[0] === 'MODULE' && parts[2] === 'FEATURE') {
29
+ return parts[3] // Extract featureId
30
+ }
31
+ return null
32
+ }
33
+
34
+ /**
35
+ * Query DynamoDB for all features assigned to a role+module
36
+ */
37
+ async function queryRoleFeatures(
38
+ roleName: string,
39
+ moduleName: string,
40
+ tableName: string
41
+ ): Promise<string[]> {
42
+ const result = await dynamoClient.send(new QueryCommand({
43
+ TableName: tableName,
44
+ KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
45
+ ExpressionAttributeValues: {
46
+ ':pk': { S: `ROLE#${roleName}` },
47
+ ':sk': { S: `MODULE#${moduleName}#` },
48
+ },
49
+ }))
50
+
51
+ if (!result.Items || result.Items.length === 0) {
52
+ return []
53
+ }
54
+
55
+ const featureIds: string[] = []
56
+ for (const item of result.Items) {
57
+ const sk = item.SK?.S || item.sk?.S
58
+ if (sk) {
59
+ const featureId = parseFeatureId(sk)
60
+ if (featureId) {
61
+ featureIds.push(featureId)
62
+ }
63
+ }
64
+ }
65
+
66
+ return featureIds
67
+ }
68
+
69
+ /**
70
+ * Build tag value from sorted feature IDs
71
+ */
72
+ function buildTagValue(featureIds: string[]): string {
73
+ const sorted = [...featureIds].sort()
74
+ return sorted.join(':')
75
+ }
76
+
77
+ /**
78
+ * List all IAM roles matching the naming pattern: *-{roleType}
79
+ */
80
+ async function listRolesMatchingPattern(roleType: string): Promise<string[]> {
81
+ const matchingRoles: string[] = []
82
+ let marker: string | undefined
83
+
84
+ do {
85
+ const response = await iamClient.send(new ListRolesCommand({ Marker: marker }))
86
+
87
+ const matches = response.Roles?.filter(role =>
88
+ role.RoleName?.includes(`-${roleType}`)
89
+ ) ?? []
90
+
91
+ matchingRoles.push(...matches.map(r => r.RoleName!))
92
+ marker = response.Marker
93
+ } while (marker)
94
+
95
+ logger.info('Found tenant roles', {
96
+ roleType,
97
+ count: matchingRoles.length,
98
+ })
99
+
100
+ return matchingRoles
101
+ }
102
+
103
+ /**
104
+ * Update role tags idempotently
105
+ */
106
+ async function updateRoleTags(
107
+ roleName: string,
108
+ tagKey: string,
109
+ tagValue: string
110
+ ): Promise<boolean> {
111
+ try {
112
+ // Get current role tags
113
+ const roleResponse = await iamClient.send(new GetRoleCommand({ RoleName: roleName }))
114
+ const currentTags = roleResponse.Role?.Tags || []
115
+
116
+ // Check if tag already exists with same value
117
+ const existingTag = currentTags.find(t => t.Key === tagKey)
118
+ if (existingTag?.Value === tagValue) {
119
+ logger.info('Tag unchanged, skipping update', { roleName, tagKey })
120
+ return false // No update needed
121
+ }
122
+
123
+ // Update tag
124
+ await iamClient.send(new TagRoleCommand({
125
+ RoleName: roleName,
126
+ Tags: [{ Key: tagKey, Value: tagValue }],
127
+ }))
128
+
129
+ logger.info('Updated tag', {
130
+ roleName,
131
+ tagKey,
132
+ tagValue,
133
+ previousValue: existingTag?.Value || 'none',
134
+ })
135
+
136
+ return true // Updated
137
+ } catch (error) {
138
+ logger.error('Failed to update role tags', error as Error, { roleName, tagKey })
139
+ throw error
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Find policy ARN by module name
145
+ */
146
+ async function findModulePolicy(
147
+ moduleName: string,
148
+ policyPrefix?: string,
149
+ accountId?: string
150
+ ): Promise<string | null> {
151
+ try {
152
+ let marker: string | undefined
153
+
154
+ do {
155
+ const response = await iamClient.send(new ListPoliciesCommand({
156
+ Scope: 'Local',
157
+ Marker: marker,
158
+ }))
159
+
160
+ const matchingPolicy = response.Policies?.find(p =>
161
+ p.PolicyName?.includes(moduleName)
162
+ )
163
+
164
+ if (matchingPolicy?.Arn) {
165
+ return matchingPolicy.Arn
166
+ }
167
+
168
+ marker = response.Marker
169
+ } while (marker)
170
+
171
+ // Policy not found - construct expected ARN pattern
172
+ if (policyPrefix && accountId) {
173
+ return `arn:aws:iam::${accountId}:policy/${policyPrefix}-${moduleName}`
174
+ }
175
+
176
+ return null
177
+ } catch (error) {
178
+ logger.error('Failed to find module policy', error as Error, { moduleName })
179
+ return null
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Attach policy to role if not already attached
185
+ */
186
+ async function attachPolicyIfMissing(
187
+ roleName: string,
188
+ moduleName: string,
189
+ policyPrefix?: string,
190
+ accountId?: string
191
+ ): Promise<boolean> {
192
+ try {
193
+ // Find the policy ARN
194
+ const policyArn = await findModulePolicy(moduleName, policyPrefix, accountId)
195
+ if (!policyArn) {
196
+ logger.warn('Module policy not found', { roleName, moduleName })
197
+ return false // Module may not have a policy yet
198
+ }
199
+
200
+ // List attached policies
201
+ const attachedResponse = await iamClient.send(
202
+ new ListAttachedRolePoliciesCommand({ RoleName: roleName })
203
+ )
204
+ const attachedPolicies = attachedResponse.AttachedPolicies || []
205
+
206
+ // Check if policy already attached
207
+ const hasPolicy = attachedPolicies.some(p => p.PolicyArn === policyArn)
208
+ if (hasPolicy) {
209
+ logger.info('Policy already attached', { roleName, moduleName, policyArn })
210
+ return false // No update needed
211
+ }
212
+
213
+ // Attach policy
214
+ await iamClient.send(new AttachRolePolicyCommand({
215
+ RoleName: roleName,
216
+ PolicyArn: policyArn,
217
+ }))
218
+
219
+ logger.info('Attached policy', { roleName, moduleName, policyArn })
220
+
221
+ return true // Attached
222
+ } catch (error) {
223
+ logger.error('Failed to attach policy', error as Error, { roleName, moduleName })
224
+ // Don't throw - policy attachment failure shouldn't block tag updates
225
+ return false
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Process a single role update message
231
+ */
232
+ async function processRoleUpdate(
233
+ message: RoleUpdateMessage,
234
+ tableName: string,
235
+ policyPrefix?: string,
236
+ accountId?: string
237
+ ): Promise<void> {
238
+ const { roleName, moduleName } = message
239
+
240
+ logger.info('Processing role update', { roleName, moduleName })
241
+
242
+ // Step 1: Query all features for this role+module
243
+ const featureIds = await queryRoleFeatures(roleName, moduleName, tableName)
244
+
245
+ logger.info('Retrieved features', {
246
+ roleName,
247
+ moduleName,
248
+ featureCount: featureIds.length,
249
+ })
250
+
251
+ // Step 2: Build tag value
252
+ const tagValue = buildTagValue(featureIds)
253
+ const tagKey = `${moduleName}Features`
254
+
255
+ logger.info('Built tag value', { roleName, moduleName, tagKey, tagValue })
256
+
257
+ // Step 3: Find all tenant roles matching the role type
258
+ const tenantRoles = await listRolesMatchingPattern(roleName)
259
+
260
+ if (tenantRoles.length === 0) {
261
+ logger.warn('No tenant roles found', { roleType: roleName })
262
+ return
263
+ }
264
+
265
+ // Step 4: Update each role
266
+ let rolesUpdated = 0
267
+ let policiesAttached = 0
268
+
269
+ for (const tenantRole of tenantRoles) {
270
+ try {
271
+ // Update tags
272
+ const tagUpdated = await updateRoleTags(tenantRole, tagKey, tagValue)
273
+ if (tagUpdated) {
274
+ rolesUpdated++
275
+ }
276
+
277
+ // Attach policy
278
+ const policyAttached = await attachPolicyIfMissing(tenantRole, moduleName, policyPrefix, accountId)
279
+ if (policyAttached) {
280
+ policiesAttached++
281
+ }
282
+ } catch (error) {
283
+ logger.error('Failed to update tenant role', error as Error, {
284
+ tenantRole,
285
+ roleName,
286
+ moduleName,
287
+ })
288
+ // Continue processing other roles
289
+ }
290
+ }
291
+
292
+ logger.info('Completed role update', {
293
+ roleName,
294
+ moduleName,
295
+ totalRoles: tenantRoles.length,
296
+ rolesUpdated,
297
+ policiesAttached,
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Publish CloudWatch metrics
303
+ */
304
+ async function publishMetrics(
305
+ messagesProcessed: number,
306
+ rolesUpdated: number,
307
+ errors: number,
308
+ duration: number
309
+ ): Promise<void> {
310
+ try {
311
+ await cloudwatchClient.send(new PutMetricDataCommand({
312
+ Namespace: 'ModuleRegistry/RoleAutomation',
313
+ MetricData: [
314
+ {
315
+ MetricName: 'SQSMessagesProcessed',
316
+ Value: messagesProcessed,
317
+ Unit: 'Count',
318
+ Timestamp: new Date(),
319
+ },
320
+ {
321
+ MetricName: 'RoleUpdatesProcessed',
322
+ Value: rolesUpdated,
323
+ Unit: 'Count',
324
+ Timestamp: new Date(),
325
+ },
326
+ {
327
+ MetricName: 'Errors',
328
+ Value: errors,
329
+ Unit: 'Count',
330
+ Timestamp: new Date(),
331
+ },
332
+ {
333
+ MetricName: 'ProcessingDuration',
334
+ Value: duration,
335
+ Unit: 'Milliseconds',
336
+ Timestamp: new Date(),
337
+ },
338
+ ],
339
+ }))
340
+ } catch (error) {
341
+ logger.error('Failed to publish metrics', error as Error)
342
+ // Don't throw - metric failure shouldn't fail the Lambda
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Main Lambda handler for processing SQS messages
348
+ */
349
+ export async function handler(event: SQSEvent): Promise<void> {
350
+ const startTime = Date.now()
351
+ let messagesProcessed = 0
352
+ let errors = 0
353
+
354
+ const DYNAMODB_TABLE_NAME = process.env.DYNAMODB_TABLE_NAME
355
+ const POLICY_PREFIX = process.env.POLICY_PREFIX
356
+ const AWS_ACCOUNT_ID = process.env.AWS_ACCOUNT_ID
357
+
358
+ logger.info('Processing SQS event', {
359
+ messageCount: event.Records.length,
360
+ })
361
+
362
+ if (!DYNAMODB_TABLE_NAME) {
363
+ logger.error('DYNAMODB_TABLE_NAME environment variable not set')
364
+ throw new Error('DYNAMODB_TABLE_NAME environment variable not set')
365
+ }
366
+
367
+ // Process each SQS message
368
+ for (const record of event.Records) {
369
+ try {
370
+ const message: RoleUpdateMessage = JSON.parse(record.body)
371
+
372
+ await processRoleUpdate(message, DYNAMODB_TABLE_NAME, POLICY_PREFIX, AWS_ACCOUNT_ID)
373
+
374
+ messagesProcessed++
375
+ } catch (error) {
376
+ errors++
377
+ logger.error('Failed to process SQS message', error as Error, {
378
+ messageId: record.messageId,
379
+ })
380
+ // Continue processing other messages
381
+ // Failed messages will stay in queue and retry via SQS visibility timeout
382
+ }
383
+ }
384
+
385
+ const duration = Date.now() - startTime
386
+
387
+ // Publish metrics
388
+ await publishMetrics(messagesProcessed, messagesProcessed, errors, duration)
389
+
390
+ logger.info('Completed SQS processing', {
391
+ totalMessages: event.Records.length,
392
+ messagesProcessed,
393
+ errors,
394
+ duration,
395
+ })
396
+
397
+ // If there were errors, log them but don't throw
398
+ // SQS will handle retries for failed messages
399
+ if (errors > 0) {
400
+ logger.warn('Some messages failed processing', { errors })
401
+ }
402
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Structured logger for Lambda functions
3
+ * Outputs JSON for CloudWatch Logs Insights queries
4
+ */
5
+
6
+ export interface LogMetadata {
7
+ [key: string]: string | number | boolean | undefined;
8
+ }
9
+
10
+ export interface Logger {
11
+ info: (_message: string, _metadata?: LogMetadata) => void;
12
+ error: (_message: string, _error?: Error, _metadata?: LogMetadata) => void;
13
+ warn: (_message: string, _metadata?: LogMetadata) => void;
14
+ }
15
+
16
+ export function createLogger(context: string): Logger {
17
+ return {
18
+ info: (message: string, metadata?: LogMetadata) => {
19
+ console.log(JSON.stringify({
20
+ level: 'INFO',
21
+ context,
22
+ message,
23
+ timestamp: new Date().toISOString(),
24
+ ...metadata,
25
+ }))
26
+ },
27
+ error: (message: string, error?: Error, metadata?: LogMetadata) => {
28
+ console.error(JSON.stringify({
29
+ level: 'ERROR',
30
+ context,
31
+ message,
32
+ error: error?.message,
33
+ stack: error?.stack,
34
+ timestamp: new Date().toISOString(),
35
+ ...metadata,
36
+ }))
37
+ },
38
+ warn: (message: string, metadata?: LogMetadata) => {
39
+ console.warn(JSON.stringify({
40
+ level: 'WARN',
41
+ context,
42
+ message,
43
+ timestamp: new Date().toISOString(),
44
+ ...metadata,
45
+ }))
46
+ },
47
+ }
48
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared types for Lambda handlers
3
+ */
4
+
5
+ /**
6
+ * Message payload for SQS queue
7
+ */
8
+ export interface RoleUpdateMessage {
9
+ roleName: string; // e.g., "authenticated"
10
+ moduleName: string; // e.g., "sign"
11
+ eventType: 'INSERT' | 'MODIFY' | 'REMOVE';
12
+ timestamp: string;
13
+ }
14
+
15
+ /**
16
+ * Parsed module and feature from DynamoDB SK
17
+ */
18
+ export interface ModuleFeature {
19
+ moduleName: string;
20
+ featureId: string;
21
+ }
22
+
23
+ /**
24
+ * Feature change event payload for EventBridge
25
+ */
26
+ export interface FeatureChangeEvent {
27
+ moduleName: string; // e.g., "public"
28
+ featureId: string; // e.g., "abc123"
29
+ eventType: 'INSERT' | 'MODIFY' | 'REMOVE';
30
+ timestamp: string; // ISO 8601
31
+ }