diff --git a/asset-account/terraform/stack-set/examples/self-managed/.terraform.lock.hcl b/asset-account/terraform/stack-set/examples/self-managed/.terraform.lock.hcl index 7de3aa8..2c5d0fb 100644 --- a/asset-account/terraform/stack-set/examples/self-managed/.terraform.lock.hcl +++ b/asset-account/terraform/stack-set/examples/self-managed/.terraform.lock.hcl @@ -2,24 +2,24 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "5.92.0" - constraints = ">= 5.0.0" + version = "5.94.1" + constraints = "~> 5.0" hashes = [ - "h1:ZnpTxMfg5PThZc5WZCsZELinsR0gPhdTpNmXjVcf7aE=", - "zh:1d3a0b40831360e8e988aee74a9ff3d69d95cb541c2eae5cb843c64303a091ba", - "zh:3d29cbced6c708be2041a708d25c7c0fc22d09e4d0b174360ed113bfae786137", - "zh:4341a203cf5820a0ca18bb514ae10a6c113bc6a728fb432acbf817d232e8eff4", - "zh:4a49e2d91e4d92b6b93ccbcbdcfa2d67935ce62e33b939656766bb81b3fd9a2c", - "zh:54c7189358b37fd895dedbabf84e509c1980a8c404a1ee5b29b06e40497b8655", - "zh:5d8bb1ff089c37cb65c83b4647f1981fded993e87d8132915d92d79f29e2fcd8", - "zh:618f2eb87cd65b245aefba03991ad714a51ff3b841016ef68e2da2b85d0b2325", - "zh:7bce07bc542d0588ca42bac5098dd4f8af715417cd30166b4fb97cedd44ab109", - "zh:81419eab2d8810beb114b1ff5cbb592d21edc21b809dc12bb066e4b88fdd184a", + "h1:pm3uoaQYHaavwE83zsEzAFn/LKD1EWGiYRfzVxNCaIA=", + "zh:14fb41e50219660d5f02b977e6f786d8ce78766cce8c2f6b8131411b087ae945", + "zh:3bc5d12acd5e1a5f1cf78a7f05d0d63f988b57485e7d20c47e80a0b723a99d26", + "zh:4835e49377f80a37c6191a092f636e227a9f086e3cc3f0c9e1b554da8793cfe8", + "zh:605971275adae25096dca30a94e29931039133c667c1d9b38778a09594312964", + "zh:8ae46b4a9a67815facf59da0c56d74ef71bcc77ae79e8bfbac504fa43f267f8e", + "zh:913f3f371c3e6d1f040d6284406204b049977c13cb75aae71edb0ef8361da7dd", + "zh:91f85ae8c73932547ad7139ce0b047a6a7c7be2fd944e51db13231cc80ce6d8e", + "zh:96352ae4323ce137903b9fe879941f894a3ce9ef30df1018a0f29f285a448793", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:9dea39d4748eeeebe2e76ca59bca4ccd161c2687050878c47289a98407a23372", - "zh:d692fc33b67ac89e916c8f9233d39eacab8c438fe10172990ee9d94fba5ca372", - "zh:d9075c7da48947c029ba47d5985e1e8e3bf92367bfee8ca1ff0e747765e779a1", - "zh:e81c62db317f3b640b2e04eba0ada8aa606bcbae0152c09f6242e86b86ef5889", - "zh:f68562e073722c378d2f3529eb80ad463f12c44aa5523d558ae3b69f4de5ca1f", + "zh:9b51922c9201b1dc3d05b39f9972715db5f67297deee088793d02dea1832564b", + "zh:a689e82112aa71e15647b06502d5b585980cd9002c3cc8458f092e8c8a667696", + "zh:c3723fa3e6aff3c1cc0088bdcb1edee168fe60020f2f77161d135bf473f45ab2", + "zh:d6a2052b864dd394b01ad1bae32d0a7d257940ee47908d02df7fa7873981d619", + "zh:dda4c9c0406cc54ad8ee4f19173a32de7c6e73abb5a948ea0f342d567df26a1d", + "zh:f42e0fe592b97cbdf70612f0fbe2bab851835e2d1aaf8cbb87c3ab0f2c96bb27", ] } diff --git a/codegen/.terraform.lock.hcl b/codegen/.terraform.lock.hcl new file mode 100644 index 0000000..49862d5 --- /dev/null +++ b/codegen/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.94.1" + hashes = [ + "h1:pm3uoaQYHaavwE83zsEzAFn/LKD1EWGiYRfzVxNCaIA=", + "zh:14fb41e50219660d5f02b977e6f786d8ce78766cce8c2f6b8131411b087ae945", + "zh:3bc5d12acd5e1a5f1cf78a7f05d0d63f988b57485e7d20c47e80a0b723a99d26", + "zh:4835e49377f80a37c6191a092f636e227a9f086e3cc3f0c9e1b554da8793cfe8", + "zh:605971275adae25096dca30a94e29931039133c667c1d9b38778a09594312964", + "zh:8ae46b4a9a67815facf59da0c56d74ef71bcc77ae79e8bfbac504fa43f267f8e", + "zh:913f3f371c3e6d1f040d6284406204b049977c13cb75aae71edb0ef8361da7dd", + "zh:91f85ae8c73932547ad7139ce0b047a6a7c7be2fd944e51db13231cc80ce6d8e", + "zh:96352ae4323ce137903b9fe879941f894a3ce9ef30df1018a0f29f285a448793", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9b51922c9201b1dc3d05b39f9972715db5f67297deee088793d02dea1832564b", + "zh:a689e82112aa71e15647b06502d5b585980cd9002c3cc8458f092e8c8a667696", + "zh:c3723fa3e6aff3c1cc0088bdcb1edee168fe60020f2f77161d135bf473f45ab2", + "zh:d6a2052b864dd394b01ad1bae32d0a7d257940ee47908d02df7fa7873981d619", + "zh:dda4c9c0406cc54ad8ee4f19173a32de7c6e73abb5a948ea0f342d567df26a1d", + "zh:f42e0fe592b97cbdf70612f0fbe2bab851835e2d1aaf8cbb87c3ab0f2c96bb27", + ] +} diff --git a/codegen/package-lock.json b/codegen/package-lock.json index 53f9a46..a306280 100644 --- a/codegen/package-lock.json +++ b/codegen/package-lock.json @@ -6,14 +6,40 @@ "packages": { "": { "dependencies": { - "aws-iam-policy-types": "^1.0.2" + "@cdktf/hcl-tools": "^0.20.11", + "arktype": "^2.1.19", + "aws-iam-policy-types": "^1.0.2", + "constructs": "^10.4.2", + "fs-extra": "^11.3.0", + "lodash": "^4.17.21", + "prettier": "^3.5.3", + "yaml": "^2.7.1" }, "devDependencies": { - "@types/node": "^22.13.13", + "@types/lodash": "^4.17.16", + "@types/node": "^22.14.0", "tsx": "^4.19.3", - "typescript": "^5.8.2" + "typescript": "^5.8.3" } }, + "node_modules/@ark/schema": { + "version": "0.45.9", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.45.9.tgz", + "integrity": "sha512-rG0v/JI0sibn/0wERAHTYVLCtEqoMP2IIlxnb+S5DrEjCI5wpubbZSWMDW50tZ8tV6FANu6zzHDeeKbp6lsZdg==", + "dependencies": { + "@ark/util": "0.45.9" + } + }, + "node_modules/@ark/util": { + "version": "0.45.9", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.45.9.tgz", + "integrity": "sha512-0WYNAb8aRGp7dNt6xIvIrRzL7V1XL3u3PK2vcklhtTrdaP235DjC9qJhzidrxtWr68mA5ySSjUrgrXk622bKkw==" + }, + "node_modules/@cdktf/hcl-tools": { + "version": "0.20.11", + "resolved": "https://registry.npmjs.org/@cdktf/hcl-tools/-/hcl-tools-0.20.11.tgz", + "integrity": "sha512-ymg3a3vFlLgo3Nbz9zpUUYorQuq0WChJpbyDIfCvAvQ4j1OWwJubmoeDSzh645WCRecwgurRJCly7QACetE03A==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", @@ -414,13 +440,28 @@ "node": ">=18" } }, + "node_modules/@types/lodash": { + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", + "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "dev": true + }, "node_modules/@types/node": { - "version": "22.13.13", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.13.tgz", - "integrity": "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ==", + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "dev": true, "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" + } + }, + "node_modules/arktype": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.1.19.tgz", + "integrity": "sha512-notORSuTSpfLV7rq0kYC4mTgIVlVR0xQuvtFxOaE9aKiXyON/kgoIBwZZcKeSSb4BebNcfJoGlxJicAUl/HMdw==", + "dependencies": { + "@ark/schema": "0.45.9", + "@ark/util": "0.45.9" } }, "node_modules/aws-iam-policy-types": { @@ -434,6 +475,11 @@ "node": ">=18.0.0" } }, + "node_modules/constructs": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.2.tgz", + "integrity": "sha512-wsNxBlAott2qg8Zv87q3eYZYgheb9lchtBfjHzzLHtXbttwSrHPs1NNQbBrmbb1YZvYg2+Vh0Dor76w4mFxJkA==" + }, "node_modules/esbuild": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", @@ -474,6 +520,19 @@ "@esbuild/win32-x64": "0.25.1" } }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -500,6 +559,41 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -529,9 +623,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -542,10 +636,29 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } } } } diff --git a/codegen/package.json b/codegen/package.json index dd6500f..4110cbf 100644 --- a/codegen/package.json +++ b/codegen/package.json @@ -4,11 +4,19 @@ "start": "tsc --noEmit && tsx ./src/main.ts" }, "devDependencies": { - "@types/node": "^22.13.13", + "@types/lodash": "^4.17.16", + "@types/node": "^22.14.0", "tsx": "^4.19.3", - "typescript": "^5.8.2" + "typescript": "^5.8.3" }, "dependencies": { - "aws-iam-policy-types": "^1.0.2" + "@cdktf/hcl-tools": "^0.20.11", + "arktype": "^2.1.19", + "aws-iam-policy-types": "^1.0.2", + "constructs": "^10.4.2", + "fs-extra": "^11.3.0", + "lodash": "^4.17.21", + "prettier": "^3.5.3", + "yaml": "^2.7.1" } } diff --git a/codegen/src/asset-account/index.ts b/codegen/src/asset-account/index.ts new file mode 100644 index 0000000..5717c7c --- /dev/null +++ b/codegen/src/asset-account/index.ts @@ -0,0 +1,38 @@ +import { ArkErrors } from "arktype"; +import { CloudFormationParams, TerraformParams } from "./params"; +import * as mir from "./mir"; +import * as terraform from "./orchestrator/terraform"; +import * as cloudformation from "./orchestrator/cloudformation"; + +export type CloudFormationParams = typeof CloudFormationParams.inferIn; +export type TerraformParams = typeof TerraformParams.inferIn; + +export function generateCloudFormation(paramsIn: CloudFormationParams) { + const params = CloudFormationParams(paramsIn); + return generate(params, cloudformation.generate); +} + +export function generateTerraform(paramsIn: TerraformParams) { + const params = TerraformParams(paramsIn); + return generate(params, terraform.generate); +} + +type Generator

= ( + resources: Record, + params: P, +) => R; + +function generate

>( + params: P | ArkErrors, + generator: F, +): R { + if (params instanceof ArkErrors) { + throw new Error(`Invalid params: ${params}`); + } + + const resources = mir.resources(params); + + params.tags["elastio:resource"] = "true"; + + return generator(resources, params); +} diff --git a/codegen/src/asset-account/mir/cloud-connector.ts b/codegen/src/asset-account/mir/cloud-connector.ts new file mode 100644 index 0000000..1cad402 --- /dev/null +++ b/codegen/src/asset-account/mir/cloud-connector.ts @@ -0,0 +1,343 @@ +import * as iam from "../../common/iam"; +import * as inventory from "../../common/inventory"; +import type { Params } from "."; +import _ from "lodash"; +import { IamRole } from "./resource"; + +export function cloudConnectorRole(params: Params): IamRole { + const otherStatements: Record = { + WriteEc2: [ + // Create and copy snapshots to the cloud connector account + { + Action: [ + "ec2:CreateSnapshot", + "ec2:CreateSnapshots", + "ec2:CopySnapshot", + ], + Resource: ["*"], + }, + { + Action: [ + "ec2:DeleteSnapshot", + + // This is used in AWS Backup restore test scenario + "ec2:StopInstances", + "ec2:TerminateInstances", + + // Allows additional tags for elastio resources. + // It's used, for example, to add new tag on a + // snapshot which indicates that it's clean or infected + "ec2:CreateTags", + "ec2:DeleteTags", + ], + Resource: ["*"], + Condition: iam.hasResourceTag("elastio:resource"), + }, + + { + Action: ["ec2:ModifySnapshotAttribute"], + Resource: ["*"], + + Condition: { + // Needed to add createVolumePermission for the connector account. + StringEquals: { + "ec2:Add/userId": params.connectorAccountId, + // Even though EC2 IAM reference says there are two more + // things that could be used in the condition: + // "ec2:Attribute" and "ec2:Attribute/${AttributeName}", + // none of them are actually present when the request + // is evaluated. This can be seen by adding a condition like + // "ec2:Attribute": "createVolumePermission" and observing + // an error with an encoded authorization message, that + // shows the real evaluation context when decoded. + }, + }, + }, + + // This is used in AWS Backup restore test scenario to mutate + // the created temporary instance/volumes + { + Action: [ + "ec2:ModifyInstanceAttribute", + "ec2:CreateTags", + "ec2:DeleteTags", + ], + Resource: ["*"], + Condition: iam.hasResourceTag("awsbackup-restore-test"), + }, + + // Allow assigning tags when creating new resources + { + Action: ["ec2:CreateTags"], + Resource: ["arn:aws:ec2:*:*:volume/*", "arn:aws:ec2:*::snapshot/*"], + Condition: { + StringLike: { + "ec2:CreateAction": "*", + }, + }, + }, + { + Action: ["ssm:SendCommand"], + Resource: [ + "arn:aws:ssm:*:*:document/AWSEC2-CreateVssSnapshot", + "arn:aws:ec2:*:*:instance/*", + ], + }, + { + Action: [ + "ssm:GetConnectionStatus", + "ssm:GetCommandInvocation", + "ssm:ListCommands", + ], + Resource: ["*"], + }, + ], + + ReadEbs: [ + { + Action: ["ebs:ListSnapshotBlocks", "ebs:GetSnapshotBlock"], + Resource: ["*"], + }, + ], + + ReadSsm: [ + // Required to be able to try to do app-consistent snapshots + // of EC2 Windows (VSS snapshots). + { + Action: ["ssm:GetParameters", "ssm:GetParameter"], + Resource: [ + `arn:aws:ssm:*:{{account_id}}:parameter/elastio/*`, + `arn:aws:ssm:*::parameter/aws/*`, + ], + }, + ], + + ReadIam: [ + { + Action: ["iam:GetInstanceProfile", "iam:SimulatePrincipalPolicy"], + Resource: ["*"], + }, + ], + + WriteCloudformation: [ + { + Action: ["cloudformation:CreateStack", "cloudformation:UpdateStack"], + Resource: [`arn:aws:cloudformation:*:{{account_id}}:stack/elastio-*/*`], + Condition: { + StringEquals: { + ["cloudformation:RoleArn"]: + "{{aws_iam_role.asset_region_stack_deployer.arn}}", + }, + }, + }, + { + // Allows running cloudformation:CreateStack/UpdateStack + // on behalf of `asset_region_stack_deployer` role. + Action: ["iam:PassRole"], + Resource: ["{{aws_iam_role.asset_region_stack_deployer.arn}}"], + }, + { + Action: ["cloudformation:TagResource"], + Resource: [`arn:aws:cloudformation:*:{{account_id}}:stack/elastio-*/*`], + Condition: { + StringLike: { + "ec2:CreateAction": "*", + }, + }, + }, + { + Action: [ + // We need to delete the asset region stack + "cloudformation:DeleteStack", + "cloudformation:TagResource", + "cloudformation:UntagResource", + ], + Resource: [`arn:aws:cloudformation:*:{{account_id}}:stack/elastio-*/*`], + Condition: iam.hasResourceTag("elastio:resource"), + }, + ], + + ReadS3: [ + { + Action: [ + // These are actually used by the scan job at the time of this writing + "s3:ListBucket", + "s3:GetObjectVersion", + "s3:GetObject", + "s3:GetBucketTagging", + + // These permissions were used at the time when we supported native Elastio + // backups and restores. These are readonly, and they are left here for + // the future when we might need them or for easier debugging. + "s3:GetReplicationConfiguration", + "s3:GetMetricsConfiguration", + "s3:GetLifecycleConfiguration", + "s3:GetInventoryConfiguration", + "s3:GetIntelligentTieringConfiguration", + "s3:GetEncryptionConfiguration", + "s3:GetBucketWebsite", + "s3:GetBucketVersioning", + "s3:GetBucketRequestPayment", + "s3:GetBucketPublicAccessBlock", + "s3:GetBucketPolicy", + "s3:GetBucketOwnershipControls", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketNotification", + "s3:GetBucketLogging", + "s3:GetBucketLocation", + "s3:GetBucketAcl", + "s3:GetAnalyticsConfiguration", + "s3:GetAccelerateConfiguration", + ], + Resource: ["*"], + }, + ], + + ReadSqs: [ + { + // Read the S3 changelog SQS queue + Action: ["sqs:ReceiveMessage", "sqs:DeleteMessage"], + Resource: ["*"], + Condition: iam.hasResourceTag("elastio:resource"), + }, + ], + + ReadDrs: [ + { + Action: [ + "drs:DescribeRecoverySnapshots", + "drs:DescribeSourceServers", + "drs:ListTagsForResource", + ], + Resource: ["*"], + }, + ], + + ReadKms: [allowKms()], + }; + + const statements = _.mergeWith( + inventory.policy, + otherStatements, + (dest, _src, key) => { + if (dest !== undefined) { + throw new Error( + `Duplicate policy statements in cloud connector policy at ${key}`, + ); + } + }, + ); + + return { + type: "aws_iam_role", + name: "ElastioCloudConnector", + description: + "Allows Elastio Cloud Connector to access the assets in this account", + + assumeRolePolicy: { + Action: ["sts:AssumeRole"], + + Principal: { + AWS: + `arn:aws:iam::${params.connectorAccountId}:role/` + + params.iamResourceNamesPrefix + + "ElastioCloudConnectorBastion" + + params.iamResourceNamesSuffix, + }, + + Condition: { + StringEquals: { + "sts:ExternalId": params.connectorRoleExternalId, + }, + }, + }, + + statements, + }; +} + +/** + * KMS actions needed to decrypt the data encrypted with the KMS key. + * This is used to decrypt data stored on EBS volumes and S3 buckets + * encrypted with a custom customer-managed KMS, for example. + * + * We explicitly add these permissions to our roles' IAM policies. Then we expect + * users to add a special tag `elastio:authorize` to their KMS keys to grant Elastio + * access to their KMS keys. The users also need to setup a KMS key resource-based + * policy that enables the AWS account where Elastio is deployed managing access + * via IAM policies through a KMS key policy like this: + * + * ```json + * { + * "Sid": "Enable IAM User Permissions", + * "Effect": "Allow", + * "Principal": { + * "AWS": "arn:aws:iam::111122223333:root" + * }, + * "Action": "kms:*", + * "Resource": "*" + * } + * ``` + * This is taken from [AWS docs](https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-default.html#key-policy-default-allow-root-enable-iam). + * + * Note that KMS resource-based policies similar to IAM role trust policies (which + * also qualify as resource-based policies) are exceptional in their behavior. + * See the excerpt from the AWS docs below about KMS key resource-based policies: + * + * > When the principal in a key policy statement is the account principal, + * > the policy statement doesn't give any IAM principal permission to use the + * > KMS key. Instead, it allows the account to use IAM policies to delegate + * > the permissions specified in the policy statement. This default key policy + * > statement allows the account to use IAM policies to delegate permission for + * > all actions (kms:*) on the KMS key. + * + * # Cross account scenario + * + * Note that IAM acts differently when cross-account access is involved in a general case. + * However, KMS with its exceptional behavior already acts similar to the cross-account + * scenario even within the same account, because it requires the resource-based policy + * to allow the direct principal to use the KMS key, or allow the entire root account + * principal and require the IAM role to have the identity-based policy that allows + * the KMS action. + * + * See details in the [issue comment](https://github.com/elastio/elastio/issues/9220#issuecomment-2110983526) + */ +export function allowKms(): iam.PolicyStatement { + return { + Action: [ + // These actions are needed to reencrypt the volumes that were encrypted + // by the KMS key. + "kms:ReEncryptFrom", + "kms:ReEncryptTo", + "kms:CreateGrant", + "kms:Encrypt", + + // This action is needed to describe the KMS key. + // Needed only for some cases. For example, when we want to backup and EBS volume + // that was created from a snapshot of the root volume of an EC2 instance. + // These calls are made by the ebs.amazonaws.com and not by our code. + "kms:DescribeKey", + + // GenerateDataKeyWithoutPlaintext in particular is required in case when + // we create a volume from an unencrypted snapshot but there is a default + // KMS encryption key set in EBS for the volume. + "kms:GenerateDataKey", + "kms:GenerateDataKeyWithoutPlaintext", + + // This is required when reading S3 buckets encrypted with a KMS key + "kms:Decrypt", + ], + + Resource: ["*"], + + // Users need to put a special tag `elastio:authorize` on their KMS keys to + // allow Elastio to use them for decrypting their data. We also allow + // access to Elastio-owned keys marked with the `elastio:resource` tag. + Condition: { + StringLike: { + ...iam.hasResourceTag("elastio:authorize").StringLike, + ...iam.hasResourceTag("elastio:resource").StringLike, + }, + }, + }; +} diff --git a/codegen/src/asset-account/mir/index.ts b/codegen/src/asset-account/mir/index.ts new file mode 100644 index 0000000..a294789 --- /dev/null +++ b/codegen/src/asset-account/mir/index.ts @@ -0,0 +1,87 @@ +import type { Resource } from "./resource"; +import { cloudConnectorRole } from "./cloud-connector"; +import { CloudFormationParams, TerraformParams } from "../params"; + +export { Resource }; + +export type Params = + | typeof CloudFormationParams.inferOut + | typeof TerraformParams.inferOut; + +const version = "0.35.13"; + +export function resources(inputs: Params): Record { + return { + inventory_event_target: { + type: "aws_iam_role", + name: "ElastioInventoryEventTarget", + description: + "Role assumed by EventBridge to send events to the Elastio Connector", + + assumeRolePolicy: { + Action: "sts:AssumeRole", + Principal: { + Service: "events.amazonaws.com", + }, + }, + statements: { + SendEventsToConnectorAccount: [ + { + Action: ["events:PutEvents"], + Resource: [ + `arn:aws:events:*:${inputs.connectorAccountId}:event-bus/elastio-*`, + ], + }, + ], + }, + }, + + asset_region_stack_deployer: { + type: "aws_iam_role", + name: "ElastioAssetRegionStackDeployer", + description: "Used by CloudFormation to deploy region-level stack", + assumeRolePolicy: { + Action: "sts:AssumeRole", + Principal: { + Service: "cloudformation.amazonaws.com", + }, + }, + statements: { + ManageElastioEventBridgeRules: [ + { + Action: [ + "events:DescribeRule", + "events:ListTargetsByRule", + "events:ListTagsForResource", + "events:PutRule", + "events:PutTargets", + "events:RemoveTargets", + "events:DeleteRule", + "events:EnableRule", + "events:DisableRule", + ], + Resource: [`arn:aws:events:*:{{account_id}}:rule/elastio-*`], + }, + { + Action: [ + // Allows assigning of `inventory_event_target` role + // to the event targets + "iam:PassRole", + ], + Resource: "{{aws_iam_role.inventory_event_target.arn}}", + }, + ], + }, + }, + + cloud_connector: cloudConnectorRole(inputs), + }; + + // new ssm.StringParameter(this, "assetAccountStackName", { + // parameterName: "/elastio/asset/account/stack-name", + // description: + // "The name of the Asset Account CloudFormation stack used " + + // "for discovery by the Cloud Connector", + // stringValue: Aws.STACK_NAME, + // }); +} diff --git a/codegen/src/asset-account/mir/resource.ts b/codegen/src/asset-account/mir/resource.ts new file mode 100644 index 0000000..408656f --- /dev/null +++ b/codegen/src/asset-account/mir/resource.ts @@ -0,0 +1,18 @@ +import * as iam from "../../common/iam"; + +export type Resource = IamRole | SsmParameter; + +export type IamRole = { + type: "aws_iam_role"; + name: string; + description: string; + assumeRolePolicy: iam.PolicyStatement; + statements: Record; +}; + +type SsmParameter = { + type: "aws_ssm_parameter"; + name: string; + description: string; + value: string; +}; diff --git a/codegen/src/asset-account/orchestrator/cloudformation.ts b/codegen/src/asset-account/orchestrator/cloudformation.ts new file mode 100644 index 0000000..9fd41ff --- /dev/null +++ b/codegen/src/asset-account/orchestrator/cloudformation.ts @@ -0,0 +1,107 @@ +import _ from "lodash"; +import * as hclTools from "@cdktf/hcl-tools"; +import * as iam from "../../common/iam"; +import * as prettier from "prettier"; +import { CloudFormationParams } from "../params"; +import { Resource } from "../mir"; + +async function literal(value: unknown): Promise { + const json = JSON.stringify(value, null, 2); + + const pretty = await prettier.format(json, { parser: "json" }); + return pretty.trim(); +} + +async function jsonencode(value: unknown): Promise { + return `jsonencode(\n${await literal(value)}\n)`; +} + +function policyDocument(statements: iam.PolicyStatement[]) { + return { + Version: "2012-10-17", + Statement: statements.map((statement) => ({ + Effect: statement.Effect ?? "Allow", + ...statement, + })), + }; +} + +export type CloudFormationParams = typeof CloudFormationParams.inferOut; + +export interface CloudFormationProject { + files: Record; +} + +export async function generate( + resources: Record, + params: CloudFormationParams, +): Promise { + const parts = [ + `locals { + tags = ${await literal(params.tags)} + }`, + ]; + + for (const [id, resource] of Object.entries(resources)) { + switch (resource.type) { + case "aws_iam_role": { + parts.push( + `resource "aws_iam_role" ${literal(id)} { + name = ${literal(resource.name)} + tags = local.tags + assume_role_policy = ${await jsonencode(policyDocument([resource.assumeRolePolicy]))} + }`, + ); + + const statements = Object.entries(resource.statements); + + if (statements.length === 0) { + continue; + } + + const policies = _.mapValues( + resource.statements, + (statement) => policyDocument(statement).Statement, + ); + + parts.push( + `resource "aws_iam_role_policy" ${literal(id)} { + role = aws_iam_role.${id}.name + name = each.key + policy = jsonencode( + { + "Version": "2012-10-17", + "Statement": each.value + } + ) + for_each = ${await literal(policies)} + }`, + ); + } + case "aws_ssm_parameter": { + } + } + } + + parts.push(` + data "aws_caller_identity" "current" {} + locals { + account_id = data.aws_caller_identity.current.account_id + } + `); + + const content = parts + .join("\n\n") + .replaceAll("{{account_id}}", "${local.account_id}") + .replaceAll(/\{\{(.*)\}\}/g, "${$1}"); + + const formatted = (await hclTools.format(content)).trim(); + + console.log(formatted); + + return { + files: { + "main.tf": formatted, + }, + }; +} diff --git a/codegen/src/asset-account/orchestrator/terraform.ts b/codegen/src/asset-account/orchestrator/terraform.ts new file mode 100644 index 0000000..9a75157 --- /dev/null +++ b/codegen/src/asset-account/orchestrator/terraform.ts @@ -0,0 +1,107 @@ +import _ from "lodash"; +import * as hclTools from "@cdktf/hcl-tools"; +import * as iam from "../../common/iam"; +import * as prettier from "prettier"; +import { TerraformParams } from "../params"; +import { Resource } from "../mir"; + +async function literal(value: unknown): Promise { + const json = JSON.stringify(value, null, 2); + + const pretty = await prettier.format(json, { parser: "json" }); + return pretty.trim(); +} + +async function jsonencode(value: unknown): Promise { + return `jsonencode(\n${await literal(value)}\n)`; +} + +function policyDocument(statements: iam.PolicyStatement[]) { + return { + Version: "2012-10-17", + Statement: statements.map((statement) => ({ + Effect: statement.Effect ?? "Allow", + ...statement, + })), + }; +} + +export type TerraformParams = typeof TerraformParams.inferOut; + +export interface TerraformProject { + files: Record; +} + +export async function generate( + resources: Record, + params: TerraformParams, +): Promise { + const parts = [ + `locals { + tags = ${await literal(params.tags)} + }`, + ]; + + for (const [id, resource] of Object.entries(resources)) { + switch (resource.type) { + case "aws_iam_role": { + parts.push( + `resource "aws_iam_role" ${literal(id)} { + name = ${literal(resource.name)} + tags = local.tags + assume_role_policy = ${await jsonencode(policyDocument([resource.assumeRolePolicy]))} + }`, + ); + + const statements = Object.entries(resource.statements); + + if (statements.length === 0) { + continue; + } + + const policies = _.mapValues( + resource.statements, + (statement) => policyDocument(statement).Statement, + ); + + parts.push( + `resource "aws_iam_role_policy" ${literal(id)} { + role = aws_iam_role.${id}.name + name = each.key + policy = jsonencode( + { + "Version": "2012-10-17", + "Statement": each.value + } + ) + for_each = ${await literal(policies)} + }`, + ); + } + case "aws_ssm_parameter": { + } + } + } + + parts.push(` + data "aws_caller_identity" "current" {} + locals { + account_id = data.aws_caller_identity.current.account_id + } + `); + + const content = parts + .join("\n\n") + .replaceAll("{{account_id}}", "${local.account_id}") + .replaceAll(/\{\{(.*)\}\}/g, "${$1}"); + + const formatted = (await hclTools.format(content)).trim(); + + console.log(formatted); + + return { + files: { + "main.tf": formatted, + }, + }; +} diff --git a/codegen/src/asset-account/params.ts b/codegen/src/asset-account/params.ts new file mode 100644 index 0000000..cb9a8d8 --- /dev/null +++ b/codegen/src/asset-account/params.ts @@ -0,0 +1,135 @@ +import { type } from "arktype"; +import { collapse } from "../common/string"; + +const IamPolicyArn = type(/^arn:aws:iam::[^:]*:policy\/.*/); + +const CommonParams = type({ + connectorAccountId: type(/^\d{12}$/).configure({ + title: "Cloud connector AWS account ID", + description: collapse` + The ID of the Elastio Connector's account that should scan assets + in this account. It will be trusted to assume the role in this + account to read the assets and create snapshots. + `, + }), + + connectorRoleExternalId: type("string").configure({ + title: "Connector IAM role external ID", + description: collapse` + The secret token generated specifically for this account that + authenticates the source Elastio Connector to assume the + ElastioCloudConnector role in this account + `, + actual: "", + }), + + tags: type({ "[string]": "string" }) + .configure({ + title: "Tags", + description: collapse` + Tags to add to all resources deployed by this stack. + `, + }) + .default(() => ({})), + + iamResourceNamesPrefix: type("string") + .configure({ + title: "IAM resource names prefix", + description: + "Add a custom prefix to names of all IAM resources deployed by this stack", + }) + .default(""), + + iamResourceNamesSuffix: type("string") + .configure({ + title: "IAM resource names suffix", + description: + "Add a custom suffix to names of all IAM resources deployed by this stack", + }) + .default(""), + + globalManagedPolicies: IamPolicyArn.array() + .configure({ + title: "Global IAM managed policies ARNs", + description: + "IAM managed policies ARNs to attach to all Elastio IAM roles", + }) + .narrow((list, ctx) => { + const unique = new Set(list); + return ( + unique.size === list.length || ctx.mustBe("a list without diplicates") + ); + }) + .default(() => []), + + globalPermissionBoundary: IamPolicyArn.configure({ + title: "Global IAM permission boundary policy ARN", + description: collapse` + The ARN of the IAM managed policy to use as a + permission boundary for all Elastio IAM roles + `, + }).optional(), +}); + +export const CloudFormationParams = CommonParams.merge({ + orchestrator: "'cloudformation'", + + disableDeploymentNotification: type("boolean") + .configure({ + title: "Deployment Notification", + description: collapse` + Send a deployment notification to the Elastio Connector + account. This is required for the connector to be able to + discover the assets in this account. + `, + }) + .default(false), + + deploymentNotificationToken: type("string") + .configure({ + title: "Deployment notification token", + description: collapse` + Token sent to the SNS topic to authenticate the deployment notification. + `, + actual: "", + }) + .optional(), + + deploymentNotificationSnsTopicArn: type("string") + .configure({ + title: "Deployment notification SNS topic ARN", + description: collapse` + ARN of the Elastio tenant SNS topic where to publish a notification about a + completed stack deployment. + `, + }) + .optional(), + + encryptWithCmk: type("boolean") + .configure({ + title: "Encrypt data with customer-managed KMS keys", + description: collapse` + Provision additional customer-managed KMS keys to encrypt + Lambda environment variables, DynamoDB tables, S3. Note that + by default data is encrypted with AWS-managed keys. Enable this + option only if your compliance requirements mandate the usage of CMKs. + If this option is disabled Elastio creates only 1 CMK per region where + the Elastio Connector stack is deployed. If this option is enabled then + Elastio creates 1 KMS key per AWS account and 2 KMS keys per every AWS + region where Elastio is deployed in your AWS Account + `, + }) + .default(false), + + lambdaTracing: type("boolean") + .configure({ + title: "Enable AWS X-Ray tracing for Lambda functions", + description: + "This increases the cost of the stack. Enable only if needed", + }) + .default(false), +}).describe("Params specific to CloudFormation orchestrator"); + +export const TerraformParams = CommonParams.describe( + "Params specific to Terraform orchestrator", +); diff --git a/codegen/src/iam.ts b/codegen/src/common/iam.ts similarity index 86% rename from codegen/src/iam.ts rename to codegen/src/common/iam.ts index 911bb82..4c4f3d0 100644 --- a/codegen/src/iam.ts +++ b/codegen/src/common/iam.ts @@ -9,23 +9,23 @@ export interface Policy { statements: PolicyStatement[]; } -interface PolicyStatement { +export type PolicyStatement = { /** * If not specified then `Allow` is assumed. */ Effect?: "Deny"; Action: Action | Action[]; - Principal?: Principal; - Resource: string | string[]; Condition?: Record; + Principal?: Principal; + Resource?: string | string[]; /** * Statement ID usually used as a description of the statement. */ Sid?: string; -} +}; -type Principal = +export type Principal = | "*" | { AWS: string | string[]; @@ -37,18 +37,25 @@ type Principal = Service: string | string[]; }; -type Action = +export type Action = | `${string}:*` | `${iam.AwsBackupActions}` + | `${iam.AwsBatchActions}` | `${iam.AwsCloudformationActions}` + | `${iam.AwsDrsActions}` | `${iam.AwsEbsActions}` | `${iam.AwsEc2Actions}` + | `${iam.AwsElasticfilesystemActions}` + | `${iam.AwsEventsActions}` + | `${iam.AwsFsxActions}` | `${iam.AwsIamActions}` | `${iam.AwsKmsActions}` | `${iam.AwsLambdaActions}` | `${iam.AwsLogsActions}` | `${iam.AwsS3Actions}` - | `${iam.AwsSsmActions}`; + | `${iam.AwsSqsActions}` + | `${iam.AwsSsmActions}` + | `${iam.AwsStsActions}`; type KnownTag = // A simple tag that customers can add to their resource for Elastio to diff --git a/codegen/src/common/inventory.ts b/codegen/src/common/inventory.ts new file mode 100644 index 0000000..5551984 --- /dev/null +++ b/codegen/src/common/inventory.ts @@ -0,0 +1,125 @@ +import * as iam from "./iam"; + +/** + * Permissions for reading inventory shared across lambas, background jobs, + * ElastioTenant (Connector Account) and CloudConnector (Asset Account) roles. + */ +export const policy: Record = { + ReadInventory: [ + { + Action: [ + // Vaults + "backup:ListBackupVaults", + "backup:DescribeBackupVault", + + // Recovery points + "backup:ListRecoveryPointsByResource", + "backup:DescribeRecoveryPoint", + "backup:ListRecoveryPointsByBackupVault", + "backup:GetRecoveryPointRestoreMetadata", + + // Common for all resources + "backup:ListTags", + + // Misc. This won't be used at the time of this writing, but + // may come in handy in the future? + "backup:ListProtectedResources", + "backup:ListProtectedResourcesByBackupVault", + + "elasticfilesystem:DescribeFileSystems", + "elasticfilesystem:ListTagsForResource", + "elasticfilesystem:DescribeTags", + + "fsx:DescribeVolumes", + "fsx:DescribeBackups", + "fsx:DescribeFileSystems", + "fsx:DescribeStorageVirtualMachines", + "fsx:ListTagsForResource", + + // Volumes + "ec2:DescribeVolumeStatus", + "ec2:DescribeVolumes", + + // Snapshots + "ec2:DescribeSnapshots", + "ec2:DescribeSnapshotAttribute", + + // Common for all resources + "ec2:DescribeTags", + + // Used for cost estimation + "ebs:ListSnapshotBlocks", + "ebs:ListChangedBlocks", + + "ec2:DescribeInstances", + "ec2:DescribeImages", + "ec2:DescribeHosts", + "ssm:DescribeInstanceInformation", + + // Used for network config troubleshooting + "ec2:DescribeAvailabilityZones", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "ec2:DescribeRouteTables", + "ec2:DescribeNatGateways", + "ec2:DescribeVpcEndpoints", + + "s3:ListAllMyBuckets", + "s3:GetBucketLocation", + "s3:GetBucketTagging", + "s3:GetBucketObjectLockConfiguration", + "s3:GetBucketAcl", + "s3:GetBucketVersioning", + "s3:GetBucketPolicy", + "s3:GetBucketLogging", + "s3:ListBucket", + + "iam:ListAccountAliases", + "ec2:DescribeRegions", + "kms:DescribeKey", + + // Required for discovering Elastio CFN stack version by blue stack + // Also used by used by the `accs` service to validate the status + // of the asset account CFN stack in cross-account scenario (this policy + // is reused when defining permissions for the connector role in asset account) + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackSet", + "cloudformation:ListStacks", + + // Just for debugging + "batch:DescribeComputeEnvironments", + "batch:DescribeJobDefinitions", + "batch:DescribeJobQueues", + "batch:DescribeJobs", + "batch:DescribeSchedulingPolicies", + + "batch:GetJobQueueSnapshot", + + "batch:ListJobs", + "batch:ListSchedulingPolicies", + "batch:ListTagsForResource", + + "drs:DescribeRecoverySnapshots", + "drs:DescribeSourceServers", + "drs:ListTagsForResource", + ], + Resource: "*", + }, + ], +}; + +/** + * Allow just the readonly actions. Make sure the policy doesn't compile if we + * specify some mutating actions. + */ +type InventoryIamActionPrefix = "List" | "Get" | "Describe"; + +type InventoryIamAction = Extract< + iam.Action, + `${string}:${InventoryIamActionPrefix}${string}` +>; + +type InventoryIamPolicyStatement = Omit & { + Action: InventoryIamAction | InventoryIamAction[]; +}; diff --git a/codegen/src/common/string.ts b/codegen/src/common/string.ts new file mode 100644 index 0000000..b6a02d9 --- /dev/null +++ b/codegen/src/common/string.ts @@ -0,0 +1,10 @@ +export function collapse( + strings: TemplateStringsArray, + ...args: (string | number)[] +): string { + return strings + .map((str, i) => (i < args.length ? str + args[i] : str)) + .join("") + .replace(/(^\s+|\s+$)/gm, "") + .replace(/\n/g, " "); +} diff --git a/codegen/src/main.ts b/codegen/src/main.ts index 01161a4..00bb2ff 100644 --- a/codegen/src/main.ts +++ b/codegen/src/main.ts @@ -1,89 +1,13 @@ -/** - * This script generates IAM policies from TypeScript files in the `policies` directory. - * We use TypeScript for this because it's easier to maintain shared code for policies - * this way and we also get nice compile-time checks from TypeScript for the IAM actions - * used in the policies. This codegen logic may also be used to support other kinds of - * orchestration tools like CloudFormation, CDK, etc. - */ - -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { Policy } from "./iam"; - -const scriptPath = fileURLToPath(import.meta.url); -const scriptDir = path.dirname(scriptPath); -const iamPoliciesTfModulePath = path.join( - path.join(scriptDir, "../../iam-policies/terraform"), -); - -async function writePolicy(policyName: string, policy: Policy) { - const policyDocument = { - Version: "2012-10-17", - Statement: policy.statements.map((statement) => ({ - ...statement, - Effect: statement.Effect ?? "Allow", - })), - }; - - const policyDefinition = { - Description: policy.description, - PolicyDocument: policyDocument, - }; - - const policyDocumentJson = JSON.stringify(policyDefinition, null, 2); - - const policyOutputPath = path.join( - iamPoliciesTfModulePath, - "policies", - `${policyName}.json`, - ); - - await fs.writeFile(policyOutputPath, policyDocumentJson); -} +import * as tfModulesDocs from './tf-modules-docs'; +import * as assetAccount from './asset-account'; async function main() { - const policiesDir = path.join(scriptDir, "policies"); - const policyFiles = await fs.readdir(policiesDir); - const policyNames = policyFiles.map((file) => path.basename(file, ".ts")); - - const policies = await Promise.all( - policyNames.map(async (policyName) => { - const policyPath = path.join(policiesDir, `${policyName}.ts`); - const module: { default: Policy } = await import(policyPath); - const policy = module.default; - - await writePolicy(policyName, policy); - - return [policyName, policy] as const; - }), - ); - - const policiesMdTable = policies - .map(([policyName, policy]) => { - const name = `[\`${policyName}\`][${policyName}]`; - return `| ${name} | ${policy.description} |`; - }) - .join("\n"); - - const links = policies - .map( - ([policyName]) => - `[${policyName}]: ../../codegen/src/policies/${policyName}.ts`, - ) - .join("\n"); - - const readmePath = path.join(iamPoliciesTfModulePath, "README.md"); - const readme = await fs.readFile(readmePath, "utf-8"); - - const docs = `| Policy | Description |\n| --- | --- |\n${policiesMdTable}\n\n${links}`; - - const policiesDocs = readme.replace( - /(.*)/s, - `\n${docs}\n`, - ); + await tfModulesDocs.generate(); - await fs.writeFile(readmePath, policiesDocs); + await assetAccount.generate({ + connectorAccountId: "123456789012", + connectorRoleExternalId: "external-id", + }); } main(); diff --git a/codegen/src/policies/ElastioAssetAccountDeployer.ts b/codegen/src/policies/ElastioAssetAccountDeployer.ts index 4ca6bd8..3f03029 100644 --- a/codegen/src/policies/ElastioAssetAccountDeployer.ts +++ b/codegen/src/policies/ElastioAssetAccountDeployer.ts @@ -1,4 +1,4 @@ -import * as iam from "../iam"; +import * as iam from "../common/iam"; /** * Use the following command to discover what resource types are deployed by diff --git a/codegen/src/policies/ElastioAwsBackupEc2Scan.ts b/codegen/src/policies/ElastioAwsBackupEc2Scan.ts index 2254569..d1e7066 100644 --- a/codegen/src/policies/ElastioAwsBackupEc2Scan.ts +++ b/codegen/src/policies/ElastioAwsBackupEc2Scan.ts @@ -1,4 +1,4 @@ -import * as iam from "../iam"; +import * as iam from "../common/iam"; export default { description: "Allows Elastio to scan AWS Backup EC2 and EBS recovery points.", diff --git a/codegen/src/tf-modules-docs.ts b/codegen/src/tf-modules-docs.ts new file mode 100644 index 0000000..a714c58 --- /dev/null +++ b/codegen/src/tf-modules-docs.ts @@ -0,0 +1,79 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Policy } from "./common/iam"; + +const scriptPath = fileURLToPath(import.meta.url); +const scriptDir = path.dirname(scriptPath); +const iamPoliciesTfModulePath = path.join( + path.join(scriptDir, "../../iam-policies/terraform"), +); + +async function writePolicy(policyName: string, policy: Policy) { + const policyDocument = { + Version: "2012-10-17", + Statement: policy.statements.map((statement) => ({ + ...statement, + Effect: statement.Effect ?? "Allow", + })), + }; + + const policyDefinition = { + Description: policy.description, + PolicyDocument: policyDocument, + }; + + const policyDocumentJson = JSON.stringify(policyDefinition, null, 2); + + const policyOutputPath = path.join( + iamPoliciesTfModulePath, + "policies", + `${policyName}.json`, + ); + + await fs.writeFile(policyOutputPath, policyDocumentJson); +} + +async function generate() { + const policiesDir = path.join(scriptDir, "policies"); + const policyFiles = await fs.readdir(policiesDir); + const policyNames = policyFiles.map((file) => path.basename(file, ".ts")); + + const policies = await Promise.all( + policyNames.map(async (policyName) => { + const policyPath = path.join(policiesDir, `${policyName}.ts`); + const module: { default: Policy } = await import(policyPath); + const policy = module.default; + + await writePolicy(policyName, policy); + + return [policyName, policy] as const; + }), + ); + + const policiesMdTable = policies + .map(([policyName, policy]) => { + const name = `[\`${policyName}\`][${policyName}]`; + return `| ${name} | ${policy.description} |`; + }) + .join("\n"); + + const links = policies + .map( + ([policyName]) => + `[${policyName}]: ../../codegen/src/policies/${policyName}.ts`, + ) + .join("\n"); + + const readmePath = path.join(iamPoliciesTfModulePath, "README.md"); + const readme = await fs.readFile(readmePath, "utf-8"); + + const docs = `| Policy | Description |\n| --- | --- |\n${policiesMdTable}\n\n${links}`; + + const policiesDocs = readme.replace( + /(.*)/s, + `\n${docs}\n`, + ); + + await fs.writeFile(readmePath, policiesDocs); +}