# Business rules — terraform_scripts

Evidence date: 2026-10-01. Revision: `main` at `8af8aa6`.

Rules below are taken from Terraform. Where the code is the only source, the rule is marked **Inferred**. No separate business-rules document exists in the source repository. Do not treat an inference as an approved policy until someone confirms it.

Contact phone numbers are not copied here. They live in the tfvars.

## Account and environment

### BR-001 — One production workspace

**Inferred.** The only backend is Terraform Cloud organisation `Datafy-AWS-Deploy`, workspace name `Production`. Client name `datafy` and stage `prod` are the production values.

Evidence: `environments/production/backend.tf`, `production.auto.tfvars`.

### BR-002 — Region

**Inferred.** Workloads run in `eu-west-1`. CloudFront certificate and CloudFront WAF run in `us-east-1` because CloudFront requires that region. The architecture JPG labels the region Europe (Ireland), which matches `eu-west-1`.

Evidence: `providers.tf`, `production.auto.tfvars`, `datafy project redeployment.jpg`.

### BR-003 — Naming

**Inferred.** Most names are `${client_name}-${stage}-…`, so production names start with `datafy-prod-`. Exceptions are the EC2 local names `water-wheel-supabase-server` and `qwen3-supabase-server`, which do not repeat the client prefix. Launch-template and instance Name tags then prefix `datafy-prod-` again, so those two hosts are tagged `datafy-prod-water-wheel-supabase-server` and `datafy-prod-qwen3-supabase-server`. The Supabase host's local name already contains `datafy-prod`, so its tagged name is `datafy-prod-datafy-prod-supabase-server`. Key pair names follow `${service_name}-${local_name}-kp` and are not created by this module.

Evidence: `modules/aws-so-ec2/main.tf`, `stacks/datafy/locals.tf`. Whether the double prefix is intentional is **Requires Confirmation**.

### BR-004 — Alternate contacts

**Implemented.** If an operations, security, or billing email is non-empty after trim, Terraform sets that AWS alternate contact. Production sets all three to John Fourie, Managing Director, `john@datafy.co.za`.

Evidence: `modules/aws-account-settings/settings.tf`, `environments/production/common.auto.tfvars`.

`environments/common.tfvars` repeats the same contact and is not loaded by the production root. It is not a second source of truth.

## Money

### BR-005 — Monthly budget

**Implemented.** Budget `datafy-prod-budget` is a cost budget of 1800 USD per month.

Notifications:

| Threshold | Type | Recipients |
|---|---|---|
| 50% | Actual | Subscriber list |
| 100% | Actual | Subscriber list |
| 150% | Actual | Escalation list |
| 50% | Forecasted | Subscriber list |
| 100% | Forecasted | Subscriber list |
| 150% | Forecasted | Monitor list |

Production subscriber addresses: `john@datafy.co.za`, `gareth@overdrive.co.za`, `software-alerts@overdrive.co.za`.

Monitor addresses: those three plus `awsbudgetalerts@overdrive.co.za`.

Escalation addresses: `john@datafy.co.za`, `awsbudgetalerts@overdrive.co.za`, `aws.escalations@overdrive.co.za`, `gareth@overdrive.co.za`, `software-alerts@overdrive.co.za`.

Evidence: `modules/aws-account-settings/settings.tf`, `common.auto.tfvars`, `production.auto.tfvars`.

**Discrepancy.** `environments/common.tfvars` sets `budget_alerts_subscriber_emails` to a single empty string and omits `gareth@` and `software-alerts@` from the monitor and escalation lists. That file is not auto-loaded. If an operator passed it manually, the subscriber notification would contain an empty address. **Requires Confirmation** that nobody still applies that file.

## Traffic

### BR-006 — Public entry is the ALB only

**Inferred.** The internet may open TCP 80 and 443 to the public ALB security group. EC2 does not allow `0.0.0.0/0`. EC2 accepts TCP 80, 3000, and 8000 only from that ALB security group.

Evidence: `stacks/networking/security_groups.tf`.

### BR-007 — Hostname decides the workload

**Implemented.**

| Host header | Target | Port |
|---|---|---|
| `waterwheel.datafy.co.za`, `*.waterwheel.datafy.co.za` | Water Wheel instance | 80 |
| `supabase.datafy.co.za` | Supabase instance | 8000 |
| `qwen.datafy.co.za` | Qwen instance | 80 |
| anything else on HTTPS | Fixed body `default response` | status 200 |

HTTP is always redirected to HTTPS.

Evidence: `stacks/datafy/load_balancers.tf`.

**Inferred.** A request that does not match a known host is still successful (200). That is an unusual public default. **Requires Confirmation** that a catch-all 200 is the intended product behaviour.

### BR-008 — Host header is preserved

**Implemented.** The public ALB sets `preserve_host_header = true` (commit `a0b9be3`). The variable `alb_internal_preserve_host_header` does not drive this. It is hardcoded.

### BR-009 — Supabase health accepts 401

**Inferred** from the target group. The Supabase group is healthy on HTTP 200 or 401 at `/` on the traffic port. Water Wheel is healthy only on 200 at `/.well-known/alb-health`. Qwen is healthy only on 200 at `/`.

Evidence: `stacks/datafy/locals.tf`, `modules/aws-so-ec2/variables.tf`.

### BR-010 — Web portal is a separate hostname

**Implemented.** `webportal.datafy.co.za` is a CloudFront alias, not an ALB rule. Missing objects and forbidden responses from S3 are shown as `/index.html` with status 200.

Evidence: `stacks/datafy/cdn_and_waf.tf`, `modules/aws-so-cdn/cdn.tf`.

### BR-011 — WAF default is allow, with exceptions

**Implemented.**

- Default action allow.
- More than 1000 requests from one IP are blocked.
- AWS managed IP reputation, common, known-bad-inputs, Linux, SQLi, and Unix groups are in use, override action none (managed actions apply), except the overrides below.
- `SizeRestrictions_BODY` counts instead of blocking.
- `CrossSiteScripting_BODY`, `GenericLFI_BODY`, `NoUserAgent_HEADER`, `LFI_URIPATH`, and `SQLi_BODY` are explicitly block.
- On the regional ACL only: host `pgadmin.waterwheel.datafy.co.za` is allowed before the managed groups; `POST` paths under `/storage/v1/object/Reports/` are allowed; the common rule set does not evaluate `POST` paths under `/storage/v1/object/`; the SQLi rule set does not evaluate that pgAdmin host together with paths under `/sqleditor/`.
- The CloudFront ACL does not receive `pgadmin_host`, so those exceptions do not apply to `webportal.datafy.co.za`.

Evidence: `modules/aws-so-waf/waf.tf`, `stacks/datafy/cdn_and_waf.tf`. The comments in the WAF file say the scope-downs match a live ACL. That comparison with a console-edited ACL was not re-checked. **Requires Confirmation** if the live web ACL has been edited outside Terraform.

### BR-012 — NACLs do not restrict traffic

**Implemented.** Every dedicated NACL has one ingress and one egress rule allowing all protocols from and to `0.0.0.0/0`. Isolation between supabase, waterwheel, qwen, lambda, and the database is not enforced at the NACL layer.

Evidence: `stacks/networking/nacls.tf`. Commit `463d89f` also opened security-group egress to all.

## Data

### BR-013 — One application database

**Inferred.** There is one PostgreSQL instance, database name `appdb`, master username `postgres`. Port 5432. It is not publicly accessible. Storage starts at 100 GB and may autoscale to 500 GB.

Evidence: `stacks/datafy/locals.tf`.

### BR-014 — Who may open Postgres

**Implemented.**

- EC2 may connect to RDS Proxy and directly to the database on 5432.
- Lambda may connect to RDS Proxy on 5432 and may not connect directly to the database security group.
- The public ALB may not connect to the database.

Evidence: `stacks/networking/security_groups.tf`.

### BR-015 — Database credentials

**Implemented.** RDS manages the master password in Secrets Manager. The proxy reads that secret. IAM authentication on the proxy is disabled. EC2 and Lambda roles may call `GetSecretValue` on secrets whose names match `rds!db-*` (Lambda also allows `${service_name}*`).

Evidence: `modules/aws-so-rds/rds.tf`, `modules/aws-so-rds-proxy/proxy.tf`, `modules/aws-so-ec2/data.tf`, `modules/aws-so-lambda/data.tf`.

### BR-016 — RDS backups versus AWS Backup

**Implemented**, and stated in a code comment. The database keeps RDS automated backups for 7 days, window 00:00–01:00 UTC, and a final snapshot on deletion. The RDS resource tags omit `env`, so the AWS Backup selections (which require `env=backup`) do not include the database. Other datafy-stack resources still carry `env=backup`, and EBS volume tags set `env=backup` even if other tags change. Maintenance window is Monday 01:00–02:00 UTC.

Evidence: `modules/aws-so-rds/rds.tf` comment "Drop env=backup so AWS Backup does not snapshot this instance."

**Requires Confirmation** that excluding RDS from AWS Backup is the approved retention design, not only a cost or duplicate-snapshot workaround.

### BR-017 — AWS Backup retention

**Implemented** from module defaults, which production does not override.

| Plan | Schedule (UTC) | Delete after | Move to cold storage |
|---|---|---|---|
| Daily | 01:00 every day | 14 days | disabled |
| Weekly | 02:00 Saturday | 120 days | disabled |
| Monthly | 03:00 on the 1st | 420 days | disabled |

Start window 60 minutes. Completion window 120 minutes. Continuous backup is off.

Evidence: `modules/aws-account-settings/variables.tf`, `settings.tf`.

### BR-018 — Buckets stay private and encrypted

**Implemented.** Created application buckets block public ACLs and public policies, enable versioning, and encrypt with AES256. A bucket policy denies requests that are not TLS. They are not force-destroyed. The server-artifacts bucket is the exception: `force_destroy = true`.

Evidence: `modules/aws-so-s3/s3.tf`, `modules/aws-so-ec2/main.tf`.

### BR-019 — Web portal bucket is external

**Inferred.** Terraform does not create `datafy-prod-webportal`. It attaches a CloudFront origin policy to whatever bucket already has that name. Ownership and object lifecycle of that bucket are outside this module.

Evidence: `data "aws_s3_bucket" "webportal"` in `stacks/datafy/cdn_and_waf.tf`.

## Security controls

### BR-020 — IAM password policy

**Implemented.** Production passes minimum length 32. Module defaults that production does not override: maximum age 90 days, reuse prevention 24, lowercase, uppercase, numbers, and symbols required, users may change their own password, `hard_expiry` false.

Evidence: `stacks/baseline/baseline.tf`, `modules/aws-account-settings/variables.tf`.

### BR-021 — Public sharing blocked

**Implemented.** Account S3 public access block is on. Default EBS encryption is on. New public AMI sharing is blocked. All public snapshot sharing is blocked.

Evidence: `modules/aws-account-settings/settings.tf`.

### BR-022 — Detection and audit

**Implemented.** Security Hub standards FSBP and CIS 1.4 are subscribed. GuardDuty, Config (including global resources), and a multi-region CloudTrail with file validation are enabled. Two CloudWatch alarms target the technical SNS topic, each with threshold 3 over one 300-second period. The filter-to-alarm metric names do not line up; see technical documentation. Do not treat the root-usage alarm as proven to fire until that wiring is confirmed.

Evidence: `modules/aws-so-security-hub`, `stacks/baseline/baseline.tf`, `settings.tf`.

Technical SNS email in production: `software-alerts@overdrive.co.za`. An empty string would still create a subscription; production's auto tfvars sets this address. The root `environments/common.tfvars` leaves it empty.

### BR-023 — Instance metadata

**Implemented.** Launch templates require IMDSv2 (`http_tokens = required`) and set the response hop limit to 2.

The same EC2 policy allows `ec2:ModifyInstanceMetadataOptions` only on instance `i-0e3ee87bcaac5d0fd` in account `926753675240`. **Requires Confirmation** why one existing instance is pinned in a policy that also applies to the three Terraform-managed hosts.

### BR-024 — pgAdmin and storage uploads bypass parts of the WAF

**Inferred** from the rule comments and the allow statements. The authors treated pgAdmin SQL editor traffic and Supabase storage uploads as legitimate traffic that the managed SQLi and common rules would otherwise block. This is encoded only on the regional web ACL.

Evidence: comments in `modules/aws-so-waf/waf.tf` ("Live ACL excludes…"). Whether that exception is an approved business exception is **Requires Confirmation**.

## Operations

### BR-025 — Instances are singletons

**Implemented.** `create_asg` is false, so Terraform creates `aws_instance`, not an Auto Scaling Group. Desired, min, and max size are 1 but those fields apply only when an ASG is created. After create, changes to the launch template are ignored on the instance.

Evidence: `modules/aws-so-ec2/main.tf`.

**Inferred.** Replacing an AMI or user-data script will not update a running instance until someone taints or replaces it outside this lifecycle rule.

### BR-026 — Lambda code is not owned here

**Implemented.** The function is created from a placeholder that returns status 200 and body `placeholder`. Terraform ignores subsequent filename and source-code hash changes.

**Inferred.** The ML detection implementation is released by another pipeline or a console upload. This repository must not be treated as the source of that function.

### BR-027 — No application deploy in Terraform

**Inferred.** User data starts the CodeDeploy agent and the SSM agent. There is no CodeDeploy application. Workload install is out of band, over SSM or CodeDeploy configured elsewhere.

Evidence: `modules/aws-so-ec2/user_data.txt`.

### BR-028 — Deletion protection

**Implemented.** The public ALB has deletion protection. The RDS instance has deletion protection and does not skip the final snapshot. KMS keys used for RDS and SNS have a 30-day deletion window and key rotation.

Evidence: `load_balancers.tf`, `modules/aws-so-rds/rds.tf`, `settings.tf`.

## Diagram rules that the code does not implement

These are **Documented** on `datafy project redeployment.jpg` and **Not implemented** in Terraform. They are not business rules of the running code until someone confirms them.

- An API Gateway in front of a Web/Admin Portal. The code uses CloudFront and S3 for `webportal.datafy.co.za`.
- Amazon SES and AWS Step Functions as included services.
- A readable PostgreSQL "slave". The code uses Multi-AZ (a standby), not a named read replica.
- Ten private subnets drawn as A1–A5 and B1–B5 with the database inside A5/B5. The code names eight private subnets plus two database subnets, and lists nine private CIDRs.
- A separate Web/Admin Portal EC2 or container. No such instance is declared.
