meal-order-manager/terraform/iam.tf
Adam Moussa 1f67543f16
fix(ci): satisfy PR policy and authorizer trust constraints
Move weekly-menu step expressions into env blocks, terraform-fmt SES
alignment, and pin API Gateway authorizer invoke role assume conditions.
2026-08-07 19:33:49 -04:00

422 lines
13 KiB
HCL

# Execution roles for the seven Lambda functions plus the API Gateway role that
# invokes the admin authorizer.
#
# Every role is created under the /tf-managed/ path and carries the account's
# seahaven-lambda-execution-boundary permissions boundary. The path is what
# distinguishes Terraform-owned roles from the /cfn-managed/ roles the retired
# SAM stack created.
#
# The inline policies below are hand-expanded from the SAM policy templates in
# template.yaml (DynamoDBCrudPolicy, DynamoDBReadPolicy, S3CrudPolicy,
# S3ReadPolicy). Two deliberate narrowings from the SAM expansions:
# - s3:PutObjectAcl is omitted. The reports bucket enforces
# BucketOwnerEnforced ownership, so ACL writes fail regardless.
# - s3:GetLifecycleConfiguration / s3:PutLifecycleConfiguration are omitted.
# Bucket lifecycle is owned by this configuration, not by function code.
data "aws_iam_policy_document" "lambda_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
# ---------------------------------------------------------------------------
# Reusable policy documents
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "dynamodb_crud" {
statement {
sid = "OrdersTableCrud"
effect = "Allow"
actions = [
"dynamodb:BatchGetItem",
"dynamodb:BatchWriteItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DeleteItem",
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:UpdateItem",
]
resources = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*",
]
}
}
data "aws_iam_policy_document" "dynamodb_read" {
statement {
sid = "OrdersTableRead"
effect = "Allow"
actions = [
"dynamodb:BatchGetItem",
"dynamodb:ConditionCheckItem",
"dynamodb:DescribeTable",
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:Scan",
]
resources = [
aws_dynamodb_table.orders.arn,
"${aws_dynamodb_table.orders.arn}/index/*",
]
}
}
data "aws_iam_policy_document" "ssm_read" {
statement {
sid = "ReadProjectParameters"
effect = "Allow"
actions = ["ssm:GetParameter"]
resources = [local.ssm_parameter_arn_wildcard]
}
}
# The SAM template granted secretsmanager:GetSecretValue on
# `secret:meal-order-manager/*`. Scoped here to the one secret the code actually
# reads, supplied as an ARN so the grant cannot drift onto a future secret that
# happens to share the prefix.
data "aws_iam_policy_document" "slack_bot_secret_read" {
statement {
sid = "ReadSlackBotToken"
effect = "Allow"
actions = ["secretsmanager:GetSecretValue"]
resources = [var.slack_bot_secret_arn]
}
}
# ---------------------------------------------------------------------------
# submit-order
# ---------------------------------------------------------------------------
resource "aws_iam_role" "submit_order" {
name = "${local.project}-submit-order"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "submit_order_basic" {
role = aws_iam_role.submit_order.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "submit_order" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_crud.json,
data.aws_iam_policy_document.ssm_read.json,
]
statement {
sid = "InvokeSlackNotifier"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.slack_notifier.arn]
}
# Read-only access to the weekly summary PDFs only — not the payroll or order
# CSVs — for the admin summary-pdf presigned-URL endpoint.
statement {
sid = "ReadWeeklySummaryPdfs"
effect = "Allow"
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.reports.arn}/reports/*/weekly-summary-*.pdf"]
}
}
resource "aws_iam_role_policy" "submit_order" {
name = "submit-order"
role = aws_iam_role.submit_order.id
policy = data.aws_iam_policy_document.submit_order.json
}
# ---------------------------------------------------------------------------
# admin-authorizer
# ---------------------------------------------------------------------------
resource "aws_iam_role" "admin_authorizer" {
name = "${local.project}-admin-authorizer"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "admin_authorizer_basic" {
role = aws_iam_role.admin_authorizer.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "admin_authorizer" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_read.json,
data.aws_iam_policy_document.ssm_read.json,
]
}
resource "aws_iam_role_policy" "admin_authorizer" {
name = "admin-authorizer"
role = aws_iam_role.admin_authorizer.id
policy = data.aws_iam_policy_document.admin_authorizer.json
}
# Role API Gateway assumes to invoke the authorizer Lambda. The authorizer has
# no resource policy of its own; this identity-based grant is the only path.
# SourceAccount + execute-api ArnLike close the confused-deputy window without
# pinning the authorizer id (that would cycle: authorizer needs this role).
data "aws_iam_policy_document" "apigateway_assume" {
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["apigateway.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [local.account_id]
}
condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = ["arn:aws:execute-api:${var.aws_region}:${local.account_id}:${aws_apigatewayv2_api.order_api.id}/*"]
}
}
}
resource "aws_iam_role" "admin_authorizer_invoke" {
name = "${local.project}-admin-authorizer-invoke"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.apigateway_assume.json
permissions_boundary = local.boundary_arn
}
data "aws_iam_policy_document" "admin_authorizer_invoke" {
statement {
sid = "InvokeAdminAuthorizer"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.admin_authorizer.arn]
}
}
resource "aws_iam_role_policy" "admin_authorizer_invoke" {
name = "invoke-admin-authorizer"
role = aws_iam_role.admin_authorizer_invoke.id
policy = data.aws_iam_policy_document.admin_authorizer_invoke.json
}
# ---------------------------------------------------------------------------
# close-form
# ---------------------------------------------------------------------------
resource "aws_iam_role" "close_form" {
name = "${local.project}-close-form"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "close_form_basic" {
role = aws_iam_role.close_form.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "close_form" {
source_policy_documents = [data.aws_iam_policy_document.dynamodb_crud.json]
statement {
sid = "InvokeAggregateOrders"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.aggregate_orders.arn]
}
}
resource "aws_iam_role_policy" "close_form" {
name = "close-form"
role = aws_iam_role.close_form.id
policy = data.aws_iam_policy_document.close_form.json
}
# ---------------------------------------------------------------------------
# aggregate-orders
# ---------------------------------------------------------------------------
resource "aws_iam_role" "aggregate_orders" {
name = "${local.project}-aggregate-orders"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "aggregate_orders_basic" {
role = aws_iam_role.aggregate_orders.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "aggregate_orders" {
source_policy_documents = [data.aws_iam_policy_document.dynamodb_crud.json]
statement {
sid = "ReportsBucketCrud"
effect = "Allow"
actions = [
"s3:DeleteObject",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:PutObject",
]
resources = ["${aws_s3_bucket.reports.arn}/*"]
}
statement {
sid = "ReportsBucketList"
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = [aws_s3_bucket.reports.arn]
}
statement {
sid = "InvokeSlackNotifier"
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [aws_lambda_function.slack_notifier.arn]
}
}
resource "aws_iam_role_policy" "aggregate_orders" {
name = "aggregate-orders"
role = aws_iam_role.aggregate_orders.id
policy = data.aws_iam_policy_document.aggregate_orders.json
}
# ---------------------------------------------------------------------------
# slack-notifier
# ---------------------------------------------------------------------------
resource "aws_iam_role" "slack_notifier" {
name = "${local.project}-slack-notifier"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "slack_notifier_basic" {
role = aws_iam_role.slack_notifier.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "slack_notifier" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_read.json,
data.aws_iam_policy_document.ssm_read.json,
data.aws_iam_policy_document.slack_bot_secret_read.json,
]
}
resource "aws_iam_role_policy" "slack_notifier" {
name = "slack-notifier"
role = aws_iam_role.slack_notifier.id
policy = data.aws_iam_policy_document.slack_notifier.json
}
# ---------------------------------------------------------------------------
# sync-roster
# ---------------------------------------------------------------------------
resource "aws_iam_role" "sync_roster" {
name = "${local.project}-sync-roster"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "sync_roster_basic" {
role = aws_iam_role.sync_roster.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "sync_roster" {
source_policy_documents = [
data.aws_iam_policy_document.dynamodb_crud.json,
data.aws_iam_policy_document.ssm_read.json,
data.aws_iam_policy_document.slack_bot_secret_read.json,
]
}
resource "aws_iam_role_policy" "sync_roster" {
name = "sync-roster"
role = aws_iam_role.sync_roster.id
policy = data.aws_iam_policy_document.sync_roster.json
}
# ---------------------------------------------------------------------------
# email-report
# ---------------------------------------------------------------------------
resource "aws_iam_role" "email_report" {
name = "${local.project}-email-report"
path = "/tf-managed/"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
permissions_boundary = local.boundary_arn
}
resource "aws_iam_role_policy_attachment" "email_report_basic" {
role = aws_iam_role.email_report.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
data "aws_iam_policy_document" "email_report" {
source_policy_documents = [data.aws_iam_policy_document.dynamodb_read.json]
statement {
sid = "ReportsBucketRead"
effect = "Allow"
actions = ["s3:GetObject", "s3:GetObjectVersion"]
resources = ["${aws_s3_bucket.reports.arn}/*"]
}
statement {
sid = "ReportsBucketList"
effect = "Allow"
actions = ["s3:GetBucketLocation", "s3:ListBucket"]
resources = [aws_s3_bucket.reports.arn]
}
# Scope SendRawEmail to the verified sender domain/identity rather than "*".
# SES still enforces verification; IAM pins the From identity ARNs.
statement {
sid = "SendPayrollReport"
effect = "Allow"
actions = ["ses:SendRawEmail"]
resources = [
"arn:aws:ses:${var.aws_region}:${local.account_id}:identity/${var.sender_email}",
"arn:aws:ses:${var.aws_region}:${local.account_id}:identity/seahavenind.com",
]
}
}
resource "aws_iam_role_policy" "email_report" {
name = "email-report"
role = aws_iam_role.email_report.id
policy = data.aws_iam_policy_document.email_report.json
}