Skip to content

Commit 3fe3266

Browse files
whummerclaude
andcommitted
Add product CRUD, IAM action buttons, and API Gateway IAM role
Products: - POST /products and DELETE /products/{product_id} Lambda handlers - Matching API Gateway routes and DynamoDB PutItem/DeleteItem permissions - UI: add product form and per-row delete button in Products section IAM section UI: - Enable/Disable Enforcement buttons (call /_aws/iam/config) - Grant/Revoke PutItem buttons (proxy through POST /iam/fix and /iam/revoke Lambda endpoints) Terraform: - apigw-exec-role with identity-based lambda:InvokeFunction policy - credentials field on all API Gateway integrations so LocalStack IAM enforcement sees an explicit allow for API Gateway → Lambda calls - Makefile: save-state and load-state targets Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0aa06b0 commit 3fe3266

4 files changed

Lines changed: 389 additions & 16 deletions

File tree

01-serverless-app/lambdas/order_handler/handler.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
dynamodb = boto3.resource("dynamodb")
99
sqs = boto3.client("sqs")
10+
iam = boto3.client("iam")
1011

1112
TABLE_NAME = os.environ["ORDERS_TABLE"]
1213
PRODUCTS_TABLE = os.environ["PRODUCTS_TABLE"]
@@ -21,7 +22,7 @@ def default(self, o):
2122
CORS_HEADERS = {
2223
"Access-Control-Allow-Origin": "*",
2324
"Access-Control-Allow-Headers": "Content-Type",
24-
"Access-Control-Allow-Methods": "GET,POST,OPTIONS",
25+
"Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS",
2526
}
2627

2728

@@ -35,9 +36,18 @@ def handler(event, context):
3536
if method == "POST" and path.endswith("/replay"):
3637
return replay_dlq()
3738

39+
if "/iam/" in path:
40+
return iam_action(method, path)
41+
3842
if method == "GET" and "/products" in path:
3943
return list_products()
4044

45+
if method == "POST" and "/products" in path:
46+
return create_product(event)
47+
48+
if method == "DELETE" and "/products" in path:
49+
return delete_product(event)
50+
4151
if method == "GET":
4252
return list_orders()
4353

@@ -70,6 +80,61 @@ def list_products():
7080
}
7181

7282

83+
def create_product(event):
84+
body = json.loads(event.get("body") or "{}")
85+
product_id = body.get("product_id") or uuid.uuid4().hex[:12]
86+
product = {
87+
"product_id": product_id,
88+
"name": body.get("name", ""),
89+
"description": body.get("description", ""),
90+
"price": Decimal(str(body.get("price", "0"))),
91+
}
92+
dynamodb.Table(PRODUCTS_TABLE).put_item(Item=product)
93+
return {
94+
"statusCode": 201,
95+
"headers": {**CORS_HEADERS, "Content-Type": "application/json"},
96+
"body": json.dumps({"product_id": product_id}),
97+
}
98+
99+
100+
def iam_action(method, path):
101+
ROLE = "lambda-exec-role"
102+
POLICY_NAME = "order-handler-putitem"
103+
POLICY_DOC = json.dumps({
104+
"Version": "2012-10-17",
105+
"Statement": [{
106+
"Effect": "Allow",
107+
"Action": ["dynamodb:PutItem"],
108+
"Resource": "*",
109+
}],
110+
})
111+
try:
112+
if path.endswith("/fix"):
113+
iam.put_role_policy(RoleName=ROLE, PolicyName=POLICY_NAME, PolicyDocument=POLICY_DOC)
114+
return {"statusCode": 200, "headers": {**CORS_HEADERS, "Content-Type": "application/json"},
115+
"body": json.dumps({"result": "granted"})}
116+
if path.endswith("/revoke"):
117+
iam.delete_role_policy(RoleName=ROLE, PolicyName=POLICY_NAME)
118+
return {"statusCode": 200, "headers": {**CORS_HEADERS, "Content-Type": "application/json"},
119+
"body": json.dumps({"result": "revoked"})}
120+
except Exception as e:
121+
return {"statusCode": 500, "headers": {**CORS_HEADERS, "Content-Type": "application/json"},
122+
"body": json.dumps({"error": str(e)})}
123+
return {"statusCode": 404, "headers": CORS_HEADERS, "body": "Not found"}
124+
125+
126+
def delete_product(event):
127+
product_id = (event.get("pathParameters") or {}).get("product_id")
128+
if not product_id:
129+
return {"statusCode": 400, "headers": CORS_HEADERS, "body": "Missing product_id"}
130+
dynamodb.Table(PRODUCTS_TABLE).delete_item(Key={"product_id": product_id})
131+
return {
132+
"statusCode": 200,
133+
"headers": {**CORS_HEADERS, "Content-Type": "application/json"},
134+
"body": json.dumps({"deleted": product_id}),
135+
}
136+
137+
73138
def list_orders():
74139
table = dynamodb.Table(TABLE_NAME)
75140
result = table.scan()

01-serverless-app/terraform/main.tf

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,32 @@ resource "aws_sqs_queue" "orders" {
181181

182182
# ── IAM ───────────────────────────────────────────────────────────────────────
183183

184+
resource "aws_iam_role" "apigw_exec" {
185+
name = "apigw-exec-role"
186+
187+
assume_role_policy = jsonencode({
188+
Version = "2012-10-17"
189+
Statement = [{
190+
Action = "sts:AssumeRole"
191+
Effect = "Allow"
192+
Principal = { Service = "apigateway.amazonaws.com" }
193+
}]
194+
})
195+
}
196+
197+
resource "aws_iam_role_policy" "apigw_invoke_lambda" {
198+
role = aws_iam_role.apigw_exec.id
199+
200+
policy = jsonencode({
201+
Version = "2012-10-17"
202+
Statement = [{
203+
Effect = "Allow"
204+
Action = "lambda:InvokeFunction"
205+
Resource = aws_lambda_function.order_handler.arn
206+
}]
207+
})
208+
}
209+
184210
resource "aws_iam_role" "lambda_exec" {
185211
name = "lambda-exec-role"
186212

@@ -201,11 +227,21 @@ resource "aws_iam_role_policy" "lambda_policy" {
201227
Version = "2012-10-17"
202228
Statement = [
203229
{
204-
# dynamodb:PutItem intentionally omitted — see 03-iam-enforcement for the IAM demo
230+
# dynamodb:PutItem intentionally omitted for orders — see 03-iam-enforcement for the IAM demo
205231
Effect = "Allow"
206232
Action = ["dynamodb:UpdateItem", "dynamodb:GetItem", "dynamodb:Scan"]
207233
Resource = [aws_dynamodb_table.orders.arn, aws_dynamodb_table.products.arn]
208234
},
235+
{
236+
Effect = "Allow"
237+
Action = ["dynamodb:PutItem", "dynamodb:DeleteItem"]
238+
Resource = aws_dynamodb_table.products.arn
239+
},
240+
{
241+
Effect = "Allow"
242+
Action = ["iam:PutRolePolicy", "iam:DeleteRolePolicy"]
243+
Resource = aws_iam_role.lambda_exec.arn
244+
},
209245
{
210246
Effect = "Allow"
211247
Action = ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"]
@@ -448,6 +484,7 @@ resource "aws_api_gateway_integration" "post_order_handler" {
448484
http_method = aws_api_gateway_method.post_order.http_method
449485
integration_http_method = "POST"
450486
type = "AWS_PROXY"
487+
credentials = aws_iam_role.apigw_exec.arn
451488
uri = aws_lambda_function.order_handler.invoke_arn
452489
}
453490

@@ -457,6 +494,7 @@ resource "aws_api_gateway_integration" "get_orders_handler" {
457494
http_method = aws_api_gateway_method.get_orders.http_method
458495
integration_http_method = "POST"
459496
type = "AWS_PROXY"
497+
credentials = aws_iam_role.apigw_exec.arn
460498
uri = aws_lambda_function.order_handler.invoke_arn
461499
}
462500

@@ -466,6 +504,7 @@ resource "aws_api_gateway_integration" "options_order_handler" {
466504
http_method = aws_api_gateway_method.options_orders.http_method
467505
integration_http_method = "POST"
468506
type = "AWS_PROXY"
507+
credentials = aws_iam_role.apigw_exec.arn
469508
uri = aws_lambda_function.order_handler.invoke_arn
470509
}
471510

@@ -495,6 +534,7 @@ resource "aws_api_gateway_integration" "get_products_handler" {
495534
http_method = aws_api_gateway_method.get_products.http_method
496535
integration_http_method = "POST"
497536
type = "AWS_PROXY"
537+
credentials = aws_iam_role.apigw_exec.arn
498538
uri = aws_lambda_function.order_handler.invoke_arn
499539
}
500540

@@ -504,6 +544,116 @@ resource "aws_api_gateway_integration" "options_products_handler" {
504544
http_method = aws_api_gateway_method.options_products.http_method
505545
integration_http_method = "POST"
506546
type = "AWS_PROXY"
547+
credentials = aws_iam_role.apigw_exec.arn
548+
uri = aws_lambda_function.order_handler.invoke_arn
549+
}
550+
551+
resource "aws_api_gateway_method" "post_product" {
552+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
553+
resource_id = aws_api_gateway_resource.products.id
554+
http_method = "POST"
555+
authorization = "NONE"
556+
}
557+
558+
resource "aws_api_gateway_integration" "post_product_handler" {
559+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
560+
resource_id = aws_api_gateway_resource.products.id
561+
http_method = aws_api_gateway_method.post_product.http_method
562+
integration_http_method = "POST"
563+
type = "AWS_PROXY"
564+
credentials = aws_iam_role.apigw_exec.arn
565+
uri = aws_lambda_function.order_handler.invoke_arn
566+
}
567+
568+
resource "aws_api_gateway_resource" "product_id" {
569+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
570+
parent_id = aws_api_gateway_resource.products.id
571+
path_part = "{product_id}"
572+
}
573+
574+
resource "aws_api_gateway_method" "delete_product" {
575+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
576+
resource_id = aws_api_gateway_resource.product_id.id
577+
http_method = "DELETE"
578+
authorization = "NONE"
579+
}
580+
581+
resource "aws_api_gateway_integration" "delete_product_handler" {
582+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
583+
resource_id = aws_api_gateway_resource.product_id.id
584+
http_method = aws_api_gateway_method.delete_product.http_method
585+
integration_http_method = "POST"
586+
type = "AWS_PROXY"
587+
credentials = aws_iam_role.apigw_exec.arn
588+
uri = aws_lambda_function.order_handler.invoke_arn
589+
}
590+
591+
resource "aws_api_gateway_method" "options_product_id" {
592+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
593+
resource_id = aws_api_gateway_resource.product_id.id
594+
http_method = "OPTIONS"
595+
authorization = "NONE"
596+
}
597+
598+
resource "aws_api_gateway_integration" "options_product_id_handler" {
599+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
600+
resource_id = aws_api_gateway_resource.product_id.id
601+
http_method = aws_api_gateway_method.options_product_id.http_method
602+
integration_http_method = "POST"
603+
type = "AWS_PROXY"
604+
credentials = aws_iam_role.apigw_exec.arn
605+
uri = aws_lambda_function.order_handler.invoke_arn
606+
}
607+
608+
resource "aws_api_gateway_resource" "iam" {
609+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
610+
parent_id = aws_api_gateway_rest_api.orders_api.root_resource_id
611+
path_part = "iam"
612+
}
613+
614+
resource "aws_api_gateway_resource" "iam_fix" {
615+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
616+
parent_id = aws_api_gateway_resource.iam.id
617+
path_part = "fix"
618+
}
619+
620+
resource "aws_api_gateway_method" "post_iam_fix" {
621+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
622+
resource_id = aws_api_gateway_resource.iam_fix.id
623+
http_method = "POST"
624+
authorization = "NONE"
625+
}
626+
627+
resource "aws_api_gateway_integration" "post_iam_fix_handler" {
628+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
629+
resource_id = aws_api_gateway_resource.iam_fix.id
630+
http_method = aws_api_gateway_method.post_iam_fix.http_method
631+
integration_http_method = "POST"
632+
type = "AWS_PROXY"
633+
credentials = aws_iam_role.apigw_exec.arn
634+
uri = aws_lambda_function.order_handler.invoke_arn
635+
}
636+
637+
resource "aws_api_gateway_resource" "iam_revoke" {
638+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
639+
parent_id = aws_api_gateway_resource.iam.id
640+
path_part = "revoke"
641+
}
642+
643+
resource "aws_api_gateway_method" "post_iam_revoke" {
644+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
645+
resource_id = aws_api_gateway_resource.iam_revoke.id
646+
http_method = "POST"
647+
authorization = "NONE"
648+
}
649+
650+
resource "aws_api_gateway_integration" "post_iam_revoke_handler" {
651+
rest_api_id = aws_api_gateway_rest_api.orders_api.id
652+
resource_id = aws_api_gateway_resource.iam_revoke.id
653+
http_method = aws_api_gateway_method.post_iam_revoke.http_method
654+
integration_http_method = "POST"
655+
type = "AWS_PROXY"
656+
credentials = aws_iam_role.apigw_exec.arn
507657
uri = aws_lambda_function.order_handler.invoke_arn
508658
}
509659

@@ -533,6 +683,7 @@ resource "aws_api_gateway_integration" "post_replay_handler" {
533683
http_method = aws_api_gateway_method.post_replay.http_method
534684
integration_http_method = "POST"
535685
type = "AWS_PROXY"
686+
credentials = aws_iam_role.apigw_exec.arn
536687
uri = aws_lambda_function.order_handler.invoke_arn
537688
}
538689

@@ -542,6 +693,7 @@ resource "aws_api_gateway_integration" "options_replay_handler" {
542693
http_method = aws_api_gateway_method.options_replay.http_method
543694
integration_http_method = "POST"
544695
type = "AWS_PROXY"
696+
credentials = aws_iam_role.apigw_exec.arn
545697
uri = aws_lambda_function.order_handler.invoke_arn
546698
}
547699

@@ -565,6 +717,11 @@ resource "aws_api_gateway_deployment" "orders_api" {
565717
aws_api_gateway_integration.options_replay_handler.id,
566718
aws_api_gateway_integration.get_products_handler.id,
567719
aws_api_gateway_integration.options_products_handler.id,
720+
aws_api_gateway_integration.post_product_handler.id,
721+
aws_api_gateway_integration.delete_product_handler.id,
722+
aws_api_gateway_integration.options_product_id_handler.id,
723+
aws_api_gateway_integration.post_iam_fix_handler.id,
724+
aws_api_gateway_integration.post_iam_revoke_handler.id,
568725
]))
569726
}
570727

@@ -576,6 +733,11 @@ resource "aws_api_gateway_deployment" "orders_api" {
576733
aws_api_gateway_integration.options_replay_handler,
577734
aws_api_gateway_integration.get_products_handler,
578735
aws_api_gateway_integration.options_products_handler,
736+
aws_api_gateway_integration.post_product_handler,
737+
aws_api_gateway_integration.delete_product_handler,
738+
aws_api_gateway_integration.options_product_id_handler,
739+
aws_api_gateway_integration.post_iam_fix_handler,
740+
aws_api_gateway_integration.post_iam_revoke_handler,
579741
]
580742
}
581743

0 commit comments

Comments
 (0)