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.
- package/dist/index.d.ts +12 -0
- package/dist/index.js +150 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/internal-infrastructure/__tests__/custom-stream-enabler.test.ts +214 -0
- package/src/internal-infrastructure/__tests__/eventbridge-config.test.ts +135 -0
- package/src/internal-infrastructure/__tests__/role-updater.test.ts +781 -0
- package/src/internal-infrastructure/__tests__/stream-processor.test.ts +994 -0
- package/src/internal-infrastructure/handlers/custom-stream-enabler.ts +163 -0
- package/src/internal-infrastructure/handlers/role-updater.ts +402 -0
- package/src/internal-infrastructure/handlers/shared/logger.ts +48 -0
- package/src/internal-infrastructure/handlers/shared/types.ts +31 -0
- package/src/internal-infrastructure/handlers/stream-processor.ts +371 -0
- package/src/internal-infrastructure/resources/cloudwatch.yml +36 -0
- package/src/internal-infrastructure/resources/custom-stream-enabler.yml +106 -0
- package/src/internal-infrastructure/resources/dynamodb.yml +74 -0
- package/src/internal-infrastructure/resources/eventbridge.yml +26 -0
- package/src/internal-infrastructure/resources/iam.yml +110 -0
- package/src/internal-infrastructure/resources/outputs.yml +13 -0
- package/src/internal-infrastructure/resources/sqs.yml +57 -0
- package/src/internal-infrastructure/serverless.yml +69 -0
|
@@ -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
|
+
}
|