I didn’t start Lab 2 with an empty account. I started with the same brownfield study account I hardened in Lab 1. Five buckets were already in it: three from a CGE-P capstone, a Terraform state store, and an evidence vault. Two capstone CMKs existed. alias/s3-compliant did not.
Lab 1 left a credential report, password-policy captures, and an MFA deny test. Those files are the proof of that baseline. They still need a place to live that an assessor will trust a year later: encrypted with a key I control, locked against silent overwrite, and unreachable over plaintext HTTP.
That landing zone is Lab 2. Lab 3’s VPC Flow Logs, Lab 4’s CloudTrail destination, Lab 5’s Config delivery channel, and Lab 9’s CI evidence uploads all write into a bucket built this way. The evidence warehouse will model whether the set is complete. This bucket is what keeps each artifact intact.
I ran it the way I’d run an engagement. I assessed the account as it was, remediated the two account-level gaps, built the bucket, and proved each control with the same CLI an assessor would run.
The build, console-first
Before I created a bucket I ran the same account-level reads I would later use as proof.

Account-level Block Public Access had never been configured. aws s3control get-public-access-block --account-id <ACCOUNT_ID> returned NoSuchPublicAccessBlockConfiguration. AC-3 / AC-6 at the account layer was an unset control, not a wrong one.

EBS encryption-by-default was off in us-east-1. The default key was alias/aws/ebs, AWS-managed. SC-28 at the volume layer was still on its factory setting.

| Finding | Control | After |
|---|---|---|
Account-level Block Public Access never configured (NoSuchPublicAccessBlockConfiguration) | AC-3 / AC-6 | All four blocks on |
EBS encryption-by-default off; default key alias/aws/ebs | SC-28 | Enabled; AWS-managed key kept |
Five pre-existing buckets, two capstone CMKs, no alias/s3-compliant | n/a | Inventory only |
I remediated those two account settings before I built anything new.
I enabled all four account-level Block Public Access flags. That setting is the future-mistakes control. Buckets I have not created yet inherit it.

I turned on “Always encrypt new EBS volumes” and left the default key as alias/aws/ebs. A customer-managed EBS key is Lab 7. CJIS wants agency-managed keys. This lab names that delta and does not treat the AWS-managed EBS key as closing it.

Then I built the evidence bucket.
I created a CMK with alias alias/s3-compliant and a minimal key policy. The key policy is the primary authorization gate, with a delegation statement for the account root. The admin/user split on that policy waits for Lab 7.

I created bucket grc-lab2-evidence-08132026 with versioning, default SSE-KMS on that CMK, S3 Bucket Key, and Object Lock in Governance mode with a 1-year default retention.


Object Lock requires versioning because a lock is attached to a version, not to a key name. A DeleteObject writes a delete marker. The locked version stays.

I used Governance mode, not Compliance. Governance lets a principal with s3:BypassGovernanceRetention shorten retention, which is how I can tear the lab down. Compliance mode cannot be shortened, even by root. Production CJI evidence would use Compliance. The lab does not.
I attached a TLS-only bucket policy, Sid DenyInsecureTransport: Deny on s3:* when aws:SecureTransport is "false". The condition uses plain Bool, not BoolIfExists. aws:SecureTransport is present on every request, so a missing key is not a fail-open path the way aws:MultiFactorAuthPresent was in Lab 1.

I enabled server access logging to grc-lab2-access-logs-08132026.

The original plan was to invoke the same pattern twice. S3 will not accept that destination. The evidence bucket is the WORM landing zone. The logs bucket is a destination S3 will actually write to.
I added lifecycle rule evidence-retention on the evidence bucket: noncurrent versions to Glacier Flexible Retrieval at 90 days, expire at 730 days. Object Lock default retention is 365 days. Lifecycle cannot override an active lock. 730 is greater than 365 on purpose. The lock wins until it expires. Lifecycle cleans up after.

I almost applied that rule to cgep-lab-grc-evidence-vault-*, a pre-existing capstone bucket whose name looks like an evidence store. Brownfield accounts accumulate near-colliding names. I caught it on a breadcrumb and pointed the rule at grc-lab2-evidence-08132026.
I turned on bucket-level Block Public Access, all four flags. Account-level BPA covers future buckets. Bucket-level BPA makes this evidence store self-contained if someone later weakens the account setting. I set both.
Verify like an assessor
I re-ran the same reads an assessor would, from CloudShell in us-east-1.
B=grc-lab2-evidence-08132026
aws s3api get-bucket-encryption --bucket $B
aws s3api get-bucket-versioning --bucket $B
aws s3api get-object-lock-configuration --bucket $B
aws s3api get-bucket-policy --bucket $B --query Policy --output text
aws ec2 get-ebs-encryption-by-default
aws s3control get-public-access-block --account-id <ACCOUNT_ID>
get-bucket-encryption returned SSEAlgorithm: aws:kms, KMSMasterKeyID: <CMK_ARN>, BucketKeyEnabled: true, and BlockedEncryptionTypes: [SSE-C].
get-bucket-versioning returned Status: Enabled.
get-object-lock-configuration returned ObjectLockEnabled: Enabled, Mode: GOVERNANCE, Years: 1.
get-bucket-policy returned the DenyInsecureTransport statement with Condition.Bool["aws:SecureTransport"] = "false".
get-ebs-encryption-by-default returned EbsEncryptionByDefault: true.
get-public-access-block returned all four flags true. Those last two are the before/after pair from pre-flight. The same commands that returned NoSuchPublicAccessBlockConfiguration and encryption-off now return the remediated state.
The negative test was an unsigned HTTP PutObject:
echo "test" > /tmp/probe.txt
aws s3api put-object --bucket $B --key probe.txt --body /tmp/probe.txt \
--no-sign-request --endpoint-url http://s3.amazonaws.com
It failed with InvalidArgument: requests specifying SSE with AWS KMS keys require Signature Version 4. The request was rejected before access control ran. An anonymous unsigned upload is structurally impossible against this default encryption. It is not an AccessDenied I happened to configure.
Control mappings
| Configuration | NIST 800-53 Rev 5 | FedRAMP High | CJIS v6.1 |
|---|---|---|---|
| SSE-KMS with customer-managed CMK | SC-28, SC-28(1) | SC-28, SC-28(1) | SC-28 (FIPS via CMK) |
| TLS-only bucket policy | SC-8, SC-8(1) | SC-8, SC-8(1) | SC-8 |
| Block Public Access (account + bucket) | AC-3, AC-6 | AC-3, AC-6 | AC-3 |
| Bucket versioning | CP-9, SI-7 | CP-9, SI-7 | CP-9 |
| Object Lock (Governance, 1-year minimum) | AU-9, AU-11 | AU-9, AU-11 | AU-9, AU-11 (CJI WORM) |
| Server access logging to separate bucket | AU-2, AU-3, AU-12 | AU-2, AU-3, AU-12 | AU-2, AU-3 |
| EBS encryption-by-default account toggle | SC-28 | SC-28 | SC-28 |
AU-9 / AU-11 is the row this series has not exercised before. Lab 1 was identity. Object Lock is WORM retention on the artifacts that prove the other controls.
The Object Lock row is the evidence bucket. Access-log AU-9 is the isolation-plus-versioning substitute on grc-lab2-access-logs-08132026, because S3 will not deliver logs into WORM + SSE-KMS.
The EBS row is the account toggle I enabled. The default key remains alias/aws/ebs. The CJIS agency-managed-key delta sits on that row until Lab 7.
The fail-closed module
I then coded the same baseline as s3-compliant-bucket in aws-grc-terraform-modules (v1.2.1). CGE-P Domain 2 is module composition. This is those exam reps in a repo the rest of the series will call.
The consumer cannot express a non-compliant evidence bucket through this module’s inputs. Null CMK, alias ARN, retention under 365 days, self-logging, and a lifecycle that undercuts Object Lock all fail at plan time.
kms_cmk_arn is required, nullable = false, and shape-checked. There is no SSE-S3 fallback.
variable "kms_cmk_arn" {
type = string
nullable = false
validation {
condition = can(regex("^arn:aws[a-z-]*:kms:[a-z0-9-]+:[0-9]{12}:key/[a-z0-9-]+$", var.kms_cmk_arn))
error_message = "kms_cmk_arn must be a customer-managed KMS key ARN (arn:aws:kms:... or arn:aws-us-gov:kms:...). SSE-S3 and AWS-managed keys are not permitted. No alias ARN."
}
}
An alias ARN, including alias/aws/s3, fails. GovCloud arn:aws-us-gov:kms:...:key/... passes because [a-z-]* after aws accepts -us-gov.
I confirmed the null case against the live module, not just the regex:
╷
│ Error: Required variable not set
│
│ on main.tf line 6, in module "evidence_bucket":
│ 6: kms_cmk_arn = null
│
│ The given value is not suitable for module.evidence_bucket.var.kms_cmk_arn
│ defined at .terraform/modules/evidence_bucket/variables.tf:6,1-23: required
│ variable may not be set to null.
╵
nullable = false fails before the regex ever runs. Terraform rejects the null at the type-checking stage, not inside the validation block.
Retention is fail-closed at the CJIS / AU-11 1-year floor. The default is the floor. Lower values error.
variable "object_lock_retention_days" {
type = number
default = 365
validation {
condition = var.object_lock_retention_days >= 365
error_message = "object_lock_retention_days must be >= 365 (CJIS / AU-11 1-year retention floor)."
}
}
Same check against the live module with object_lock_retention_days = 30:
╷
│ Error: Invalid value for variable
│
│ on main.tf line 12, in module "evidence_bucket":
│ 12: object_lock_retention_days = 30
│ ├────────────────
│ │ var.object_lock_retention_days is 30
│
│ object_lock_retention_days must be >= 365 (CJIS / AU-11 1-year retention
│ floor).
│
│ This was checked by the validation rule at
│ .terraform/modules/evidence_bucket/variables.tf:34,3-13.
╵
This one does run the validation block: object_lock_retention_days is nullable = true with a default, so 30 passes type-checking and fails on the >= 365 condition instead.
Lifecycle expiration cannot undercut that floor. Cross-variable validation (Terraform >= 1.9) requires noncurrent_expiration_days >= object_lock_retention_days. The console choice of 730 against 365 is now a plan-time error if a caller inverts it.
validation {
condition = var.noncurrent_expiration_days >= var.object_lock_retention_days
error_message = "noncurrent_expiration_days must be >= object_lock_retention_days (lifecycle must not undercut the Object Lock floor)."
}
The rest of the guardrails have no toggle, or a toggle that cannot turn off in prod:
- All four bucket Block Public Access flags are hardcoded. No input exists that can clear one.
- Versioning
statusis the literalEnabled. - The TLS-only policy auto-attaches. Same
Bool/aws:SecureTransportcondition as the console bucket. enable_access_loggingdefaults true and cannot be false whenenvironmentisprod.logs_destination_bucketis required when logging is on. The module never creates that destination. Self-logging (logs_destination_bucket == bucket_name) fails validation, because this bucket has SSE-KMS and Object Lock, both illegal on an S3 log destination.manage_account_defaultsdefaults false so a second instantiation does not fight over the account BPA singleton. Set it true on exactly one call per account.
EBS encryption-by-default is not in the module. The AWS resource would select alias/aws/ebs. This repo does not ship AWS-managed keys. EBS CMK is Lab 7. The console toggle still happened. The module does not claim it.
compliance_attestation reads deployed resource attributes, not input variables. Same pattern as Lab 1’s iam-hardening. sse_kms_with_cmk is true only when the encryption resource’s algorithm is aws:kms and its kms_master_key_id matches the key-ARN regex. object_lock_governance_1yr reads GOVERNANCE and days >= 365 off the Object Lock resource. AU-9 appears in controls_satisfied only when the logging resource exists.
Downstream callers: the oscal-evidence-pipeline output writer, and Labs 3 through 9.
GovCloud and CJIS
I ran this on commercial us-east-1. A GovCloud assessment would have to name three differences I did not deploy.
A CMK in us-gov-west-1 validates at FIPS 140-2. CJIS v6.1 wants a FIPS 140-3 endpoint. There’s no IAM condition key that enforces this: FIPS compliance is a property of which endpoint the client calls, not a policy annotation. The bucket policy can’t require it; the caller has to point at the region’s FIPS endpoint (kms-fips.us-gov-west-1.amazonaws.com) explicitly.
Object Lock exists in GovCloud. Governance-mode bypass principals (s3:BypassGovernanceRetention) need to be documented separately. I used Governance so I can tear the lab down. That principal list is the production control, not the mode name.
S3 Object Lambda is missing in some GovCloud regions. None of the downstream labs in this series target Object Lambda. The gap is flagged so a later design does not assume the API.
What lands here next
Lab 3 writes VPC Flow Logs here. Lab 4 writes CloudTrail here. Lab 5’s Config delivery channel lands here. Lab 9 uploads CI evidence here. The evidence warehouse models whether that set is complete.
The module is s3-compliant-bucket v1.2.1.