From 7cb3f6510f6248cde1662af1c998e58075a03c59 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 28 Sep 2026 16:38:03 -0400 Subject: [PATCH] feat(infra): add HCP Terraform for the prod file share (PLAT-77) The prod host will live on a subnet in the syslog VPC. The data volume stays unmanaged and is attached only after a snapshot copy. --- .github/workflows/ci.yaml | 26 ++ .gitignore | 6 + README.md | 18 +- terraform/.terraform.lock.hcl | 26 ++ terraform/dlm.tf | 29 ++ terraform/ec2.tf | 36 +++ terraform/hcp_iam.tf | 513 ++++++++++++++++++++++++++++++++++ terraform/iam.tf | 167 +++++++++++ terraform/locals.tf | 28 ++ terraform/network.tf | 59 ++++ terraform/outputs.tf | 17 ++ terraform/providers.tf | 14 + terraform/security.tf | 49 ++++ terraform/user_data.sh.tftpl | 111 ++++++++ terraform/variables.tf | 48 ++++ terraform/versions.tf | 18 ++ 16 files changed, 1158 insertions(+), 7 deletions(-) create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/dlm.tf create mode 100644 terraform/ec2.tf create mode 100644 terraform/hcp_iam.tf create mode 100644 terraform/iam.tf create mode 100644 terraform/locals.tf create mode 100644 terraform/network.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/providers.tf create mode 100644 terraform/security.tf create mode 100644 terraform/user_data.sh.tftpl create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36466c1..ec90dc2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,3 +13,29 @@ jobs: with: node-version: "24" run-tests: true + + terraform: + name: Terraform + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: terraform + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1 + with: + terraform_version: "1.16.0" + terraform_wrapper: false + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init -backend=false + + - name: Terraform validate + run: terraform validate diff --git a/.gitignore b/.gitignore index 1b323b7..1ac33ee 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ cdk.out/ *.d.ts *.js.map .env +.terraform/ +*.tfstate +*.tfstate.* +crash.log +override.tf +override.tf.json diff --git a/README.md b/README.md index 7418193..a56f5b7 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,14 @@ Personal file share server on AWS — Samba for macOS Finder integration and FileBrowser for web-based file management. Accessible exclusively over the site-to-site VPN. -## Infrastructure (CDK) +## Infrastructure -All infrastructure is defined as code with the [AWS CDK](https://docs.aws.amazon.com/cdk/) (TypeScript). The app synthesizes a single CloudFormation stack — `file-share` — that provisions everything described under [Architecture](#architecture), deployed to account `328440206208` in `us-east-1`. +The live share is still the management-account CDK stack until cutover proof. The prod replacement is HCP Terraform in `terraform/`, workspace `file-share-prod`, trigger `terraform/**`. CDK deploy on push to main is frozen. + +| Path | Role | +|---|---| +| `terraform/` | Prod EC2, subnet in the syslog VPC, DLM, and HCP roles | +| `lib/file-share-stack.ts` | Management-account CDK stack, still the live path until decommission | ``` bin/app.ts # CDK app entry point — instantiates the stack @@ -80,12 +85,11 @@ aws secretsmanager create-secret --name file-share/filebrowser-password --secret ## Deploy -```bash -npm install -npx cdk deploy -``` +Prod changes go through HCP Terraform workspace `file-share-prod` (manual apply until the move is sealed). The bootstrap apply creates the HCP roles and the instance boundary. The following apply, using `hcptf-file-share`, creates the subnet, security group, and DLM policy. `data_volume_id` stays empty until the snapshot copy exists, so those applies do not boot an instance. -The stack outputs the instance's private IP for SMB and FileBrowser access. +The management-account CDK workflow no longer runs on push. `workflow_dispatch` remains for an explicit rollback of that stack. + +Clients use the private IP. Office routing must include `10.40.20.0/24` on the existing syslog IPsec before SMB from the office will work. A check from `10.10.70.0/24` on 2026-09-28 reached the gateway for `10.40.10.254` and got no hop-1 reply for `10.40.20.1`. ## Expanding Storage diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..e2d8f07 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.66.0" + constraints = "~> 6.64" + hashes = [ + "h1:OnLj4nhqJnEcUzyyRKUjp1FgWG00Y8maikJEYSf9Zjw=", + "zh:156fe7164a3d26ef6b35734c43e99fb198df90575ed897d1182b8e930b8cd523", + "zh:1af52b22b35be00f8d16e3ebebff9fa699ec4db2ef69e6032ba5c536f80c03d9", + "zh:2545a8478bd551fdc9694f6cc1a1ad24617f6736f8bde0ad6cae90987c65380f", + "zh:4070db1ee369ccb41cb610bfd887386bc0a9b9ecad60aeb4dbce58443d2519dd", + "zh:53da7d3c1840ef875c7d34e967732502a64fe677af0e78824773d4c15a8fe740", + "zh:576a93a28bf611a4de2a2e6ced697a41d5126b8fd31d30782b16797e410a9706", + "zh:58fed5fa9a033355b9d4f3092c817b70d934100e0d8678d6e4c93f3c9493d4e4", + "zh:6a9ca2f24e2ee9156dd785d159a850b35d190e9cf7eca21cb9582970c2db80cd", + "zh:729edd30f99cc16009deba5c013265b0c81eda261a3d0821cbd011d3287fd230", + "zh:7ae460049b75bd4aefee465ef7c53a01ac2df46d4d3e3ac00824afa8b5cb83fb", + "zh:9051fa85c8034ade8a57a5c6f232fd33da28f3800bb5aa40bc8625dbc5e27632", + "zh:906547e4319805e7acf7fbdf2bac28a4b1a7370790a2a430c7adb1b29bb934eb", + "zh:998f27410a66158a35ee5ed142c27e5b21fe8601941da55da2157f8042d6dcca", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9c1804eff1dda0446dc2d215231015bb65a2fc6c3b7ba24584fe45f1ddd3fa9f", + "zh:b03ff5efdee310502aaaeb460144dc059bce72a0d8217e6b989099ef8aef9283", + ] +} diff --git a/terraform/dlm.tf b/terraform/dlm.tf new file mode 100644 index 0000000..f497c59 --- /dev/null +++ b/terraform/dlm.tf @@ -0,0 +1,29 @@ +resource "aws_dlm_lifecycle_policy" "nightly" { + description = "Nightly EBS snapshots for file share" + execution_role_arn = local.dlm_service_role_arn + state = "ENABLED" + + policy_details { + resource_types = ["INSTANCE"] + + target_tags = { + "file-share-backup" = "true" + } + + schedule { + name = "file-share-nightly" + + create_rule { + interval = 24 + interval_unit = "HOURS" + times = ["06:00"] + } + + retain_rule { + count = 30 + } + + copy_tags = true + } + } +} diff --git a/terraform/ec2.tf b/terraform/ec2.tf new file mode 100644 index 0000000..d251b33 --- /dev/null +++ b/terraform/ec2.tf @@ -0,0 +1,36 @@ +resource "aws_instance" "this" { + count = local.create_instance ? 1 : 0 + + ami = var.ami_id + instance_type = "t4g.small" + subnet_id = aws_subnet.file_share.id + vpc_security_group_ids = [aws_security_group.file_share.id] + iam_instance_profile = aws_iam_instance_profile.this.name + associate_public_ip_address = true + user_data = local.user_data + user_data_replace_on_change = false + + root_block_device { + volume_size = 20 + volume_type = "gp3" + encrypted = true + } + + metadata_options { + http_endpoint = "enabled" + http_tokens = "required" + } + + tags = { + Name = "file-share" + "file-share-backup" = "true" + } +} + +resource "aws_volume_attachment" "data" { + count = local.create_instance ? 1 : 0 + + device_name = "/dev/xvdf" + volume_id = var.data_volume_id + instance_id = aws_instance.this[0].id +} diff --git a/terraform/hcp_iam.tf b/terraform/hcp_iam.tf new file mode 100644 index 0000000..964a59a --- /dev/null +++ b/terraform/hcp_iam.tf @@ -0,0 +1,513 @@ +# HCP plan/apply roles for file-share-prod (PLAT-77). +# Copy of the syslog-server EC2 shape, narrowed to this stack. +# Create, do not import. +# +# Live seahaven-hcptf-iam-management DenySelfMutation blocks DetachRolePolicy +# and PutRolePolicy on hcptf-* (including this role). First-apply sequence: +# 1. seahaven-org-baseline scripts/create-hcptf-bootstrap-roles.sh +# --account prod --allow-workspace file-share-prod +# 2. Point this workspace's TFC_AWS_* at hcptf-bootstrap / +# hcptf-bootstrap-plan (workspace vars, never a project set). +# 3. One Manual apply. Bootstrap can create these roles and the boundary. +# Subnet, DLM, and the instance are created on the following apply +# after TFC_AWS_* points at hcptf-file-share. Tolerate that partial +# state. +# 4. Point TFC_AWS_* back at hcptf-file-share / hcptf-file-share-plan. +# 5. Re-run the create script without --allow-workspace to pin trust +# back to iam-bootstrap-prod only. +# Later apply-role IAM edits use the same window. Do not add StringLike +# on bootstrap trust. CreatePolicy stays on hcptf-bootstrap only. + +data "aws_iam_policy_document" "hcptf_apply_trust" { + statement { + sid = "HcpApply" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/app.terraform.io"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = [ + "organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:apply", + ] + } + } +} + +data "aws_iam_policy_document" "hcptf_plan_trust" { + statement { + sid = "HcpPlan" + effect = "Allow" + actions = ["sts:AssumeRoleWithWebIdentity"] + + principals { + type = "Federated" + identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:oidc-provider/app.terraform.io"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:aud" + values = ["aws.workload.identity"] + } + + condition { + test = "StringEquals" + variable = "app.terraform.io:sub" + values = [ + "organization:seahaven:project:${local.hcp_project}:workspace:${local.hcp_workspace}:run_phase:plan", + ] + } + } +} + +data "aws_iam_policy_document" "hcptf_scoped_iam" { + statement { + sid = "DenyCreatePolicy" + effect = "Deny" + actions = [ + "iam:CreatePolicy", + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ] + resources = ["*"] + } + + statement { + sid = "CreateExecRoleWithBoundary" + effect = "Allow" + actions = ["iam:CreateRole"] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*", + ] + + condition { + test = "StringLike" + variable = "iam:PermissionsBoundary" + values = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*", + ] + } + } + + statement { + sid = "MutateExecRoleWithBoundary" + effect = "Allow" + actions = [ + "iam:AttachRolePolicy", + "iam:PutRolePolicy", + "iam:PutRolePermissionsBoundary", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*", + ] + + condition { + test = "StringLike" + variable = "iam:PermissionsBoundary" + values = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*", + ] + } + } + + statement { + sid = "WriteExecRoles" + effect = "Allow" + actions = [ + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:TagRole", + "iam:UntagRole", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*", + ] + } + + statement { + sid = "PassExecRoleToEc2" + effect = "Allow" + actions = ["iam:PassRole"] + resources = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.instance_role_name}"] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["ec2.amazonaws.com"] + } + } + + statement { + sid = "PassDlmServiceRole" + effect = "Allow" + actions = ["iam:PassRole"] + resources = [local.dlm_service_role_arn] + + condition { + test = "StringEquals" + variable = "iam:PassedToService" + values = ["dlm.amazonaws.com"] + } + } + + statement { + sid = "CreateDlmServiceLinkedRole" + effect = "Allow" + actions = [ + "iam:CreateServiceLinkedRole", + ] + resources = [local.dlm_service_role_arn] + + condition { + test = "StringEquals" + variable = "iam:AWSServiceName" + values = ["dlm.amazonaws.com"] + } + } + + statement { + sid = "InstanceProfiles" + effect = "Allow" + actions = [ + "iam:AddRoleToInstanceProfile", + "iam:CreateInstanceProfile", + "iam:DeleteInstanceProfile", + "iam:GetInstanceProfile", + "iam:ListInstanceProfileTags", + "iam:RemoveRoleFromInstanceProfile", + "iam:TagInstanceProfile", + "iam:UntagInstanceProfile", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:instance-profile/tf-managed/${local.instance_profile_name}", + ] + } + + statement { + sid = "IamReadOnly" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:GetInstanceProfile", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfiles", + "iam:ListInstanceProfilesForRole", + "iam:ListPolicies", + "iam:ListPolicyVersions", + "iam:ListRolePolicies", + "iam:ListRoleTags", + "iam:ListRoles", + ] + resources = ["*"] + } + + statement { + sid = "DenySelfMutation" + effect = "Deny" + actions = [ + "iam:AttachRolePolicy", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DeleteRolePermissionsBoundary", + "iam:DetachRolePolicy", + "iam:PutRolePolicy", + "iam:PutRolePermissionsBoundary", + "iam:UpdateAssumeRolePolicy", + "iam:UpdateRole", + "iam:UpdateRoleDescription", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/hcptf-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/github-cfn-execution-role", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/githubdeploy-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/cdk-hnb659fds-*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/OrganizationAccountAccessRole", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/seahaven-*", + ] + } + + statement { + sid = "DenyBoundaryTampering" + effect = "Deny" + actions = [ + "iam:DeleteRolePermissionsBoundary", + "iam:DeleteUserPermissionsBoundary", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/*", + ] + } + + statement { + sid = "DenyBoundaryPolicyEdit" + effect = "Deny" + actions = [ + "iam:CreatePolicyVersion", + "iam:DeletePolicy", + "iam:DeletePolicyVersion", + "iam:SetDefaultPolicyVersion", + ] + resources = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/seahaven-*"] + } + + statement { + sid = "ReadTfManagedBoundary" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + "iam:ListPolicyVersions", + "iam:ListPolicyTags", + "iam:TagPolicy", + "iam:UntagPolicy", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*", + ] + } +} + +data "aws_iam_policy_document" "hcptf_apply_services" { + # checkov:skip=CKV_AWS_111: EC2 describe and RunInstances APIs require Resource=*. This role cannot CreateVolume or DeleteVolume. + statement { + sid = "Ec2Network" + effect = "Allow" + actions = [ + "ec2:AssociateRouteTable", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:CreateSecurityGroup", + "ec2:CreateSubnet", + "ec2:CreateTags", + "ec2:DeleteSecurityGroup", + "ec2:DeleteSubnet", + "ec2:DeleteTags", + "ec2:DescribeAccountAttributes", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeImages", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeInstanceStatus", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstances", + "ec2:DescribeInternetGateways", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribePrefixLists", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeTags", + "ec2:DescribeVolumeAttribute", + "ec2:DescribeVolumeStatus", + "ec2:DescribeVolumes", + "ec2:DescribeVpcAttribute", + "ec2:DescribeVpcs", + "ec2:DescribeVpnGateways", + "ec2:DisassociateRouteTable", + "ec2:ModifySecurityGroupRules", + "ec2:ModifySubnetAttribute", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "ec2:UpdateSecurityGroupRuleDescriptionsEgress", + "ec2:UpdateSecurityGroupRuleDescriptionsIngress", + ] + resources = ["*"] + } + + statement { + sid = "Ec2Instance" + effect = "Allow" + actions = [ + "ec2:AssociateIamInstanceProfile", + "ec2:AttachVolume", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:DetachVolume", + "ec2:DisassociateIamInstanceProfile", + "ec2:GetConsoleOutput", + "ec2:ModifyInstanceAttribute", + "ec2:MonitorInstances", + "ec2:ReplaceIamInstanceProfileAssociation", + "ec2:RunInstances", + "ec2:StartInstances", + "ec2:StopInstances", + "ec2:TerminateInstances", + "ec2:UnmonitorInstances", + ] + resources = ["*"] + } + + statement { + sid = "DlmPolicy" + effect = "Allow" + actions = [ + "dlm:CreateLifecyclePolicy", + "dlm:DeleteLifecyclePolicy", + "dlm:GetLifecyclePolicy", + "dlm:ListTagsForResource", + "dlm:TagResource", + "dlm:UntagResource", + "dlm:UpdateLifecyclePolicy", + ] + resources = ["*"] + } +} + +data "aws_iam_policy_document" "hcptf_plan_refresh" { + statement { + sid = "RefreshIamRoles" + effect = "Allow" + actions = [ + "iam:GetRole", + "iam:GetRolePolicy", + "iam:GetInstanceProfile", + "iam:ListRolePolicies", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListRoleTags", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/tf-managed/${local.stack_prefix}*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:instance-profile/tf-managed/${local.stack_prefix}*", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.apply_role}", + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.plan_role}", + ] + } + + statement { + sid = "RefreshManagedPolicies" + effect = "Allow" + actions = [ + "iam:GetPolicy", + "iam:GetPolicyVersion", + ] + resources = [ + "arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/tf-managed/${local.stack_prefix}*", + "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", + "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", + ] + } + + statement { + sid = "RefreshEc2" + effect = "Allow" + actions = [ + "ec2:DescribeAccountAttributes", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeIamInstanceProfileAssociations", + "ec2:DescribeImages", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceCreditSpecifications", + "ec2:DescribeInstanceStatus", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstances", + "ec2:DescribeInternetGateways", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribePrefixLists", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeTags", + "ec2:DescribeVolumeAttribute", + "ec2:DescribeVolumeStatus", + "ec2:DescribeVolumes", + "ec2:DescribeVpcAttribute", + "ec2:DescribeVpcs", + "ec2:DescribeVpnGateways", + "ec2:GetConsoleOutput", + ] + resources = ["*"] + } + + statement { + sid = "RefreshDlm" + effect = "Allow" + actions = [ + "dlm:GetLifecyclePolicy", + "dlm:ListTagsForResource", + ] + resources = ["*"] + } +} + +resource "aws_iam_role" "hcptf_apply" { + name = local.apply_role + assume_role_policy = data.aws_iam_policy_document.hcptf_apply_trust.json + max_session_duration = 3600 + + tags = { + Owner = "adam@seahavenind.com" + ManagedBy = "terraform" + } +} + +resource "aws_iam_role" "hcptf_plan" { + name = local.plan_role + assume_role_policy = data.aws_iam_policy_document.hcptf_plan_trust.json + max_session_duration = 3600 + + tags = { + Owner = "adam@seahavenind.com" + ManagedBy = "terraform" + } +} + +resource "aws_iam_role_policy" "hcptf_scoped_iam" { + name = "scoped-iam-management" + role = aws_iam_role.hcptf_apply.id + policy = data.aws_iam_policy_document.hcptf_scoped_iam.json +} + +resource "aws_iam_role_policy" "hcptf_apply_services" { + # checkov:skip=CKV_AWS_111: EC2 describe and RunInstances APIs require Resource=*. This role cannot CreateVolume or DeleteVolume. + name = "file-share-services" + role = aws_iam_role.hcptf_apply.id + policy = data.aws_iam_policy_document.hcptf_apply_services.json +} + +resource "aws_iam_role_policy" "hcptf_plan_refresh" { + # checkov:skip=CKV_AWS_107: ViewOnlyAccess plus this sidecar is the HCP plan-role pattern. Actions are named. It does not add iam:CreateAccessKey, secretsmanager:GetSecretValue, or ssm:GetParameter on *. + name = "file-share-plan-refresh" + role = aws_iam_role.hcptf_plan.id + policy = data.aws_iam_policy_document.hcptf_plan_refresh.json +} + +resource "aws_iam_role_policy_attachment" "hcptf_plan_view_only" { + role = aws_iam_role.hcptf_plan.name + policy_arn = "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess" +} + +resource "aws_iam_role_policy_attachments_exclusive" "hcptf_apply" { + role_name = aws_iam_role.hcptf_apply.name + policy_arns = [] +} + +resource "aws_iam_role_policy_attachments_exclusive" "hcptf_plan" { + role_name = aws_iam_role.hcptf_plan.name + policy_arns = [ + "arn:aws:iam::aws:policy/job-function/ViewOnlyAccess", + ] +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000..2a1783a --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,167 @@ +# Instance permissions boundary. +# The scoped apply role denies iam:CreatePolicy / CreatePolicyVersion, +# so later edits to this document need the hcptf-bootstrap window. + +data "aws_iam_policy_document" "instance_boundary" { + # checkov:skip=CKV_AWS_111: SSM agent APIs require Resource=*. Secret reads are ARN-scoped. + statement { + sid = "SecretsRead" + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue", + ] + resources = [ + var.smb_password_secret_arn, + var.filebrowser_password_secret_arn, + ] + } + + statement { + sid = "Ec2DescribeForAgent" + effect = "Allow" + actions = [ + "ec2:DescribeTags", + "ec2:DescribeVolumes", + "ec2:DescribeInstances", + ] + resources = ["*"] + } + + statement { + sid = "SsmAgentBuckets" + effect = "Allow" + actions = [ + "s3:GetObject", + ] + resources = [ + "arn:aws:s3:::aws-ssm-*/*", + "arn:aws:s3:::aws-windows-downloads-*/*", + "arn:aws:s3:::amazon-ssm-*/*", + "arn:aws:s3:::amazon-ssm-packages-*/*", + "arn:aws:s3:::patch-baseline-snapshot-*/*", + ] + } + + statement { + sid = "SsmManagedInstance" + effect = "Allow" + actions = [ + "ssm:DescribeAssociation", + "ssm:GetDeployablePatchSnapshotForInstance", + "ssm:GetDocument", + "ssm:DescribeDocument", + "ssm:GetManifest", + "ssm:ListAssociations", + "ssm:ListInstanceAssociations", + "ssm:PutInventory", + "ssm:PutComplianceItems", + "ssm:PutConfigurePackageResult", + "ssm:UpdateAssociationStatus", + "ssm:UpdateInstanceAssociationStatus", + "ssm:UpdateInstanceInformation", + ] + resources = ["*"] + } + + statement { + sid = "SsmAgentParameters" + effect = "Allow" + actions = [ + "ssm:GetParameter", + "ssm:GetParameters", + ] + resources = [ + "arn:aws:ssm:${var.aws_region}::parameter/aws/service/*", + "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/aws/service/*", + ] + } + + statement { + sid = "SsmMessages" + effect = "Allow" + actions = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel", + ] + resources = ["*"] + } + + statement { + sid = "Ec2Messages" + effect = "Allow" + actions = [ + "ec2messages:AcknowledgeMessage", + "ec2messages:DeleteMessage", + "ec2messages:FailMessage", + "ec2messages:GetEndpoint", + "ec2messages:GetMessages", + "ec2messages:SendReply", + ] + resources = ["*"] + } +} + +resource "aws_iam_policy" "instance_boundary" { + # checkov:skip=CKV_AWS_111: SSM agent APIs require Resource=*. Secret reads are ARN-scoped. + name = local.boundary_name + path = "/tf-managed/" + description = "EC2 permissions boundary for file-share." + policy = data.aws_iam_policy_document.instance_boundary.json +} + +data "aws_iam_policy_document" "instance_assume" { + statement { + sid = "Ec2Assume" + effect = "Allow" + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["ec2.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "instance" { + name = local.instance_role_name + path = "/tf-managed/" + assume_role_policy = data.aws_iam_policy_document.instance_assume.json + permissions_boundary = aws_iam_policy.instance_boundary.arn + + tags = { + Name = local.instance_role_name + } +} + +resource "aws_iam_role_policy_attachment" "ssm" { + role = aws_iam_role.instance.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +data "aws_iam_policy_document" "instance_secrets" { + statement { + sid = "SecretsRead" + effect = "Allow" + actions = [ + "secretsmanager:GetSecretValue", + ] + resources = [ + var.smb_password_secret_arn, + var.filebrowser_password_secret_arn, + ] + } +} + +resource "aws_iam_role_policy" "instance_secrets" { + name = "file-share-secrets" + role = aws_iam_role.instance.id + policy = data.aws_iam_policy_document.instance_secrets.json +} + +resource "aws_iam_instance_profile" "this" { + name = local.instance_profile_name + path = "/tf-managed/" + role = aws_iam_role.instance.name +} diff --git a/terraform/locals.tf b/terraform/locals.tf new file mode 100644 index 0000000..35ebfe5 --- /dev/null +++ b/terraform/locals.tf @@ -0,0 +1,28 @@ +locals { + project = "file-share" + environment = "prod" + + hcp_project = "seahaven-prod" + hcp_workspace = "file-share-prod" + apply_role = "hcptf-file-share" + plan_role = "hcptf-file-share-plan" + stack_name = local.project + stack_prefix = "file-share-" + + instance_role_name = "file-share-role" + instance_profile_name = "file-share-profile" + boundary_name = "file-share-instance-boundary" + + office_lan_cidrs = ["10.10.0.0/16", "10.30.0.0/16"] + subnet_cidr = "10.40.20.0/24" + subnet_az = "us-east-1a" + + create_instance = var.data_volume_id != "" + + dlm_service_role_arn = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/aws-service-role/dlm.amazonaws.com/AWSServiceRoleForDataLifecycleManager" + + user_data = templatefile("${path.module}/user_data.sh.tftpl", { + aws_region = var.aws_region + filebrowser_version = var.filebrowser_version + }) +} diff --git a/terraform/network.tf b/terraform/network.tf new file mode 100644 index 0000000..8331770 --- /dev/null +++ b/terraform/network.tf @@ -0,0 +1,59 @@ +data "aws_vpc" "syslog" { + filter { + name = "tag:Name" + values = ["syslog-server-vpc"] + } +} + +data "aws_internet_gateway" "syslog" { + filter { + name = "tag:Name" + values = ["syslog-server-igw"] + } +} + +data "aws_vpn_gateway" "syslog" { + filter { + name = "tag:Name" + values = ["syslog-server-office"] + } + + attached_vpc_id = data.aws_vpc.syslog.id +} + +data "aws_route_table" "syslog_public" { + vpc_id = data.aws_vpc.syslog.id + + filter { + name = "tag:Name" + values = ["syslog-server-public"] + } +} + +resource "aws_subnet" "file_share" { + vpc_id = data.aws_vpc.syslog.id + cidr_block = local.subnet_cidr + availability_zone = local.subnet_az + map_public_ip_on_launch = true + + tags = { + Name = "file-share" + } + + lifecycle { + precondition { + condition = one(data.aws_internet_gateway.syslog.attachments[*].vpc_id) == data.aws_vpc.syslog.id + error_message = "syslog IGW is not attached to the syslog VPC." + } + + precondition { + condition = data.aws_vpn_gateway.syslog.attached_vpc_id == data.aws_vpc.syslog.id + error_message = "syslog VPN gateway is not attached to the syslog VPC." + } + } +} + +resource "aws_route_table_association" "file_share" { + subnet_id = aws_subnet.file_share.id + route_table_id = data.aws_route_table.syslog_public.id +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..bc1eaad --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,17 @@ +output "private_ip" { + description = "SMB (smb://ip/files), FileBrowser (http://ip:8080), and SFTP. Clients use this address, not the public IP." + value = one(aws_instance.this[*].private_ip) +} + +output "public_ip" { + description = "Egress address for package install and SSM. Not a client endpoint." + value = one(aws_instance.this[*].public_ip) +} + +output "instance_id" { + value = one(aws_instance.this[*].id) +} + +output "subnet_id" { + value = aws_subnet.file_share.id +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..e618f93 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,14 @@ +provider "aws" { + region = var.aws_region + + default_tags { + tags = { + Project = local.project + Environment = "prod" + ManagedBy = "terraform" + Workspace = local.hcp_workspace + } + } +} + +data "aws_caller_identity" "current" {} diff --git a/terraform/security.tf b/terraform/security.tf new file mode 100644 index 0000000..18c376b --- /dev/null +++ b/terraform/security.tf @@ -0,0 +1,49 @@ +resource "aws_security_group" "file_share" { + name = "file-share" + description = "SMB, FileBrowser, and SFTP from office LANs" + vpc_id = data.aws_vpc.syslog.id + + tags = { + Name = "file-share" + } +} + +resource "aws_vpc_security_group_egress_rule" "all" { + security_group_id = aws_security_group.file_share.id + ip_protocol = "-1" + cidr_ipv4 = "0.0.0.0/0" + description = "Outbound for package install, Secrets Manager, and SSM" +} + +resource "aws_vpc_security_group_ingress_rule" "smb" { + for_each = toset(local.office_lan_cidrs) + + security_group_id = aws_security_group.file_share.id + ip_protocol = "tcp" + from_port = 445 + to_port = 445 + cidr_ipv4 = each.value + description = "SMB from office LAN" +} + +resource "aws_vpc_security_group_ingress_rule" "filebrowser" { + for_each = toset(local.office_lan_cidrs) + + security_group_id = aws_security_group.file_share.id + ip_protocol = "tcp" + from_port = 8080 + to_port = 8080 + cidr_ipv4 = each.value + description = "FileBrowser from office LAN" +} + +resource "aws_vpc_security_group_ingress_rule" "sftp" { + for_each = toset(local.office_lan_cidrs) + + security_group_id = aws_security_group.file_share.id + ip_protocol = "tcp" + from_port = 22 + to_port = 22 + cidr_ipv4 = each.value + description = "SFTP from office LAN" +} diff --git a/terraform/user_data.sh.tftpl b/terraform/user_data.sh.tftpl new file mode 100644 index 0000000..f3b814c --- /dev/null +++ b/terraform/user_data.sh.tftpl @@ -0,0 +1,111 @@ +#!/bin/bash +set -euo pipefail + +until lsblk -dno NAME | grep -v "$(lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda)" | grep -q .; do + echo "Waiting for data volume..." + sleep 5 +done +DATA_DEVICE="/dev/$(lsblk -dno NAME | grep -v "$(lsblk -no PKNAME "$(findmnt -n -o SOURCE /)" 2>/dev/null || echo xvda)" | head -1)" +if ! blkid "$DATA_DEVICE"; then + mkfs.ext4 -L file-share-data "$DATA_DEVICE" +fi +mkdir -p /data +grep -q 'LABEL=file-share-data /data ' /etc/fstab || echo "LABEL=file-share-data /data ext4 defaults,nofail 0 2" >> /etc/fstab +mount -a +mkdir -p /data/share + +dnf install -y samba samba-common + +useradd --system --no-create-home --shell /sbin/nologin adam || true +chown adam:adam /data/share + +SMB_PASSWORD="$(aws secretsmanager get-secret-value --secret-id file-share/smb-password --query SecretString --output text --region ${aws_region})" +(printf '%s\n' "$SMB_PASSWORD"; printf '%s\n' "$SMB_PASSWORD") | smbpasswd -s -a adam +if passwd --help 2>&1 | grep -q -- '--stdin'; then + printf '%s\n' "$SMB_PASSWORD" | passwd --stdin adam +else + printf 'adam:%s\n' "$SMB_PASSWORD" | chpasswd +fi +unset SMB_PASSWORD + +cat > /etc/samba/smb.conf << 'SMBEOF' +[global] +workgroup = SEAHAVEN +server string = Sea Haven File Share +security = user +map to guest = never +log file = /var/log/samba/log.%m +max log size = 1000 +server min protocol = SMB3 + +# macOS Finder optimizations +vfs objects = catia fruit streams_xattr +fruit:metadata = stream +fruit:model = MacSamba +fruit:posix_rename = yes +fruit:veto_appledouble = no +fruit:nfs_aces = no +fruit:wipe_intentionally_left_blank_rfork = yes +fruit:delete_empty_adfiles = yes + +[files] +path = /data/share +browseable = yes +writable = yes +valid users = adam +create mask = 0644 +directory mask = 0755 +SMBEOF + +systemctl enable --now smb nmb + +curl -sfL "https://github.com/filebrowser/filebrowser/releases/download/${filebrowser_version}/linux-arm64-filebrowser.tar.gz" | tar xz -C /usr/local/bin filebrowser +chmod +x /usr/local/bin/filebrowser + +mkdir -p /etc/filebrowser +FB_PASSWORD="$(aws secretsmanager get-secret-value --secret-id file-share/filebrowser-password --query SecretString --output text --region ${aws_region})" + +cat > /etc/filebrowser/config.json << 'FBEOF' +{ + "address": "0.0.0.0", + "port": 8080, + "root": "/data/share", + "database": "/etc/filebrowser/filebrowser.db", + "log": "/var/log/filebrowser.log" +} +FBEOF + +filebrowser config init --config /etc/filebrowser/config.json +filebrowser users add admin "$FB_PASSWORD" --config /etc/filebrowser/config.json --perm.admin +unset FB_PASSWORD + +cat > /etc/systemd/system/filebrowser.service << 'SVCEOF' +[Unit] +Description=FileBrowser +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/filebrowser --config /etc/filebrowser/config.json +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +SVCEOF + +systemctl daemon-reload +systemctl enable --now filebrowser + +usermod -s /bin/bash -d /data/share adam +if ! grep -q 'Match User adam' /etc/ssh/sshd_config; then + cat >> /etc/ssh/sshd_config << 'SSHEOF' + +Match User adam + ForceCommand internal-sftp + PasswordAuthentication yes + AllowTcpForwarding no + X11Forwarding no +SSHEOF +fi +systemctl restart sshd diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..48cda43 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,48 @@ +variable "aws_region" { + description = "Region every resource in this configuration is created in." + type = string + default = "us-east-1" +} + +variable "ami_id" { + description = "Pinned Amazon Linux 2023 arm64 AMI. Changing this replaces the instance. Snapshot the data volume the same day and confirm before apply." + type = string + default = "ami-0eb45f74aa8a20238" +} + +variable "filebrowser_version" { + description = "Pinned FileBrowser release. Do not track releases/latest." + type = string + default = "v2.63.23" +} + +variable "smb_password_secret_arn" { + description = "Exact ARN of file-share/smb-password in this account. Set as an HCP workspace variable. Never the secret value." + type = string + + validation { + condition = startswith(var.smb_password_secret_arn, "arn:aws:secretsmanager:") + error_message = "smb_password_secret_arn must be a Secrets Manager ARN." + } +} + +variable "filebrowser_password_secret_arn" { + description = "Exact ARN of file-share/filebrowser-password in this account. Set as an HCP workspace variable. Never the secret value." + type = string + + validation { + condition = startswith(var.filebrowser_password_secret_arn, "arn:aws:secretsmanager:") + error_message = "filebrowser_password_secret_arn must be a Secrets Manager ARN." + } +} + +variable "data_volume_id" { + description = "Imported data volume id. Empty until cutover. Terraform attaches this volume and must not create or delete it." + type = string + default = "" + + validation { + condition = var.data_volume_id == "" || startswith(var.data_volume_id, "vol-") + error_message = "data_volume_id must be empty or an EBS volume id." + } +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 0000000..30aad02 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.64" + } + } + + cloud { + organization = "seahaven" + + workspaces { + name = "file-share-prod" + } + } +}