thinkwork-cli 0.8.2 → 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.
Files changed (57) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +18 -2
  3. package/dist/cli.js +3004 -215
  4. package/dist/terraform/examples/greenfield/main.tf +325 -19
  5. package/dist/terraform/examples/greenfield/terraform.tfvars.example +14 -0
  6. package/dist/terraform/modules/app/agentcore-code-interpreter/Dockerfile.sandbox-base +61 -0
  7. package/dist/terraform/modules/app/agentcore-code-interpreter/README.md +54 -0
  8. package/dist/terraform/modules/app/agentcore-code-interpreter/main.tf +197 -0
  9. package/dist/terraform/modules/app/agentcore-code-interpreter/scripts/build_and_push_sandbox_base.sh +70 -0
  10. package/dist/terraform/modules/app/agentcore-flue/README.md +58 -0
  11. package/dist/terraform/modules/app/agentcore-flue/main.tf +322 -0
  12. package/dist/terraform/modules/app/agentcore-flue/outputs.tf +23 -0
  13. package/dist/terraform/modules/app/agentcore-flue/variables.tf +91 -0
  14. package/dist/terraform/modules/app/agentcore-memory/scripts/create_or_find_memory.sh +0 -0
  15. package/dist/terraform/modules/app/agentcore-runtime/main.tf +204 -4
  16. package/dist/terraform/modules/app/appsync-subscriptions/main.tf +4 -0
  17. package/dist/terraform/modules/app/appsync-subscriptions/outputs.tf +5 -0
  18. package/dist/terraform/modules/app/computer-runtime/README.md +15 -0
  19. package/dist/terraform/modules/app/computer-runtime/main.tf +406 -0
  20. package/dist/terraform/modules/app/computer-runtime/outputs.tf +75 -0
  21. package/dist/terraform/modules/app/computer-runtime/variables.tf +66 -0
  22. package/dist/terraform/modules/app/hindsight-memory/main.tf +6 -0
  23. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +128 -0
  24. package/dist/terraform/modules/app/lambda-api/handlers.tf +1557 -42
  25. package/dist/terraform/modules/app/lambda-api/main.tf +299 -15
  26. package/dist/terraform/modules/app/lambda-api/mcp-oauth.tf +118 -0
  27. package/dist/terraform/modules/app/lambda-api/oauth-secrets.tf +49 -0
  28. package/dist/terraform/modules/app/lambda-api/outputs.tf +38 -0
  29. package/dist/terraform/modules/app/lambda-api/slack-app-secrets.tf +43 -0
  30. package/dist/terraform/modules/app/lambda-api/stripe-secrets.tf +53 -0
  31. package/dist/terraform/modules/app/lambda-api/variables.tf +349 -2
  32. package/dist/terraform/modules/app/lambda-api/workspace-events.tf +125 -0
  33. package/dist/terraform/modules/app/routines-stepfunctions/main.tf +453 -0
  34. package/dist/terraform/modules/app/sandbox-log-scrubber/README.md +66 -0
  35. package/dist/terraform/modules/app/sandbox-log-scrubber/main.tf +200 -0
  36. package/dist/terraform/modules/app/static-site/main.tf +146 -5
  37. package/dist/terraform/modules/app/www-dns/main.tf +118 -15
  38. package/dist/terraform/modules/app/www-dns/outputs.tf +10 -0
  39. package/dist/terraform/modules/app/www-dns/variables.tf +42 -0
  40. package/dist/terraform/modules/data/aurora-postgres/main.tf +164 -3
  41. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +34 -0
  42. package/dist/terraform/modules/data/aurora-postgres/variables.tf +16 -0
  43. package/dist/terraform/modules/data/compliance-audit-bucket/README.md +145 -0
  44. package/dist/terraform/modules/data/compliance-audit-bucket/main.tf +573 -0
  45. package/dist/terraform/modules/data/compliance-audit-bucket/outputs.tf +43 -0
  46. package/dist/terraform/modules/data/compliance-audit-bucket/variables.tf +93 -0
  47. package/dist/terraform/modules/data/compliance-exports-bucket/main.tf +269 -0
  48. package/dist/terraform/modules/data/compliance-exports-bucket/outputs.tf +23 -0
  49. package/dist/terraform/modules/data/compliance-exports-bucket/variables.tf +50 -0
  50. package/dist/terraform/modules/data/s3-backups-bucket/main.tf +123 -0
  51. package/dist/terraform/modules/data/s3-buckets/main.tf +13 -0
  52. package/dist/terraform/modules/foundation/cognito/variables.tf +5 -2
  53. package/dist/terraform/modules/thinkwork/main.tf +439 -21
  54. package/dist/terraform/modules/thinkwork/outputs.tf +121 -0
  55. package/dist/terraform/modules/thinkwork/variables.tf +165 -6
  56. package/dist/terraform/schema.graphql +45 -0
  57. 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
+ }