Related: Issue #668, Epic #651
S3 bucket (raxx-support-attachments-prod) that stores customer-uploaded attachments for the support.raxx.app portal. Files go directly from the customer's browser to S3 via a pre-signed POST URL — they never pass through the Raxx backend. Downloads are served via short-TTL (5-minute) pre-signed GET URLs.
All objects are encrypted at rest with a customer-managed KMS key (alias/raxx-support-attachments), separate from any other AWS resource, providing blast-radius isolation.
cd terraform/support-attachments
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars: set ops_break_glass_arn to your operator IAM user ARN
export AWS_PROFILE=raxx-prod # or set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
terraform init
terraform plan # review; verify no PUBLIC access resources
terraform apply
After apply, write outputs to vault:
# Create IAM access key for the long-lived Heroku user
aws iam create-access-key --user-name raxx-support-backend-user
# Store in Infisical
infisical secrets set \
AWS_ACCESS_KEY_ID_SUPPORT="<AccessKeyId>" \
AWS_SECRET_ACCESS_KEY_SUPPORT="<SecretAccessKey>" \
S3_BUCKET_SUPPORT_ATTACHMENTS="$(terraform output -raw bucket_name)" \
KMS_KEY_ARN_SUPPORT_ATTACHMENTS="$(terraform output -raw kms_key_arn)" \
--path /MooseQuest/aws/support-attachments/
Set the same four vars in Heroku (prod + staging environments), then flip the feature flag:
# backend_v2/api/feature_flags.yaml
support_s3_attachments: true
Or via env override: FLAG_SUPPORT_S3_ATTACHMENTS=1
s3://raxx-support-attachments-prod/<customer_uuid>/<ticket_uuid>/<file_uuid>.<ext>
The per-customer prefix is the isolation boundary. Future per-customer IAM scoping will use s3:prefix conditions on this path segment.
| Property | Value |
|---|---|
| Alias | alias/raxx-support-attachments |
| Rotation | Annual (automatic) |
| Key policy principals | raxx-support-backend-role (encrypt/decrypt), ops_break_glass_arn (decrypt only), account root (admin) |
To check rotation status:
aws kms describe-key --key-id alias/raxx-support-attachments \
--query 'KeyMetadata.{Enabled:Enabled,Rotation:KeyRotationEnabled}'
| Rule | Trigger | Action |
|---|---|---|
expire-resolved-attachments |
90 days after creation | Delete object |
expire-unresolved-attachments |
365 days after creation | Delete objects tagged ticket_status=unresolved |
| Non-current versions | 30 days after version superseded | Delete |
| Incomplete multipart uploads | 7 days after initiation | Abort |
Every upload URL generation and download URL generation writes a structured audit log line via Python's logging module at INFO level:
AUDIT {'event': 'support.attachment.upload_url_issued', 'customer_uuid': '...', 'ticket_uuid': '...', ...}
AUDIT {'event': 'support.attachment.accessed', 'customer_uuid': '...', ...}
Pre-signed URLs are never included in audit log entries.
sh
aws kms disable-key --key-id alias/raxx-support-attachmentsraxx-support-backend-user:
sh
aws iam list-access-keys --user-name raxx-support-backend-user
aws iam delete-access-key --user-name raxx-support-backend-user --access-key-id <old>
aws iam create-access-key --user-name raxx-support-backend-userVersioning is enabled. To restore a deleted object:
# List versions + delete markers
aws s3api list-object-versions \
--bucket raxx-support-attachments-prod \
--prefix "<customer_uuid>/<ticket_uuid>/<file_uuid>"
# Remove the delete marker to restore
aws s3api delete-object \
--bucket raxx-support-attachments-prod \
--key "<object_key>" \
--version-id "<DeleteMarkerVersionId>"
sh
aws kms schedule-key-deletion \
--key-id alias/raxx-support-attachments \
--pending-window-in-days 14terraform apply with a new kms_key_alias.| Resource | Est. monthly cost |
|---|---|
| S3 storage (first 50 GB) | ~$1.15/mo at $0.023/GB |
| KMS key | $1.00/mo flat |
| KMS API calls (100K req) | $0.03 |
| S3 requests (1M PUT/GET) | ~$5.00 |
| Total (estimated) | ~$7.18/mo at current scale |