thinkwork-cli 0.9.0 → 0.9.1
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/LICENSE +202 -0
- package/README.md +2 -2
- package/dist/cli.js +1187 -315
- package/dist/terraform/examples/greenfield/main.tf +325 -19
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +14 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +61 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +54 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +197 -0
- package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +70 -0
- package/dist/terraform/modules/app/agentcore-flue/README.md +58 -0
- package/dist/terraform/modules/app/agentcore-flue/main.tf +322 -0
- package/dist/terraform/modules/app/agentcore-flue/outputs.tf +23 -0
- package/dist/terraform/modules/app/agentcore-flue/variables.tf +91 -0
- package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +0 -0
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +165 -0
- package/dist/terraform/modules/app/appsync-subscriptions/main.tf +4 -0
- package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +5 -0
- package/dist/terraform/modules/app/computer-runtime/README.md +15 -0
- package/dist/terraform/modules/app/computer-runtime/main.tf +406 -0
- package/dist/terraform/modules/app/computer-runtime/outputs.tf +75 -0
- package/dist/terraform/modules/app/computer-runtime/variables.tf +66 -0
- package/dist/terraform/modules/app/hindsight-memory/main.tf +6 -0
- package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +128 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +1454 -43
- package/dist/terraform/modules/app/lambda-api/main.tf +221 -12
- package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +118 -0
- package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +49 -0
- package/dist/terraform/modules/app/lambda-api/outputs.tf +38 -0
- package/dist/terraform/modules/app/lambda-api/slack-app-secrets.tf +43 -0
- package/dist/terraform/modules/app/lambda-api/stripe-secrets.tf +53 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +349 -2
- package/dist/terraform/modules/app/lambda-api/workspace-events.tf +125 -0
- package/dist/terraform/modules/app/routines-stepfunctions/main.tf +453 -0
- package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +66 -0
- package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +200 -0
- package/dist/terraform/modules/app/static-site/main.tf +146 -5
- package/dist/terraform/modules/app/www-dns/main.tf +118 -15
- package/dist/terraform/modules/app/www-dns/outputs.tf +10 -0
- package/dist/terraform/modules/app/www-dns/variables.tf +42 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +164 -3
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +34 -0
- package/dist/terraform/modules/data/aurora-postgres/variables.tf +16 -0
- package/dist/terraform/modules/data/compliance-audit-bucket/README.md +145 -0
- package/dist/terraform/modules/data/compliance-audit-bucket/main.tf +573 -0
- package/dist/terraform/modules/data/compliance-audit-bucket/outputs.tf +43 -0
- package/dist/terraform/modules/data/compliance-audit-bucket/variables.tf +93 -0
- package/dist/terraform/modules/data/compliance-exports-bucket/main.tf +269 -0
- package/dist/terraform/modules/data/compliance-exports-bucket/outputs.tf +23 -0
- package/dist/terraform/modules/data/compliance-exports-bucket/variables.tf +50 -0
- package/dist/terraform/modules/data/s3-backups-bucket/main.tf +123 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +13 -0
- package/dist/terraform/modules/foundation/cognito/variables.tf +2 -2
- package/dist/terraform/modules/thinkwork/main.tf +439 -21
- package/dist/terraform/modules/thinkwork/outputs.tf +121 -0
- package/dist/terraform/modules/thinkwork/variables.tf +153 -2
- package/dist/terraform/schema.graphql +17 -0
- package/package.json +15 -14
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Compliance Audit Bucket — Data Module
|
|
3
|
+
#
|
|
4
|
+
# WORM-protected S3 bucket for SOC2 Type 1 tamper-evident audit anchoring.
|
|
5
|
+
# Stores Merkle-anchor evidence (anchors/) and per-tenant proof slices
|
|
6
|
+
# (proofs/). The bucket itself is the substrate; the anchor Lambda (U8a/U8b)
|
|
7
|
+
# is the writer; the audit-verifier CLI (U9) is the reader.
|
|
8
|
+
#
|
|
9
|
+
# Object Lock posture:
|
|
10
|
+
# - Enabled at create time (one-way commit; cannot be disabled later).
|
|
11
|
+
# - Default retention = var.retention_days (365 days = SOC2 Type 1 baseline).
|
|
12
|
+
# - Default mode = GOVERNANCE (var.mode); flip to COMPLIANCE in prod via
|
|
13
|
+
# tfvars at audit-engagement time. COMPLIANCE is irreversible — even AWS
|
|
14
|
+
# root cannot delete or shorten retention until it expires.
|
|
15
|
+
#
|
|
16
|
+
# Prefix contract:
|
|
17
|
+
# - anchors/ — WORM-protected Merkle anchors (subject to default retention).
|
|
18
|
+
# - proofs/ — per-tenant proof slices written by the anchor Lambda. The
|
|
19
|
+
# bucket-level lock applies, but U8b sets a shorter per-object
|
|
20
|
+
# retention because slices are derivable from the chain + anchor.
|
|
21
|
+
#
|
|
22
|
+
# Bucket policy (defense-in-depth):
|
|
23
|
+
# - EnforceHTTPS: deny any s3:* over plain HTTP.
|
|
24
|
+
# - DenyDeleteObject: deny s3:DeleteObject and s3:DeleteObjectVersion from
|
|
25
|
+
# any principal — even bypassing Object Lock can't satisfy this policy.
|
|
26
|
+
#
|
|
27
|
+
# IAM role (anchor Lambda's eventual identity, defined below):
|
|
28
|
+
# - Allow s3:PutObject + s3:PutObjectRetention + s3:GetObject +
|
|
29
|
+
# s3:GetObjectRetention on ${bucket}/anchors/* and ${bucket}/proofs/*.
|
|
30
|
+
# - Allow s3:GetBucketObjectLockConfiguration on ${bucket}.
|
|
31
|
+
# - Allow kms:GenerateDataKey + kms:Decrypt + kms:DescribeKey on the CMK.
|
|
32
|
+
# - **Explicit Deny** s3:BypassGovernanceRetention and s3:PutObjectLegalHold
|
|
33
|
+
# on ${bucket}/* — the explicit deny survives any future broadening of
|
|
34
|
+
# the role's IAM grants (matches master plan U7 line 517).
|
|
35
|
+
#
|
|
36
|
+
# Inert seam:
|
|
37
|
+
# - U7 ships the role; no Lambda assumes it until U8a (master plan
|
|
38
|
+
# Decision #9 — inert→live seam swap).
|
|
39
|
+
#
|
|
40
|
+
# `force_destroy = false` is hardcoded. Object Lock + force_destroy is
|
|
41
|
+
# pathologically incompatible: COMPLIANCE-mode buckets cannot be emptied
|
|
42
|
+
# until retention expires; GOVERNANCE-mode emptying requires
|
|
43
|
+
# s3:BypassGovernanceRetention which we explicitly deny on the Lambda role.
|
|
44
|
+
# Dev cleanup is documented in README.md (admin role + bypass flag).
|
|
45
|
+
################################################################################
|
|
46
|
+
|
|
47
|
+
resource "aws_s3_bucket" "anchor" {
|
|
48
|
+
bucket = var.bucket_name
|
|
49
|
+
object_lock_enabled = true
|
|
50
|
+
|
|
51
|
+
# Hardcoded false — see header comment.
|
|
52
|
+
force_destroy = false
|
|
53
|
+
|
|
54
|
+
tags = {
|
|
55
|
+
Name = var.bucket_name
|
|
56
|
+
Stage = var.stage
|
|
57
|
+
Purpose = "compliance-anchors"
|
|
58
|
+
Retention = "${var.retention_days}d"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
resource "aws_s3_bucket_versioning" "anchor" {
|
|
63
|
+
bucket = aws_s3_bucket.anchor.id
|
|
64
|
+
|
|
65
|
+
# Object Lock requires versioning. Enabling Object Lock at bucket creation
|
|
66
|
+
# auto-enables versioning at the AWS level, but the provider model still
|
|
67
|
+
# expects this resource declared for state tracking and drift detection.
|
|
68
|
+
versioning_configuration {
|
|
69
|
+
status = "Enabled"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
resource "aws_s3_bucket_object_lock_configuration" "anchor" {
|
|
74
|
+
bucket = aws_s3_bucket.anchor.id
|
|
75
|
+
|
|
76
|
+
# AWS rejects PutBucketObjectLockConfiguration before versioning is
|
|
77
|
+
# Enabled. The explicit dependency prevents Terraform from applying both
|
|
78
|
+
# in parallel and getting a 400 from S3 (InvalidRequest: Versioning must
|
|
79
|
+
# be Enabled). We use the standalone resource (not the deprecated inline
|
|
80
|
+
# block on aws_s3_bucket) because the inline form is not drift-detected
|
|
81
|
+
# by Terraform — see HashiCorp v4.0 S3 refactor blog.
|
|
82
|
+
depends_on = [aws_s3_bucket_versioning.anchor]
|
|
83
|
+
|
|
84
|
+
# Production stages must run COMPLIANCE mode. GOVERNANCE-mode in prod
|
|
85
|
+
# would let any principal with s3:BypassGovernanceRetention shorten or
|
|
86
|
+
# delete retention windows — the auditor question "can anyone bypass
|
|
87
|
+
# this?" gets the wrong answer. README documents the cutover playbook
|
|
88
|
+
# (one-line tfvars change at audit-engagement time). This precondition
|
|
89
|
+
# closes the operator-memory gap by failing the plan instead of silently
|
|
90
|
+
# shipping a misconfigured bucket.
|
|
91
|
+
lifecycle {
|
|
92
|
+
precondition {
|
|
93
|
+
condition = !(contains(["prod", "production"], var.stage) && var.mode == "GOVERNANCE")
|
|
94
|
+
error_message = "var.mode must be COMPLIANCE for prod stages (var.stage = '${var.stage}'). See terraform/modules/data/compliance-audit-bucket/README.md COMPLIANCE-mode cutover playbook. Override at audit-engagement time via the composite-root tfvars compliance_anchor_object_lock_mode = \"COMPLIANCE\"."
|
|
95
|
+
}
|
|
96
|
+
# Phase 3 U8b — block COMPLIANCE on non-prod by default (Decision #18).
|
|
97
|
+
# COMPLIANCE bytes are unrecoverable for the full retention window even by
|
|
98
|
+
# AWS root, so a typo'd stage name producing a dev-bucket COMPLIANCE
|
|
99
|
+
# cluster is a one-way disaster. The operator sets
|
|
100
|
+
# `allow_compliance_in_non_prod = true` in tfvars on the specific
|
|
101
|
+
# non-prod stage where COMPLIANCE is intentional (e.g., a staging
|
|
102
|
+
# rehearsal stage during audit prep).
|
|
103
|
+
precondition {
|
|
104
|
+
condition = !(!contains(["prod", "production"], var.stage) && var.mode == "COMPLIANCE") || var.allow_compliance_in_non_prod
|
|
105
|
+
error_message = "var.mode = \"COMPLIANCE\" on non-prod stage '${var.stage}' is blocked by default. COMPLIANCE bytes are unrecoverable for the full ${var.retention_days}-day retention window. To intentionally enable on this stage, set `allow_compliance_in_non_prod = true` in tfvars. See terraform/modules/data/compliance-audit-bucket/README.md."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
rule {
|
|
110
|
+
default_retention {
|
|
111
|
+
mode = var.mode
|
|
112
|
+
days = var.retention_days
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
resource "aws_s3_bucket_server_side_encryption_configuration" "anchor" {
|
|
118
|
+
bucket = aws_s3_bucket.anchor.id
|
|
119
|
+
|
|
120
|
+
# Resource-level precondition: belt-and-suspenders alongside the variable
|
|
121
|
+
# validation. If module.kms.key_arn is ever empty (e.g., create_kms_key =
|
|
122
|
+
# false somewhere upstream without an existing_kms_key_arn), fail at plan
|
|
123
|
+
# time, not apply time.
|
|
124
|
+
lifecycle {
|
|
125
|
+
precondition {
|
|
126
|
+
condition = length(var.kms_key_arn) > 0
|
|
127
|
+
error_message = "kms_key_arn must be non-empty — check that module.kms is enabled in the composite root."
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
rule {
|
|
132
|
+
apply_server_side_encryption_by_default {
|
|
133
|
+
sse_algorithm = "aws:kms"
|
|
134
|
+
kms_master_key_id = var.kms_key_arn
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
# Bucket Key reduces KMS request volume by up to 99%. With Bucket Key
|
|
138
|
+
# enabled, the KMS encryption context is the bucket ARN, not the
|
|
139
|
+
# object ARN — relevant if a future per-object encryption-context
|
|
140
|
+
# condition lands.
|
|
141
|
+
bucket_key_enabled = true
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
resource "aws_s3_bucket_public_access_block" "anchor" {
|
|
146
|
+
bucket = aws_s3_bucket.anchor.id
|
|
147
|
+
|
|
148
|
+
block_public_acls = true
|
|
149
|
+
block_public_policy = true
|
|
150
|
+
ignore_public_acls = true
|
|
151
|
+
restrict_public_buckets = true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
resource "aws_s3_bucket_lifecycle_configuration" "anchor" {
|
|
155
|
+
bucket = aws_s3_bucket.anchor.id
|
|
156
|
+
|
|
157
|
+
# Object Lock requires versioning, which the lifecycle config interacts
|
|
158
|
+
# with via noncurrent_version_*. Sequence after versioning to avoid the
|
|
159
|
+
# provider's "must enable versioning before lifecycle on a versioned
|
|
160
|
+
# bucket" warning on first apply.
|
|
161
|
+
depends_on = [aws_s3_bucket_versioning.anchor]
|
|
162
|
+
|
|
163
|
+
rule {
|
|
164
|
+
id = "anchor-glacier-ir"
|
|
165
|
+
status = "Enabled"
|
|
166
|
+
|
|
167
|
+
# Scope to anchors/ — long-lived WORM evidence is the only thing worth
|
|
168
|
+
# transitioning. proofs/ is short-lived per-tenant slice data (U8b
|
|
169
|
+
# owns retention semantics there) and small enough that Glacier IR's
|
|
170
|
+
# 90-day billing minimum eats any savings.
|
|
171
|
+
filter {
|
|
172
|
+
prefix = "anchors/"
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
transition {
|
|
176
|
+
days = 90
|
|
177
|
+
storage_class = "GLACIER_IR"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Defense for hypothetical overwrite events (anchors are append-only by
|
|
181
|
+
# design but versioning is on). 90-day floor matches the Glacier IR
|
|
182
|
+
# billing minimum.
|
|
183
|
+
noncurrent_version_transition {
|
|
184
|
+
noncurrent_days = 90
|
|
185
|
+
storage_class = "GLACIER_IR"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# **No** expiration. Object Lock retention is the deletion gate. Post-
|
|
189
|
+
# 365-day disposition (anchors become deletable but won't auto-delete)
|
|
190
|
+
# is deferred to a follow-up after SOC2 auditor guidance — see plan
|
|
191
|
+
# Scope Boundaries.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
resource "aws_s3_bucket_policy" "anchor" {
|
|
196
|
+
bucket = aws_s3_bucket.anchor.id
|
|
197
|
+
|
|
198
|
+
# aws_s3_bucket_public_access_block must apply first; otherwise account-
|
|
199
|
+
# level BPA defaults can intermittently reject the bucket-policy PUT with
|
|
200
|
+
# AccessDenied during initial apply.
|
|
201
|
+
depends_on = [aws_s3_bucket_public_access_block.anchor]
|
|
202
|
+
|
|
203
|
+
policy = jsonencode({
|
|
204
|
+
Version = "2012-10-17"
|
|
205
|
+
Statement = [
|
|
206
|
+
{
|
|
207
|
+
Sid = "EnforceHTTPS"
|
|
208
|
+
Effect = "Deny"
|
|
209
|
+
Principal = "*"
|
|
210
|
+
Action = "s3:*"
|
|
211
|
+
Resource = [
|
|
212
|
+
aws_s3_bucket.anchor.arn,
|
|
213
|
+
"${aws_s3_bucket.anchor.arn}/*",
|
|
214
|
+
]
|
|
215
|
+
Condition = {
|
|
216
|
+
Bool = {
|
|
217
|
+
"aws:SecureTransport" = "false"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
# Defense-in-depth against accidental or malicious object deletes.
|
|
223
|
+
# The anchor Lambda role's allow grants do NOT include any Delete
|
|
224
|
+
# action; this policy prevents any other principal (including a
|
|
225
|
+
# principal that gains s3:BypassGovernanceRetention via a future
|
|
226
|
+
# role broadening) from removing audit evidence. Master plan
|
|
227
|
+
# U7 line 518.
|
|
228
|
+
Sid = "DenyDeleteObject"
|
|
229
|
+
Effect = "Deny"
|
|
230
|
+
Principal = "*"
|
|
231
|
+
Action = [
|
|
232
|
+
"s3:DeleteObject",
|
|
233
|
+
"s3:DeleteObjectVersion",
|
|
234
|
+
]
|
|
235
|
+
Resource = "${aws_s3_bucket.anchor.arn}/*"
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
# Defense-in-depth against bucket-level deletion. Object Lock
|
|
239
|
+
# protects the *contents*; this protects the *container* for the
|
|
240
|
+
# post-retention window when objects become deletable. We do NOT
|
|
241
|
+
# deny PutBucketPolicy / DeleteBucketPolicy here because Terraform
|
|
242
|
+
# itself calls PutBucketPolicy to manage this resource — denying
|
|
243
|
+
# it would lock out future module updates. Policy-rewrite defense
|
|
244
|
+
# belongs at the IAM-policy layer on the deploying principal, not
|
|
245
|
+
# at the bucket-policy layer.
|
|
246
|
+
Sid = "DenyBucketDelete"
|
|
247
|
+
Effect = "Deny"
|
|
248
|
+
Principal = "*"
|
|
249
|
+
Action = [
|
|
250
|
+
"s3:DeleteBucket",
|
|
251
|
+
]
|
|
252
|
+
Resource = aws_s3_bucket.anchor.arn
|
|
253
|
+
},
|
|
254
|
+
]
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
################################################################################
|
|
259
|
+
# IAM role for the anchor Lambda (U8a/U8b assumes this role).
|
|
260
|
+
#
|
|
261
|
+
# **Inert in U7.** No Lambda function references this role yet; U8a wires
|
|
262
|
+
# the function. The role exists so U7 ships a complete, atomic infrastructure
|
|
263
|
+
# unit (master plan U7 file list).
|
|
264
|
+
################################################################################
|
|
265
|
+
|
|
266
|
+
resource "aws_iam_role" "anchor_lambda" {
|
|
267
|
+
name = "thinkwork-${var.stage}-compliance-anchor-lambda-role"
|
|
268
|
+
|
|
269
|
+
# Phase 3 U8a — `aws:SourceArn` pin via string-construction to avoid
|
|
270
|
+
# the circular dependency between this trust policy and the anchor
|
|
271
|
+
# Lambda function (defined in lambda-api/handlers.tf, which depends on
|
|
272
|
+
# this role's ARN). The function name follows the predictable pattern
|
|
273
|
+
# `thinkwork-${stage}-api-compliance-anchor` so the literal ARN is
|
|
274
|
+
# known at plan time.
|
|
275
|
+
#
|
|
276
|
+
# `StringEquals` (NOT `StringEqualsIfExists`) so a missing/empty
|
|
277
|
+
# SourceArn on the AssumeRole call DENIES rather than no-ops.
|
|
278
|
+
assume_role_policy = jsonencode({
|
|
279
|
+
Version = "2012-10-17"
|
|
280
|
+
Statement = [{
|
|
281
|
+
Effect = "Allow"
|
|
282
|
+
Principal = { Service = "lambda.amazonaws.com" }
|
|
283
|
+
Action = "sts:AssumeRole"
|
|
284
|
+
Condition = {
|
|
285
|
+
StringEquals = {
|
|
286
|
+
"aws:SourceAccount" = var.account_id
|
|
287
|
+
"aws:SourceArn" = "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-compliance-anchor"
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}]
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
resource "aws_iam_role_policy_attachment" "anchor_basic" {
|
|
295
|
+
role = aws_iam_role.anchor_lambda.name
|
|
296
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
resource "aws_iam_role_policy" "anchor_s3_allow" {
|
|
300
|
+
name = "anchor-s3"
|
|
301
|
+
role = aws_iam_role.anchor_lambda.id
|
|
302
|
+
|
|
303
|
+
policy = jsonencode({
|
|
304
|
+
Version = "2012-10-17"
|
|
305
|
+
Statement = [
|
|
306
|
+
{
|
|
307
|
+
# Object-side actions — path-scoped to the two prefixes we use.
|
|
308
|
+
# The wildcard inside the prefix is intentional (per-cadence object
|
|
309
|
+
# keys vary), but the prefix itself is fixed.
|
|
310
|
+
Sid = "AnchorObjectsAllow"
|
|
311
|
+
Effect = "Allow"
|
|
312
|
+
Action = [
|
|
313
|
+
"s3:PutObject",
|
|
314
|
+
"s3:PutObjectRetention",
|
|
315
|
+
"s3:GetObject",
|
|
316
|
+
"s3:GetObjectRetention",
|
|
317
|
+
]
|
|
318
|
+
Resource = [
|
|
319
|
+
"${aws_s3_bucket.anchor.arn}/anchors/*",
|
|
320
|
+
"${aws_s3_bucket.anchor.arn}/proofs/*",
|
|
321
|
+
]
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
# Bucket-side action — read-only metadata about Object Lock config
|
|
325
|
+
# (the Lambda verifies the bucket is locked before writing).
|
|
326
|
+
Sid = "AnchorBucketAllow"
|
|
327
|
+
Effect = "Allow"
|
|
328
|
+
Action = [
|
|
329
|
+
"s3:GetBucketObjectLockConfiguration",
|
|
330
|
+
]
|
|
331
|
+
Resource = aws_s3_bucket.anchor.arn
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
# **Explicit Deny** — survives a future broadening of the role's
|
|
335
|
+
# IAM grants. Without this, a permissions-boundary change, an
|
|
336
|
+
# AWS-managed-policy attachment containing s3:*, or an SCP grant
|
|
337
|
+
# could silently re-enable WORM bypass. Master plan U7 line 517.
|
|
338
|
+
Sid = "DenyWormBypass"
|
|
339
|
+
Effect = "Deny"
|
|
340
|
+
Action = [
|
|
341
|
+
"s3:BypassGovernanceRetention",
|
|
342
|
+
"s3:PutObjectLegalHold",
|
|
343
|
+
]
|
|
344
|
+
Resource = "${aws_s3_bucket.anchor.arn}/*"
|
|
345
|
+
},
|
|
346
|
+
]
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
resource "aws_iam_role_policy" "anchor_kms" {
|
|
351
|
+
name = "anchor-kms"
|
|
352
|
+
role = aws_iam_role.anchor_lambda.id
|
|
353
|
+
|
|
354
|
+
policy = jsonencode({
|
|
355
|
+
Version = "2012-10-17"
|
|
356
|
+
Statement = [
|
|
357
|
+
{
|
|
358
|
+
Sid = "AnchorKmsAllow"
|
|
359
|
+
Effect = "Allow"
|
|
360
|
+
# SSE-KMS PutObject (envelope encryption) needs only GenerateDataKey.
|
|
361
|
+
# DescribeKey is the SDK pre-flight check some clients perform.
|
|
362
|
+
# `kms:Decrypt` was removed in U8b — the anchor Lambda only writes;
|
|
363
|
+
# the verifier (U9, separate role) is the read path. Least-privilege.
|
|
364
|
+
Action = [
|
|
365
|
+
"kms:GenerateDataKey",
|
|
366
|
+
"kms:DescribeKey",
|
|
367
|
+
]
|
|
368
|
+
Resource = var.kms_key_arn
|
|
369
|
+
},
|
|
370
|
+
]
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
# Phase 3 U8a — Secrets Manager read for the two compliance Aurora roles
|
|
375
|
+
# the anchor Lambda connects as (compliance_reader for SELECT,
|
|
376
|
+
# compliance_drainer for UPDATE on tenant_anchor_state).
|
|
377
|
+
#
|
|
378
|
+
# Note: today the compliance secrets use `aws/secretsmanager` (default
|
|
379
|
+
# AWS-managed key) so no explicit KMS Decrypt grant is needed. If a
|
|
380
|
+
# future hardening pass migrates the secrets to a customer-managed CMK,
|
|
381
|
+
# add `kms:Decrypt` on that CMK to this role — the failure mode is a
|
|
382
|
+
# confusing AccessDeniedException from KMS, not Secrets Manager.
|
|
383
|
+
resource "aws_iam_role_policy" "anchor_secrets" {
|
|
384
|
+
name = "anchor-secrets"
|
|
385
|
+
role = aws_iam_role.anchor_lambda.id
|
|
386
|
+
|
|
387
|
+
policy = jsonencode({
|
|
388
|
+
Version = "2012-10-17"
|
|
389
|
+
Statement = [
|
|
390
|
+
{
|
|
391
|
+
Sid = "AnchorSecretsAllow"
|
|
392
|
+
Effect = "Allow"
|
|
393
|
+
Action = ["secretsmanager:GetSecretValue"]
|
|
394
|
+
Resource = [
|
|
395
|
+
var.compliance_reader_secret_arn,
|
|
396
|
+
var.compliance_drainer_secret_arn,
|
|
397
|
+
]
|
|
398
|
+
},
|
|
399
|
+
]
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
# Phase 3 U8a — CloudWatch PutMetricData for the watchdog heartbeat
|
|
404
|
+
# (Decision #16) and U8b's live ComplianceAnchorGap. Namespace-scoped
|
|
405
|
+
# via condition so the role cannot publish into other namespaces.
|
|
406
|
+
#
|
|
407
|
+
# **Note on least-privilege:** the anchor Lambda itself never emits
|
|
408
|
+
# metrics in U8a (only the watchdog does, via the shared lambda role's
|
|
409
|
+
# compliance_watchdog_metrics policy). This grant on the U7 anchor role
|
|
410
|
+
# is pre-plumbed for U8b — at which point the anchor Lambda may emit
|
|
411
|
+
# its own metrics around the live S3 path. If U8b doesn't end up
|
|
412
|
+
# needing it, this policy can be removed in U8b's PR.
|
|
413
|
+
resource "aws_iam_role_policy" "anchor_cloudwatch_metrics" {
|
|
414
|
+
name = "anchor-cloudwatch-metrics"
|
|
415
|
+
role = aws_iam_role.anchor_lambda.id
|
|
416
|
+
|
|
417
|
+
policy = jsonencode({
|
|
418
|
+
Version = "2012-10-17"
|
|
419
|
+
Statement = [
|
|
420
|
+
{
|
|
421
|
+
Sid = "AnchorMetricsAllow"
|
|
422
|
+
Effect = "Allow"
|
|
423
|
+
Action = ["cloudwatch:PutMetricData"]
|
|
424
|
+
Resource = "*"
|
|
425
|
+
Condition = {
|
|
426
|
+
StringEquals = {
|
|
427
|
+
"cloudwatch:namespace" = "Thinkwork/Compliance"
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
]
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
################################################################################
|
|
436
|
+
# Phase 3 U8b — Sibling IAM role for the anchor watchdog Lambda.
|
|
437
|
+
#
|
|
438
|
+
# The watchdog moves OFF the shared `aws_iam_role.lambda` (which is bound
|
|
439
|
+
# to ~60 unrelated handlers) onto a dedicated role. Two reasons:
|
|
440
|
+
# 1. Least privilege — the watchdog needs s3:ListBucket + s3:GetObject
|
|
441
|
+
# against the WORM bucket, plus kms:DescribeKey on the CMK. Adding
|
|
442
|
+
# those grants to the shared lambda role widens the blast radius for
|
|
443
|
+
# every other handler.
|
|
444
|
+
# 2. KMS posture — the watchdog gets `kms:DescribeKey` ONLY (NOT
|
|
445
|
+
# `kms:Decrypt`). Watchdog never reads object bodies; it issues
|
|
446
|
+
# ListObjectsV2 + LastModified metadata only. Decrypt-less is the
|
|
447
|
+
# correct boundary (Decision #5 / SEC-U8B-003).
|
|
448
|
+
#
|
|
449
|
+
# Policy boundary mirrors the anchor role: s3:* path-scoped to anchors/
|
|
450
|
+
# (the watchdog only inspects the anchors/ prefix), explicit Deny on
|
|
451
|
+
# s3:BypassGovernanceRetention + s3:PutObjectLegalHold + every Delete
|
|
452
|
+
# action so a future broadening cannot turn the watchdog into a deletion
|
|
453
|
+
# vector.
|
|
454
|
+
################################################################################
|
|
455
|
+
|
|
456
|
+
resource "aws_iam_role" "anchor_watchdog_lambda" {
|
|
457
|
+
name = "thinkwork-${var.stage}-compliance-anchor-watchdog"
|
|
458
|
+
|
|
459
|
+
# `aws:SourceArn` pin via string-construction — the watchdog Lambda's
|
|
460
|
+
# ARN follows the same predictable pattern as the anchor's, so we can
|
|
461
|
+
# tighten the trust policy without a circular dependency.
|
|
462
|
+
assume_role_policy = jsonencode({
|
|
463
|
+
Version = "2012-10-17"
|
|
464
|
+
Statement = [{
|
|
465
|
+
Effect = "Allow"
|
|
466
|
+
Principal = { Service = "lambda.amazonaws.com" }
|
|
467
|
+
Action = "sts:AssumeRole"
|
|
468
|
+
Condition = {
|
|
469
|
+
StringEquals = {
|
|
470
|
+
"aws:SourceAccount" = var.account_id
|
|
471
|
+
"aws:SourceArn" = "arn:aws:lambda:${var.region}:${var.account_id}:function:thinkwork-${var.stage}-api-compliance-anchor-watchdog"
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}]
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
resource "aws_iam_role_policy_attachment" "anchor_watchdog_basic" {
|
|
479
|
+
role = aws_iam_role.anchor_watchdog_lambda.name
|
|
480
|
+
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
resource "aws_iam_role_policy" "anchor_watchdog_s3" {
|
|
484
|
+
name = "anchor-watchdog-s3"
|
|
485
|
+
role = aws_iam_role.anchor_watchdog_lambda.id
|
|
486
|
+
|
|
487
|
+
policy = jsonencode({
|
|
488
|
+
Version = "2012-10-17"
|
|
489
|
+
Statement = [
|
|
490
|
+
{
|
|
491
|
+
# Bucket-scoped ListBucket — prefix-conditioned so the watchdog
|
|
492
|
+
# cannot enumerate proofs/ (which carries per-tenant metadata that
|
|
493
|
+
# need not be visible to a metrics-only path).
|
|
494
|
+
Sid = "WatchdogListBucket"
|
|
495
|
+
Effect = "Allow"
|
|
496
|
+
Action = ["s3:ListBucket"]
|
|
497
|
+
Resource = aws_s3_bucket.anchor.arn
|
|
498
|
+
Condition = {
|
|
499
|
+
StringLike = {
|
|
500
|
+
"s3:prefix" = ["anchors/", "anchors/*"]
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
# GetObject grant on anchors/* is reserved for future HeadObject
|
|
506
|
+
# hardening — U8b's path uses ListObjectsV2 metadata only and
|
|
507
|
+
# never fetches body bytes. Granting now avoids re-running the
|
|
508
|
+
# IAM role-policy update on a hot-path the day we add HeadObject.
|
|
509
|
+
Sid = "WatchdogGetAnchor"
|
|
510
|
+
Effect = "Allow"
|
|
511
|
+
Action = ["s3:GetObject"]
|
|
512
|
+
Resource = "${aws_s3_bucket.anchor.arn}/anchors/*"
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
# Defense-in-depth: even though the role's allow grants do not
|
|
516
|
+
# include any Delete or Bypass action, an explicit Deny survives
|
|
517
|
+
# any future role broadening (e.g., AWS-managed-policy attachment).
|
|
518
|
+
Sid = "WatchdogDenyMutations"
|
|
519
|
+
Effect = "Deny"
|
|
520
|
+
Action = [
|
|
521
|
+
"s3:DeleteObject",
|
|
522
|
+
"s3:DeleteObjectVersion",
|
|
523
|
+
"s3:PutObject",
|
|
524
|
+
"s3:PutObjectRetention",
|
|
525
|
+
"s3:PutObjectLegalHold",
|
|
526
|
+
"s3:BypassGovernanceRetention",
|
|
527
|
+
]
|
|
528
|
+
Resource = "${aws_s3_bucket.anchor.arn}/*"
|
|
529
|
+
},
|
|
530
|
+
]
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
resource "aws_iam_role_policy" "anchor_watchdog_kms" {
|
|
535
|
+
name = "anchor-watchdog-kms"
|
|
536
|
+
role = aws_iam_role.anchor_watchdog_lambda.id
|
|
537
|
+
|
|
538
|
+
policy = jsonencode({
|
|
539
|
+
Version = "2012-10-17"
|
|
540
|
+
Statement = [
|
|
541
|
+
{
|
|
542
|
+
# `kms:DescribeKey` ONLY — the watchdog never reads object bodies
|
|
543
|
+
# so it never needs `kms:Decrypt`. SEC-U8B-003.
|
|
544
|
+
Sid = "WatchdogKmsDescribe"
|
|
545
|
+
Effect = "Allow"
|
|
546
|
+
Action = ["kms:DescribeKey"]
|
|
547
|
+
Resource = var.kms_key_arn
|
|
548
|
+
},
|
|
549
|
+
]
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
resource "aws_iam_role_policy" "anchor_watchdog_cloudwatch_metrics" {
|
|
554
|
+
name = "anchor-watchdog-cloudwatch-metrics"
|
|
555
|
+
role = aws_iam_role.anchor_watchdog_lambda.id
|
|
556
|
+
|
|
557
|
+
policy = jsonencode({
|
|
558
|
+
Version = "2012-10-17"
|
|
559
|
+
Statement = [
|
|
560
|
+
{
|
|
561
|
+
Sid = "WatchdogMetricsAllow"
|
|
562
|
+
Effect = "Allow"
|
|
563
|
+
Action = ["cloudwatch:PutMetricData"]
|
|
564
|
+
Resource = "*"
|
|
565
|
+
Condition = {
|
|
566
|
+
StringEquals = {
|
|
567
|
+
"cloudwatch:namespace" = "Thinkwork/Compliance"
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
]
|
|
572
|
+
})
|
|
573
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Compliance Audit Bucket — Outputs
|
|
3
|
+
################################################################################
|
|
4
|
+
|
|
5
|
+
output "bucket_name" {
|
|
6
|
+
description = "Name (id) of the compliance audit-anchor S3 bucket."
|
|
7
|
+
value = aws_s3_bucket.anchor.id
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
output "bucket_arn" {
|
|
11
|
+
description = "ARN of the compliance audit-anchor S3 bucket."
|
|
12
|
+
value = aws_s3_bucket.anchor.arn
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
output "lambda_role_arn" {
|
|
16
|
+
description = "ARN of the IAM role the anchor Lambda (U8a/U8b) will assume. Inert in U7 — no Lambda function references this yet."
|
|
17
|
+
value = aws_iam_role.anchor_lambda.arn
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
output "lambda_role_name" {
|
|
21
|
+
description = "Name of the IAM role the anchor Lambda assumes. Used by sibling app-tier modules that need to attach inline policies (e.g., DLQ SendMessage) to this role without re-deriving the name from the ARN."
|
|
22
|
+
value = aws_iam_role.anchor_lambda.name
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
output "watchdog_role_arn" {
|
|
26
|
+
description = "ARN of the sibling IAM role the watchdog Lambda assumes (Phase 3 U8b). Decrypt-less: kms:DescribeKey only on the bucket CMK; s3:ListBucket prefix-scoped to anchors/."
|
|
27
|
+
value = aws_iam_role.anchor_watchdog_lambda.arn
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
output "watchdog_role_name" {
|
|
31
|
+
description = "Name of the sibling watchdog IAM role. Used by app-tier modules that may need to attach future inline policies (e.g., DLQ SendMessage) without re-deriving the name from the ARN."
|
|
32
|
+
value = aws_iam_role.anchor_watchdog_lambda.name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
output "kms_key_arn" {
|
|
36
|
+
description = "Pass-through of var.kms_key_arn so app-tier modules can wire the CMK ARN into the anchor Lambda's COMPLIANCE_ANCHOR_KMS_KEY_ARN env var without taking a second dependency on module.kms."
|
|
37
|
+
value = var.kms_key_arn
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
output "object_lock_mode" {
|
|
41
|
+
description = "Pass-through of var.mode so app-tier modules can wire the Object Lock mode into the anchor Lambda's COMPLIANCE_ANCHOR_OBJECT_LOCK_MODE env var. Whatever the bucket is locked to is what the per-object writes assert."
|
|
42
|
+
value = var.mode
|
|
43
|
+
}
|