diff --git a/docs/self-host/security.md b/docs/self-host/security.md index 5ceed80c..29eae2dc 100644 --- a/docs/self-host/security.md +++ b/docs/self-host/security.md @@ -71,7 +71,7 @@ If the first launch fails, CloudFormation rolls the stack back and deletes what | SetupKey | Stores the install guide's one-time setup key in the secret, only while no GitHub App is stored there. | | Allowlist | Puts the first admin, the sign-up domain and the two-factor rule on the sign-in allowlist. It adds entries and never removes them. | | Domain | With **Hostname** set: asks the updater to request the certificate (see [Your own domain](./domains.md)). | -| Status | With the install guide's progress id: writes a small public progress file (the phase, and your URL when done). On a failed first launch it writes CloudFormation's own reason. | +| Status | With the install guide's progress id and key: reports the phase (and your URL when done) to CoderHelm with the key; nothing in your account is public. Deletes an older release's public progress bucket. On a failed first launch it writes CloudFormation's own reason. | | HybridLeave | When the stack is deleted: releases the CoderHelm address and links of a [hybrid](./hybrid.md) install. | | Sweep | When the stack is deleted with **KeepDataOnDelete** `false`: empties the data buckets and deletes the tables. See [Uninstall](./uninstall.md). | | Final | When the stack is deleted, after every part and after HybridLeave and SetupKey: deletes the certificates the updater requested for this install and their validation records, and every log group the parts declare, by its exact name (Lambda makes one again when a function's last logs arrive after CloudFormation deleted it). With **KeepDataOnDelete** `false`, it also deletes the install's other log groups that are still there, only those tagged `coderhelm:install = coderhelm-`, and the install's secret, without a recovery window. | @@ -111,7 +111,7 @@ The helper's role carries the boundary and can only do this (`helperPolicy()` in - write its own log group, `/aws/lambda/coderhelm--install-helper`; - read, write, list and delete objects in the release bucket; - list and delete objects in buckets named `coderhelm--*` (to empty the data buckets on delete); -- write the install guide's progress file and its read policy, in this install's own progress bucket, `coderhelm-setup-`; +- delete an older release's progress bucket, `coderhelm-setup-`: its policy, its files and the bucket (never write to it or make anything public); - read and write the install's secret, `coderhelm//secrets`, and delete it (only on a stack delete with **KeepDataOnDelete** `false`); - write items to, describe, update and delete the tables named `coderhelm-` and `coderhelm--*` (the allowlist at launch; the tables on delete), and use the KMS keys tagged `coderhelm:install = coderhelm-` to encrypt and decrypt for them; - invoke `coderhelm--updater` (the domain); @@ -124,7 +124,7 @@ Delete work runs only while the root stack itself is being deleted and follows * Besides CloudFormation, only the updater calls it, for one thing: before a change set, copy a release's part templates into the release bucket (`stage`). It downloads that release's `coderhelm.json` itself, checks it against the sha256 from the signed `SHA256SUMS` that the updater passes, and copies only the part templates it lists, each checked like ReleaseAssets does. It trusts the sha256 its caller passes: a caller that may invoke it could stage the part templates of any release CoderHelm published (from the write-once release location, only under that release's own `releases//stacks/`), which changes nothing that runs. -It answers only CloudFormation's own response address for a custom resource, and it keeps account numbers, ARNs and email addresses out of the public progress file. +It answers only CloudFormation's own response address for a custom resource, and it keeps account numbers, ARNs and email addresses out of the progress reports it sends to CoderHelm. It downloads release files over HTTPS from `coderhelm-public.s3.amazonaws.com/launch/v...` only, and refuses any other address. It can't create or change roles, policies, functions or stacks. diff --git a/docs/self-host/setup-checks.md b/docs/self-host/setup-checks.md index 6bf2634c..e0293636 100644 --- a/docs/self-host/setup-checks.md +++ b/docs/self-host/setup-checks.md @@ -8,12 +8,12 @@ The [install guide](https://docs.coderhelm.com/self-host/install/) installs Code ## How the install guide works -1. **Launch.** The guide makes a CloudFormation quick-create link with your region, install name and email filled in, plus two random values made in your browser: - - a **progress ID** (16 characters), which names a small bucket, `coderhelm-setup-`, in your account; +1. **Launch.** The guide makes a CloudFormation quick-create link with your region, install name and email filled in, plus random values made in your browser: + - a **progress ID** (16 characters) and, with hybrid, a **progress key** (40 characters, a hidden stack parameter). The guide registers the ID with CoderHelm along with the key's SHA-256, before AWS opens; - a **setup key** (40 characters), kept in your browser's storage and in the stack's parameters. Quick-create links ignore hidden (NoEcho) parameters, so both values appear in the stack's Parameters tab. The setup key works only once, only on your install, and only until a GitHub App exists. -2. **Progress.** The stack's Status resource (run by the install helper) writes `status.json` to that bucket: the phase (`deploying` when the stack starts, `done` at the end, `failed` if the first launch rolls back), a plain message, the release, the install name and region, and at the end your CoderHelm URL. On a failure the message is CloudFormation's own reason for the first resource that failed. With your own domain, Settings, then System shows the DNS records to add. The guide reads `https://coderhelm-setup-.s3..amazonaws.com/status.json`. If your account blocks public bucket policies (S3 Block Public Access at account level), the file stays private and the guide asks you to paste the URL from the stack's Outputs instead. The install itself is unaffected. +2. **Progress.** The stack's Status resource (run by the install helper) reports each phase to CoderHelm (`PUT https://api.coderhelm.com/api/install-progress/`, the key in the body): the phase (`deploying` when the stack starts, `done` at the end, `failed` if the first launch rolls back), a plain message with account numbers, ARNs and emails taken out, the release, the install name and region, and at the end your CoderHelm URL. The guide reads it back with the key; without it nobody can read or write it, and CoderHelm keeps it for 14 days. Nothing in your account is public. The private guide sends CoderHelm nothing, so it asks you to paste the URL from the stack's Outputs. Releases before this one wrote a public `status.json` to a bucket, `coderhelm-setup-`: the install helper deletes that bucket on the stack's first update, and the guide reads the old file only while CoderHelm has no report for the ID. 3. **Checks.** Once the URL is known, the guide polls `GET /api/setup/status` (below) and shows each check as waiting, passing or failing, with a fix. 4. **GitHub App.** The guide posts a GitHub App manifest to github.com with `redirect_url` set to `/api/setup/github/callback` and `state` set to the setup key. GitHub sends the browser to your install with a one-time code. Your install exchanges the code for the app's keys, stores them in its own secret, restarts its services to read them and sends you back to the guide. The keys never leave your account. diff --git a/infra/lib/native/parent.ts b/infra/lib/native/parent.ts index c71f82fc..9ca27c87 100644 --- a/infra/lib/native/parent.ts +++ b/infra/lib/native/parent.ts @@ -135,7 +135,8 @@ export function parameters(): Json { UpdateTimezone: { Type: "String", Default: "UTC", AllowedPattern: "^(UTC|[A-Za-z_]+(/[A-Za-z0-9_+-]+)+)$", Description: "The window's time zone, e.g. Europe/Berlin." }, KeepDataOnDelete: { Type: "String", Default: "true", AllowedValues: ["true", "false"], Description: "true: deleting this stack keeps the tables, data buckets, keys, user pool and secret. false: deleting it removes them too (keys after AWS's 7-day minimum)." }, SetupToken: { Type: "String", Default: "", NoEcho: true, AllowedPattern: "^$|^[A-Za-z0-9]{32,64}$", Description: "A one-time key from the install guide that lets it hand your new GitHub App's keys to this install. Empty to create the GitHub App later." }, - StatusId: { Type: "String", Default: "", AllowedPattern: "^$|^[a-z0-9]{16}$", Description: "Lets the install guide show progress (a small public status file with the phase and your URL). Empty for none." }, + StatusId: { Type: "String", Default: "", AllowedPattern: "^$|^[a-z0-9]{16}$", Description: "Lets the install guide show progress: each phase is reported to CoderHelm with StatusKey, and only the guide that made the key can read it. Nothing in your account is public. Empty for none." }, + StatusKey: { Type: "String", Default: "", NoEcho: true, AllowedPattern: "^$|^[A-Za-z0-9]{32,64}$", Description: "The install guide's key for its progress (see StatusId). Empty for none." }, DomainCertificateArn: { Type: "String", Default: "", Description: "Set by CoderHelm's stack update once your domain's certificate is issued. Leave empty." }, DomainZoneId: { Type: "String", Default: "", Description: "No longer used: CoderHelm never changes DNS (kept so older stacks update). Leave empty." }, }; @@ -190,7 +191,8 @@ export function helperPolicy(): PolicyDocument { { Sid: "OwnLog", Effect: "Allow", Action: ["logs:CreateLogStream", "logs:PutLogEvents"], Resource: `${at("logs")}:log-group:/aws/lambda/${p}-install-helper:*` }, { Sid: "ReleaseFiles", Effect: "Allow", Action: ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObjectVersion"], Resource: [`arn:\${AWS::Partition}:s3:::${p}-release-\${AWS::AccountId}`, `arn:\${AWS::Partition}:s3:::${p}-release-\${AWS::AccountId}/*`] }, { Sid: "EmptyDataBucketsOnDelete", Effect: "Allow", Action: ["s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], Resource: [`arn:\${AWS::Partition}:s3:::${p}-*`, `arn:\${AWS::Partition}:s3:::${p}-*/*`] }, - { Sid: "StatusFile", Effect: "Allow", Action: ["s3:PutObject", "s3:PutBucketPolicy", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], Resource: ["arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}", "arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}/*"] }, + // An older release's public progress bucket: deleted, never written (progress goes to CoderHelm with the guide's key). + { Sid: "RetireProgressBucket", Effect: "Allow", Action: ["s3:DeleteBucketPolicy", "s3:DeleteBucket", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], Resource: ["arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}", "arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}/*"] }, // DeleteSecret: only on a stack delete with KeepDataOnDelete=false (Final). { Sid: "Secret", Effect: "Allow", Action: ["secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue", "secretsmanager:DeleteSecret"], Resource: `${at("secretsmanager")}:secret:coderhelm/\${Stage}/secrets-??????` }, { Sid: "AllowlistAndTablesOnDelete", Effect: "Allow", Action: ["dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:DeleteTable", "dynamodb:UpdateTable"], Resource: [`${at("dynamodb")}:table/${p}`, `${at("dynamodb")}:table/${p}-*`] }, @@ -231,7 +233,7 @@ export function deployRolePolicy(n: AccessNames): PolicyDocument { // The install's secret: read and update its settings, never delete it (its value comes from the helper and the gateway). { Sid: "InstallSecret", Effect: "Allow", Action: ["secretsmanager:DescribeSecret", "secretsmanager:GetSecretValue", "secretsmanager:UpdateSecret", "secretsmanager:TagResource", "secretsmanager:UntagResource"], Resource: "arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:coderhelm/${Stage}/secrets-??????" }, { Sid: "SecretPassword", Effect: "Allow", Action: "secretsmanager:GetRandomPassword", Resource: "*" }, - // The install guide's progress bucket. + // An older release's progress bucket, so CloudFormation can remove it on update. { Sid: "ProgressBucket", Effect: "Allow", Action: "s3:*", Resource: ["arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}", "arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}/*"] }, ], }; @@ -377,24 +379,15 @@ export function buildParent(input: ParentInput): Json { Type: "Custom::ReleaseAssets", Properties: { ...helper, Bucket: { Ref: "AssetsBucket" }, BaseUrl: `${RELEASE_BUCKET_URL}/${version}`, Version: version, Files: files }, }; - R.StatusBucket = { - Type: "AWS::S3::Bucket", - Condition: "HasStatus", - Properties: { - BucketName: sub("coderhelm-setup-${StatusId}"), - OwnershipControls: { Rules: [{ ObjectOwnership: "BucketOwnerEnforced" }] }, - PublicAccessBlockConfiguration: { BlockPublicAcls: true, IgnorePublicAcls: true, BlockPublicPolicy: false, RestrictPublicBuckets: false }, - CorsConfiguration: { CorsRules: [{ AllowedMethods: ["GET", "HEAD"], AllowedOrigins: ["*"], MaxAge: 300 }] }, - LifecycleConfiguration: { Rules: [{ Id: "expire-progress", Status: "Enabled", ExpirationInDays: 30 }] }, - }, - }; + // Progress goes to CoderHelm with the guide's key; nothing in the account is public. const statusProps = { - StatusBucket: { "Fn::If": ["HasStatus", { Ref: "StatusBucket" }, ""] }, + StatusId: { Ref: "StatusId" }, + StatusKey: { Ref: "StatusKey" }, Version: version, Stage: { Ref: "Stage" }, Region: { Ref: "AWS::Region" }, }; - const statusOn = { ...statusProps, StatusBucket: { Ref: "StatusBucket" } }; + const statusOn = statusProps; R.StatusStart = { Type: "Custom::Status", Condition: "HasStatus", Properties: { ...helper, ...statusOn, Phase: "start" } }; // Deleted after every part (they depend on it): certificates, and logs Lambda made by itself. R.Final = { @@ -403,7 +396,7 @@ export function buildParent(input: ParentInput): Json { ...helper, Prefix: sub("coderhelm-${Stage}"), KeepData: { Ref: "KeepDataOnDelete" }, - StatusBucket: statusProps.StatusBucket, + StatusId: { Ref: "StatusId" }, LogGroupPrefixes: [sub("/aws/lambda/coderhelm-${Stage}-"), sub("/aws/codebuild/coderhelm-${Stage}-")], LogGroupNames: declaredLogGroups(children).map((x) => sub(x)), SecretId: { Ref: "InstallSecret" }, @@ -540,7 +533,7 @@ export function buildParent(input: ParentInput): Json { { Label: { default: "Who may join" }, Parameters: ["SignupDomain", "RequireTwoFactor"] }, { Label: { default: "Optional" }, Parameters: ["EmailTransport", "EmailFrom", "BedrockInferencePrefix"] }, { Label: { default: "Optional: updates" }, Parameters: ["AutoUpdate", "UpdateDay", "UpdateHour", "UpdateTimezone"] }, - { Label: { default: "Filled in by the install guide or by CoderHelm (leave as they are)" }, Parameters: ["StatusId", "SetupToken", "DomainCertificateArn", "DomainZoneId"] }, + { Label: { default: "Filled in by the install guide or by CoderHelm (leave as they are)" }, Parameters: ["StatusId", "StatusKey", "SetupToken", "DomainCertificateArn", "DomainZoneId"] }, ], }, CoderHelm: { version, files: files.length }, diff --git a/infra/scripts/deploy-role-sim.py b/infra/scripts/deploy-role-sim.py index 41cf931d..806e3725 100644 --- a/infra/scripts/deploy-role-sim.py +++ b/infra/scripts/deploy-role-sim.py @@ -693,7 +693,9 @@ def helper_rows(): allowed = [ ("s3:PutObject", f"{s3}{P}-release-{ACCT}/assets/a.zip"), ("s3:DeleteObject", f"{s3}{P}-release-{ACCT}/assets/a.zip"), ("s3:ListBucketVersions", f"{s3}{P}-data-{ACCT}"), ("s3:DeleteObjectVersion", f"{s3}{P}-data-{ACCT}/x"), - ("s3:PutObject", f"{s3}coderhelm-setup-0123456789abcdef/status.json"), + # An older release's public progress bucket: deleted, never written. + ("s3:DeleteBucketPolicy", f"{s3}coderhelm-setup-0123456789abcdef"), ("s3:DeleteBucket", f"{s3}coderhelm-setup-0123456789abcdef"), + ("s3:DeleteObjectVersion", f"{s3}coderhelm-setup-0123456789abcdef/status.json"), ("secretsmanager:PutSecretValue", f"{at('secretsmanager')}:secret:coderhelm/prod/secrets-AbCdEf"), ("dynamodb:DeleteTable", f"{at('dynamodb')}:table/{P}-traces"), ("dynamodb:BatchWriteItem", f"{at('dynamodb')}:table/{P}-settings"), # The main table has no suffix (the live test's failed delete). @@ -725,6 +727,8 @@ def helper_rows(): ("s3:DeleteBucket", f"{s3}someone-else"), ("s3:DeleteBucket", f"{s3}{P}-dashboard-{ACCT}"), ("kms:DeleteAlias", f"{at('kms')}:alias/my-app"), ("kms:ScheduleKeyDeletion", f"{at('kms')}:key/0f0f0f0f-1111-2222-3333-444455556666"), ("cognito-idp:DeleteUserPool", f"{at('cognito-idp')}:userpool/us-east-1_someones"), + # Nothing public: progress goes to CoderHelm with the guide's key. + ("s3:PutObject", f"{s3}coderhelm-setup-0123456789abcdef/status.json"), ("s3:PutBucketPolicy", f"{s3}coderhelm-setup-0123456789abcdef"), ] return [{"action": a, "resource": r, "want": "allowed"} for a, r in allowed] + [{"action": a, "resource": r, "want": "denied"} for a, r in denied] diff --git a/infra/test/fixtures/native-sim.json b/infra/test/fixtures/native-sim.json index 83cdcaf4..25077063 100644 --- a/infra/test/fixtures/native-sim.json +++ b/infra/test/fixtures/native-sim.json @@ -1361,11 +1361,11 @@ {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/2819175352ad1ce0dae768e83fc328fb70fb5f10b4a8ff0ccbcb791f02b0716d.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/3423a042b818e31c1e34a19d6689ab2e5f9b70fcbe9e71df66f241b20a200bd9.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/53eada20248e9d1494fc00c7310a9df9f3647025d008c2562adb60bf7ac88f2d.zip"}, - {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/7b44faf39d25c239c5790797d2c0d87dd014ad0528a2c94467b3975a17a54c0a.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/a77bb41544bf739e532a3faedb5ecda0bba5d9e19ab56eaa0fc6ec92f1bf5827.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/ae46d59f832d7b7e8ed231a7a3af7955b7585bf1cef81350968eb356db6a79e0.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/bc4e026165204dddfc22a4d9b0dd1cc9b8f53c37b8575a9c4638b289303f2da4.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/c6a0d267ee8fc5537ab8911079af833d5135b8ba2dada7bb2046cb9b7cab6902.zip"}, + {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/dfa2ec5066963bca8a1394faef4ceafca564232d628b3cfc0815a5d3d6a9140c.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/e2323f040b88a4c0ffdb662a75b62cad20b096cc1a873043f7b71c3927548590.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::LayerVersion create"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/e2659170a0721541efa761a8d5d04d5e36cbbf691c4b15a9053002b7c825055d.zip"}, {"actions": {"s3:GetObject": "allowed", "s3:GetObjectVersion": "allowed"}, "calls": ["AWS::Lambda::Function create", "AWS::Lambda::Function update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/faa95a81ae7d7373f3e1f242268f904eb748d8d0fdd306e8a6fe515a1905a7d6.zip"}, @@ -1373,10 +1373,6 @@ {"actions": {"s3:CreateBucket": "allowed", "s3:PutBucketTagging": "allowed"}, "calls": ["AWS::S3::Bucket create"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:RequestTag/coderhelm:install": "coderhelm-prod", "aws:TagKeys": ["coderhelm:install"]}, "resource": "arn:aws:s3:::coderhelm-prod-sandbox-111122223333"}, {"actions": {"s3:BypassGovernanceRetention": "allowed", "s3:CreateBucketMetadataTableConfiguration": "allowed", "s3:DeleteBucket": "allowed", "s3:DeleteBucketAnalyticsConfiguration": "allowed", "s3:DeleteBucketCors": "allowed", "s3:DeleteBucketEncryption": "allowed", "s3:DeleteBucketLifecycle": "allowed", "s3:DeleteBucketMetadataTableConfiguration": "allowed", "s3:DeleteBucketMetricsConfiguration": "allowed", "s3:DeleteBucketPolicy": "allowed", "s3:DeleteBucketReplication": "allowed", "s3:DeleteBucketTagging": "allowed", "s3:DeleteBucketWebsite": "allowed", "s3:DeleteObject": "allowed", "s3:DeleteObjectVersion": "allowed", "s3:GetAccelerateConfiguration": "allowed", "s3:GetAnalyticsConfiguration": "allowed", "s3:GetBucketAbac": "allowed", "s3:GetBucketAcl": "allowed", "s3:GetBucketCORS": "allowed", "s3:GetBucketDefaultStorageClass": "allowed", "s3:GetBucketLogging": "allowed", "s3:GetBucketMetadataTableConfiguration": "allowed", "s3:GetBucketNotification": "allowed", "s3:GetBucketObjectLockConfiguration": "allowed", "s3:GetBucketOwnershipControls": "allowed", "s3:GetBucketPolicy": "allowed", "s3:GetBucketPublicAccessBlock": "allowed", "s3:GetBucketTagging": "allowed", "s3:GetBucketVersioning": "allowed", "s3:GetBucketWebsite": "allowed", "s3:GetEncryptionConfiguration": "allowed", "s3:GetIntelligentTieringConfiguration": "allowed", "s3:GetInventoryConfiguration": "allowed", "s3:GetLifecycleConfiguration": "allowed", "s3:GetMetricsConfiguration": "allowed", "s3:GetReplicationConfiguration": "allowed", "s3:ListBucket": "allowed", "s3:ListTagsForResource": "allowed", "s3:PutAccelerateConfiguration": "allowed", "s3:PutAnalyticsConfiguration": "allowed", "s3:PutBucketAbac": "allowed", "s3:PutBucketAcl": "allowed", "s3:PutBucketCORS": "allowed", "s3:PutBucketDefaultStorageClass": "allowed", "s3:PutBucketLogging": "allowed", "s3:PutBucketNotification": "allowed", "s3:PutBucketObjectLockConfiguration": "allowed", "s3:PutBucketOwnershipControls": "allowed", "s3:PutBucketPolicy": "allowed", "s3:PutBucketPublicAccessBlock": "allowed", "s3:PutBucketReplication": "allowed", "s3:PutBucketVersioning": "allowed", "s3:PutBucketWebsite": "allowed", "s3:PutEncryptionConfiguration": "allowed", "s3:PutIntelligentTieringConfiguration": "allowed", "s3:PutInventoryConfiguration": "allowed", "s3:PutLifecycleConfiguration": "allowed", "s3:PutMetricsConfiguration": "allowed", "s3:PutObjectLockConfiguration": "allowed", "s3:PutReplicationConfiguration": "allowed", "s3:UntagResource": "allowed", "s3:UpdateBucketMetadataAnnotationTableConfiguration": "allowed", "s3:UpdateBucketMetadataInventoryTableConfiguration": "allowed", "s3:UpdateBucketMetadataJournalTableConfiguration": "allowed"}, "calls": ["AWS::S3::Bucket create", "AWS::S3::Bucket delete", "AWS::S3::Bucket read", "AWS::S3::Bucket tag", "AWS::S3::Bucket update", "AWS::S3::BucketPolicy create", "AWS::S3::BucketPolicy delete", "AWS::S3::BucketPolicy read", "AWS::S3::BucketPolicy update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-prod-sandbox-111122223333"}, {"actions": {"s3:CreateBucketMetadataTableConfiguration": "allowed", "s3:PutAccelerateConfiguration": "allowed", "s3:PutAnalyticsConfiguration": "allowed", "s3:PutBucketAbac": "allowed", "s3:PutBucketAcl": "allowed", "s3:PutBucketCORS": "allowed", "s3:PutBucketDefaultStorageClass": "allowed", "s3:PutBucketLogging": "allowed", "s3:PutBucketNotification": "allowed", "s3:PutBucketObjectLockConfiguration": "allowed", "s3:PutBucketOwnershipControls": "allowed", "s3:PutBucketPolicy": "allowed", "s3:PutBucketPublicAccessBlock": "allowed", "s3:PutBucketReplication": "allowed", "s3:PutBucketVersioning": "allowed", "s3:PutBucketWebsite": "allowed", "s3:PutEncryptionConfiguration": "allowed", "s3:PutIntelligentTieringConfiguration": "allowed", "s3:PutInventoryConfiguration": "allowed", "s3:PutLifecycleConfiguration": "allowed", "s3:PutMetricsConfiguration": "allowed", "s3:PutObjectAcl": "allowed", "s3:PutObjectLockConfiguration": "allowed", "s3:PutObjectVersionAcl": "allowed", "s3:PutReplicationConfiguration": "allowed"}, "calls": ["AWS::S3::Bucket create", "AWS::S3::BucketPolicy create"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack"}, "resource": "arn:aws:s3:::coderhelm-prod-sandbox-111122223333"}, - {"actions": {"s3:PutBucketTagging": "allowed", "s3:TagResource": "allowed"}, "calls": ["AWS::S3::Bucket create", "AWS::S3::Bucket tag", "AWS::S3::Bucket update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:RequestTag/coderhelm:install": "coderhelm-prod", "aws:ResourceTag/coderhelm:install": "coderhelm-prod", "aws:TagKeys": ["coderhelm:install"]}, "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef"}, - {"actions": {"s3:CreateBucket": "allowed", "s3:PutBucketTagging": "allowed"}, "calls": ["AWS::S3::Bucket create"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:RequestTag/coderhelm:install": "coderhelm-prod", "aws:TagKeys": ["coderhelm:install"]}, "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef"}, - {"actions": {"s3:BypassGovernanceRetention": "allowed", "s3:CreateBucketMetadataTableConfiguration": "allowed", "s3:DeleteBucket": "allowed", "s3:DeleteBucketAnalyticsConfiguration": "allowed", "s3:DeleteBucketCors": "allowed", "s3:DeleteBucketEncryption": "allowed", "s3:DeleteBucketLifecycle": "allowed", "s3:DeleteBucketMetadataTableConfiguration": "allowed", "s3:DeleteBucketMetricsConfiguration": "allowed", "s3:DeleteBucketReplication": "allowed", "s3:DeleteBucketTagging": "allowed", "s3:DeleteBucketWebsite": "allowed", "s3:DeleteObject": "allowed", "s3:DeleteObjectVersion": "allowed", "s3:GetAccelerateConfiguration": "allowed", "s3:GetAnalyticsConfiguration": "allowed", "s3:GetBucketAbac": "allowed", "s3:GetBucketAcl": "allowed", "s3:GetBucketCORS": "allowed", "s3:GetBucketDefaultStorageClass": "allowed", "s3:GetBucketLogging": "allowed", "s3:GetBucketMetadataTableConfiguration": "allowed", "s3:GetBucketNotification": "allowed", "s3:GetBucketObjectLockConfiguration": "allowed", "s3:GetBucketOwnershipControls": "allowed", "s3:GetBucketPublicAccessBlock": "allowed", "s3:GetBucketTagging": "allowed", "s3:GetBucketVersioning": "allowed", "s3:GetBucketWebsite": "allowed", "s3:GetEncryptionConfiguration": "allowed", "s3:GetIntelligentTieringConfiguration": "allowed", "s3:GetInventoryConfiguration": "allowed", "s3:GetLifecycleConfiguration": "allowed", "s3:GetMetricsConfiguration": "allowed", "s3:GetReplicationConfiguration": "allowed", "s3:ListBucket": "allowed", "s3:ListTagsForResource": "allowed", "s3:PutAccelerateConfiguration": "allowed", "s3:PutAnalyticsConfiguration": "allowed", "s3:PutBucketAbac": "allowed", "s3:PutBucketAcl": "allowed", "s3:PutBucketCORS": "allowed", "s3:PutBucketDefaultStorageClass": "allowed", "s3:PutBucketLogging": "allowed", "s3:PutBucketNotification": "allowed", "s3:PutBucketObjectLockConfiguration": "allowed", "s3:PutBucketOwnershipControls": "allowed", "s3:PutBucketPublicAccessBlock": "allowed", "s3:PutBucketReplication": "allowed", "s3:PutBucketVersioning": "allowed", "s3:PutBucketWebsite": "allowed", "s3:PutEncryptionConfiguration": "allowed", "s3:PutIntelligentTieringConfiguration": "allowed", "s3:PutInventoryConfiguration": "allowed", "s3:PutLifecycleConfiguration": "allowed", "s3:PutMetricsConfiguration": "allowed", "s3:PutObjectLockConfiguration": "allowed", "s3:PutReplicationConfiguration": "allowed", "s3:UntagResource": "allowed", "s3:UpdateBucketMetadataAnnotationTableConfiguration": "allowed", "s3:UpdateBucketMetadataInventoryTableConfiguration": "allowed", "s3:UpdateBucketMetadataJournalTableConfiguration": "allowed"}, "calls": ["AWS::S3::Bucket create", "AWS::S3::Bucket delete", "AWS::S3::Bucket read", "AWS::S3::Bucket tag", "AWS::S3::Bucket update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef"}, - {"actions": {"s3:CreateBucketMetadataTableConfiguration": "allowed", "s3:PutAccelerateConfiguration": "allowed", "s3:PutAnalyticsConfiguration": "allowed", "s3:PutBucketAbac": "allowed", "s3:PutBucketAcl": "allowed", "s3:PutBucketCORS": "allowed", "s3:PutBucketDefaultStorageClass": "allowed", "s3:PutBucketLogging": "allowed", "s3:PutBucketNotification": "allowed", "s3:PutBucketObjectLockConfiguration": "allowed", "s3:PutBucketOwnershipControls": "allowed", "s3:PutBucketPublicAccessBlock": "allowed", "s3:PutBucketReplication": "allowed", "s3:PutBucketVersioning": "allowed", "s3:PutBucketWebsite": "allowed", "s3:PutEncryptionConfiguration": "allowed", "s3:PutIntelligentTieringConfiguration": "allowed", "s3:PutInventoryConfiguration": "allowed", "s3:PutLifecycleConfiguration": "allowed", "s3:PutMetricsConfiguration": "allowed", "s3:PutObjectAcl": "allowed", "s3:PutObjectLockConfiguration": "allowed", "s3:PutObjectVersionAcl": "allowed", "s3:PutReplicationConfiguration": "allowed"}, "calls": ["AWS::S3::Bucket create"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack"}, "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef"}, {"actions": {"scheduler:TagResource": "allowed"}, "calls": ["AWS::Scheduler::ScheduleGroup create", "AWS::Scheduler::ScheduleGroup tag", "AWS::Scheduler::ScheduleGroup update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:RequestTag/coderhelm:install": "coderhelm-prod", "aws:ResourceTag/coderhelm:install": "coderhelm-prod", "aws:TagKeys": ["coderhelm:install"]}, "resource": "arn:aws:scheduler:us-east-1:111122223333:schedule-group/coderhelm-prod-plugins"}, {"actions": {"scheduler:CreateScheduleGroup": "allowed"}, "calls": ["AWS::Scheduler::ScheduleGroup create"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:RequestTag/coderhelm:install": "coderhelm-prod", "aws:TagKeys": ["coderhelm:install"]}, "resource": "arn:aws:scheduler:us-east-1:111122223333:schedule-group/coderhelm-prod-plugins"}, {"actions": {"scheduler:DeleteScheduleGroup": "allowed", "scheduler:GetScheduleGroup": "allowed", "scheduler:ListTagsForResource": "allowed", "scheduler:UntagResource": "allowed"}, "calls": ["AWS::Scheduler::ScheduleGroup create", "AWS::Scheduler::ScheduleGroup delete", "AWS::Scheduler::ScheduleGroup read", "AWS::Scheduler::ScheduleGroup tag", "AWS::Scheduler::ScheduleGroup update"], "context": {"aws:PrincipalArn": "arn:aws:iam::111122223333:role/coderhelm-prod-deploy-stack", "aws:ResourceTag/coderhelm:install": "coderhelm-prod"}, "resource": "arn:aws:scheduler:us-east-1:111122223333:schedule-group/coderhelm-prod-plugins"}, @@ -1520,13 +1516,15 @@ ] }, "helper": { - "policy": {"Statement": [{"Action": ["logs:CreateLogStream", "logs:PutLogEvents"], "Effect": "Allow", "Resource": "arn:aws:logs:us-east-1:111122223333:log-group:/aws/lambda/coderhelm-prod-install-helper:*", "Sid": "OwnLog"}, {"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObjectVersion"], "Effect": "Allow", "Resource": ["arn:aws:s3:::coderhelm-prod-release-111122223333", "arn:aws:s3:::coderhelm-prod-release-111122223333/*"], "Sid": "ReleaseFiles"}, {"Action": ["s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], "Effect": "Allow", "Resource": ["arn:aws:s3:::coderhelm-prod-*", "arn:aws:s3:::coderhelm-prod-*/*"], "Sid": "EmptyDataBucketsOnDelete"}, {"Action": ["s3:PutObject", "s3:PutBucketPolicy", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], "Effect": "Allow", "Resource": ["arn:aws:s3:::coderhelm-setup-0123456789abcdef", "arn:aws:s3:::coderhelm-setup-0123456789abcdef/*"], "Sid": "StatusFile"}, {"Action": ["secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue", "secretsmanager:DeleteSecret"], "Effect": "Allow", "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:coderhelm/prod/secrets-??????", "Sid": "Secret"}, {"Action": ["dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:DeleteTable", "dynamodb:UpdateTable"], "Effect": "Allow", "Resource": ["arn:aws:dynamodb:us-east-1:111122223333:table/coderhelm-prod", "arn:aws:dynamodb:us-east-1:111122223333:table/coderhelm-prod-*"], "Sid": "AllowlistAndTablesOnDelete"}, {"Action": ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*", "kms:DescribeKey"], "Condition": {"StringEquals": {"aws:ResourceTag/coderhelm:install": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "*", "Sid": "TableKey"}, {"Action": "lambda:InvokeFunction", "Effect": "Allow", "Resource": "arn:aws:lambda:us-east-1:111122223333:function:coderhelm-prod-updater", "Sid": "Domain"}, {"Action": ["cloudformation:DescribeStacks", "cloudformation:DescribeStackEvents"], "Effect": "Allow", "Resource": "arn:aws:cloudformation:us-east-1:111122223333:stack/coderhelm-prod/*", "Sid": "RootStack"}, {"Action": ["logs:DescribeLogGroups"], "Effect": "Allow", "Resource": "*", "Sid": "LogGroupsOnDelete"}, {"Action": ["logs:DeleteLogGroup", "logs:ListTagsForResource"], "Effect": "Allow", "Resource": ["arn:aws:logs:us-east-1:111122223333:log-group:/aws/lambda/coderhelm-prod-*", "arn:aws:logs:us-east-1:111122223333:log-group:/aws/codebuild/coderhelm-prod-*"], "Sid": "InstallLogGroupsOnDelete"}, {"Action": ["acm:ListCertificates", "acm:ListTagsForCertificate", "acm:DescribeCertificate"], "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1"}}, "Effect": "Allow", "Resource": "*", "Sid": "FindCertificates"}, {"Action": "acm:DeleteCertificate", "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1", "aws:ResourceTag/coderhelm": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "*", "Sid": "CertificatesOnDelete"}, {"Action": ["ssm:GetParameter", "ssm:DeleteParameter"], "Effect": "Allow", "Resource": "arn:aws:ssm:us-east-1:111122223333:parameter/coderhelm-prod/uninstall/delete-data", "Sid": "UninstallSwitch"}, {"Action": "s3:DeleteBucket", "Effect": "Allow", "Resource": "arn:aws:s3:::coderhelm-prod-data-111122223333", "Sid": "DataBucketOnSwitch"}, {"Action": ["cognito-idp:DescribeUserPool", "cognito-idp:UpdateUserPool", "cognito-idp:DeleteUserPool", "cognito-idp:DeleteUserPoolDomain"], "Condition": {"StringEquals": {"aws:ResourceTag/coderhelm:install": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "arn:aws:cognito-idp:us-east-1:111122223333:userpool/*", "Sid": "UserPoolOnSwitch"}, {"Action": "kms:ListAliases", "Effect": "Allow", "Resource": "*", "Sid": "FindKeyAliases"}, {"Action": "kms:DeleteAlias", "Effect": "Allow", "Resource": "arn:aws:kms:us-east-1:111122223333:alias/coderhelm-prod-*", "Sid": "KeyAliasesOnSwitch"}, {"Action": ["kms:ScheduleKeyDeletion", "kms:DeleteAlias"], "Condition": {"StringEquals": {"aws:ResourceTag/coderhelm:install": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "arn:aws:kms:us-east-1:111122223333:key/*", "Sid": "KeysOnSwitch"}], "Version": "2012-10-17"}, + "policy": {"Statement": [{"Action": ["logs:CreateLogStream", "logs:PutLogEvents"], "Effect": "Allow", "Resource": "arn:aws:logs:us-east-1:111122223333:log-group:/aws/lambda/coderhelm-prod-install-helper:*", "Sid": "OwnLog"}, {"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObjectVersion"], "Effect": "Allow", "Resource": ["arn:aws:s3:::coderhelm-prod-release-111122223333", "arn:aws:s3:::coderhelm-prod-release-111122223333/*"], "Sid": "ReleaseFiles"}, {"Action": ["s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], "Effect": "Allow", "Resource": ["arn:aws:s3:::coderhelm-prod-*", "arn:aws:s3:::coderhelm-prod-*/*"], "Sid": "EmptyDataBucketsOnDelete"}, {"Action": ["s3:DeleteBucketPolicy", "s3:DeleteBucket", "s3:ListBucket", "s3:ListBucketVersions", "s3:DeleteObject", "s3:DeleteObjectVersion"], "Effect": "Allow", "Resource": ["arn:aws:s3:::coderhelm-setup-0123456789abcdef", "arn:aws:s3:::coderhelm-setup-0123456789abcdef/*"], "Sid": "RetireProgressBucket"}, {"Action": ["secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue", "secretsmanager:DeleteSecret"], "Effect": "Allow", "Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:coderhelm/prod/secrets-??????", "Sid": "Secret"}, {"Action": ["dynamodb:BatchWriteItem", "dynamodb:DescribeTable", "dynamodb:DeleteTable", "dynamodb:UpdateTable"], "Effect": "Allow", "Resource": ["arn:aws:dynamodb:us-east-1:111122223333:table/coderhelm-prod", "arn:aws:dynamodb:us-east-1:111122223333:table/coderhelm-prod-*"], "Sid": "AllowlistAndTablesOnDelete"}, {"Action": ["kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey*", "kms:DescribeKey"], "Condition": {"StringEquals": {"aws:ResourceTag/coderhelm:install": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "*", "Sid": "TableKey"}, {"Action": "lambda:InvokeFunction", "Effect": "Allow", "Resource": "arn:aws:lambda:us-east-1:111122223333:function:coderhelm-prod-updater", "Sid": "Domain"}, {"Action": ["cloudformation:DescribeStacks", "cloudformation:DescribeStackEvents"], "Effect": "Allow", "Resource": "arn:aws:cloudformation:us-east-1:111122223333:stack/coderhelm-prod/*", "Sid": "RootStack"}, {"Action": ["logs:DescribeLogGroups"], "Effect": "Allow", "Resource": "*", "Sid": "LogGroupsOnDelete"}, {"Action": ["logs:DeleteLogGroup", "logs:ListTagsForResource"], "Effect": "Allow", "Resource": ["arn:aws:logs:us-east-1:111122223333:log-group:/aws/lambda/coderhelm-prod-*", "arn:aws:logs:us-east-1:111122223333:log-group:/aws/codebuild/coderhelm-prod-*"], "Sid": "InstallLogGroupsOnDelete"}, {"Action": ["acm:ListCertificates", "acm:ListTagsForCertificate", "acm:DescribeCertificate"], "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1"}}, "Effect": "Allow", "Resource": "*", "Sid": "FindCertificates"}, {"Action": "acm:DeleteCertificate", "Condition": {"StringEquals": {"aws:RequestedRegion": "us-east-1", "aws:ResourceTag/coderhelm": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "*", "Sid": "CertificatesOnDelete"}, {"Action": ["ssm:GetParameter", "ssm:DeleteParameter"], "Effect": "Allow", "Resource": "arn:aws:ssm:us-east-1:111122223333:parameter/coderhelm-prod/uninstall/delete-data", "Sid": "UninstallSwitch"}, {"Action": "s3:DeleteBucket", "Effect": "Allow", "Resource": "arn:aws:s3:::coderhelm-prod-data-111122223333", "Sid": "DataBucketOnSwitch"}, {"Action": ["cognito-idp:DescribeUserPool", "cognito-idp:UpdateUserPool", "cognito-idp:DeleteUserPool", "cognito-idp:DeleteUserPoolDomain"], "Condition": {"StringEquals": {"aws:ResourceTag/coderhelm:install": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "arn:aws:cognito-idp:us-east-1:111122223333:userpool/*", "Sid": "UserPoolOnSwitch"}, {"Action": "kms:ListAliases", "Effect": "Allow", "Resource": "*", "Sid": "FindKeyAliases"}, {"Action": "kms:DeleteAlias", "Effect": "Allow", "Resource": "arn:aws:kms:us-east-1:111122223333:alias/coderhelm-prod-*", "Sid": "KeyAliasesOnSwitch"}, {"Action": ["kms:ScheduleKeyDeletion", "kms:DeleteAlias"], "Condition": {"StringEquals": {"aws:ResourceTag/coderhelm:install": "coderhelm-prod"}}, "Effect": "Allow", "Resource": "arn:aws:kms:us-east-1:111122223333:key/*", "Sid": "KeysOnSwitch"}], "Version": "2012-10-17"}, "rows": [ {"action": "s3:PutObject", "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/a.zip", "simulator": "allowed", "want": "allowed"}, {"action": "s3:DeleteObject", "resource": "arn:aws:s3:::coderhelm-prod-release-111122223333/assets/a.zip", "simulator": "allowed", "want": "allowed"}, {"action": "s3:ListBucketVersions", "resource": "arn:aws:s3:::coderhelm-prod-data-111122223333", "simulator": "allowed", "want": "allowed"}, {"action": "s3:DeleteObjectVersion", "resource": "arn:aws:s3:::coderhelm-prod-data-111122223333/x", "simulator": "allowed", "want": "allowed"}, - {"action": "s3:PutObject", "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef/status.json", "simulator": "allowed", "want": "allowed"}, + {"action": "s3:DeleteBucketPolicy", "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef", "simulator": "allowed", "want": "allowed"}, + {"action": "s3:DeleteBucket", "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef", "simulator": "allowed", "want": "allowed"}, + {"action": "s3:DeleteObjectVersion", "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef/status.json", "simulator": "allowed", "want": "allowed"}, {"action": "secretsmanager:PutSecretValue", "resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:coderhelm/prod/secrets-AbCdEf", "simulator": "allowed", "want": "allowed"}, {"action": "dynamodb:DeleteTable", "resource": "arn:aws:dynamodb:us-east-1:111122223333:table/coderhelm-prod-traces", "simulator": "allowed", "want": "allowed"}, {"action": "dynamodb:BatchWriteItem", "resource": "arn:aws:dynamodb:us-east-1:111122223333:table/coderhelm-prod-settings", "simulator": "allowed", "want": "allowed"}, @@ -1566,7 +1564,9 @@ {"action": "s3:DeleteBucket", "resource": "arn:aws:s3:::coderhelm-prod-dashboard-111122223333", "simulator": "implicitDeny", "want": "denied"}, {"action": "kms:DeleteAlias", "resource": "arn:aws:kms:us-east-1:111122223333:alias/my-app", "simulator": "implicitDeny", "want": "denied"}, {"action": "kms:ScheduleKeyDeletion", "resource": "arn:aws:kms:us-east-1:111122223333:key/0f0f0f0f-1111-2222-3333-444455556666", "simulator": "implicitDeny", "want": "denied"}, - {"action": "cognito-idp:DeleteUserPool", "resource": "arn:aws:cognito-idp:us-east-1:111122223333:userpool/us-east-1_someones", "simulator": "implicitDeny", "want": "denied"} + {"action": "cognito-idp:DeleteUserPool", "resource": "arn:aws:cognito-idp:us-east-1:111122223333:userpool/us-east-1_someones", "simulator": "implicitDeny", "want": "denied"}, + {"action": "s3:PutObject", "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef/status.json", "simulator": "implicitDeny", "want": "denied"}, + {"action": "s3:PutBucketPolicy", "resource": "arn:aws:s3:::coderhelm-setup-0123456789abcdef", "simulator": "implicitDeny", "want": "denied"} ] }, "notSimulated": [ diff --git a/infra/test/native.test.ts b/infra/test/native.test.ts index 02e49ee5..00b2aff2 100644 --- a/infra/test/native.test.ts +++ b/infra/test/native.test.ts @@ -162,7 +162,12 @@ test("only the helper writes release files; its deletes reach only this install' const by = (sid: string) => doc.Statement.find((x) => x.Sid === sid)!; assert.deepEqual(by("CertificatesOnDelete").Condition, { StringEquals: { "aws:RequestedRegion": "us-east-1", "aws:ResourceTag/coderhelm": "coderhelm-${Stage}" } }); assert.ok(!JSON.stringify(doc).includes("route53:"), "the helper has no Route 53 access"); - assert.deepEqual(by("StatusFile").Resource, ["arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}", "arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}/*"]); + // An older release's public progress bucket is only ever deleted: no write, no bucket policy. + assert.deepEqual(by("RetireProgressBucket").Resource, ["arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}", "arn:${AWS::Partition}:s3:::coderhelm-setup-${StatusId}/*"]); + for (const s of doc.Statement) { + const acts = ([] as string[]).concat(s.Action as string | string[]); + assert.ok(!acts.some((a) => /^s3:(PutBucketPolicy|PutBucketAcl|PutObjectAcl|PutPublicAccessBlock|DeletePublicAccessBlock)$/.test(a)), `${s.Sid}: nothing may make a bucket public`); + } }); test("the helper may only do what security.md lists", () => { @@ -468,8 +473,9 @@ test("the bar: every piece the real uninstall left is owned and removed by the s assert.match(R.Helper.Properties.Code.ZipFile, /def delete_tables\(/); // The certificate the updater requested is deleted by Final, after the distribution is gone. assert.match(R.Helper.Properties.Code.ZipFile, /def delete_certificates\(/); - // The progress bucket is the stack's (emptied by Final), not retained. - assert.notEqual(R.StatusBucket.DeletionPolicy, "Retain"); + // No progress bucket at all: progress goes to CoderHelm, and Final deletes an older release's. + assert.equal(R.StatusBucket, undefined); + assert.match(R.Helper.Properties.Code.ZipFile, /def retire_progress_bucket\(/); // One stack: no Launch Stack, no boundary or bucket left on purpose. assert.notEqual(R.Boundary.DeletionPolicy, "Retain"); assert.notEqual(R.AssetsBucket.DeletionPolicy, "Retain"); @@ -493,3 +499,12 @@ test("a native install says so, so setup checks never point at a config file it assert.equal(emailEnvironment(nativeConfig()).INSTALL_FORM, "native"); }); + +test("nothing in the account is public: no bucket policy, no public access block turned off, progress through CoderHelm", () => { + const t = JSON.stringify(P); + assert.doesNotMatch(t, /"BlockPublicPolicy":false|"RestrictPublicBuckets":false|"BlockPublicAcls":false|"IgnorePublicAcls":false/); + assert.doesNotMatch(t, /InstallGuideReadsProgress|"Principal":"\*","Action":"s3:GetObject"/); + assert.equal(P.Parameters.StatusKey.NoEcho, true); + assert.equal(R.StatusStart.Properties.StatusKey.Ref, "StatusKey"); + assert.equal(R.StatusStart.Properties.StatusBucket, undefined); +}); diff --git a/lambda/native-helper/handler.py b/lambda/native-helper/handler.py index f153e9fb..c6f0d03b 100644 --- a/lambda/native-helper/handler.py +++ b/lambda/native-helper/handler.py @@ -613,32 +613,60 @@ def request_domain(props, lam=None): def redact(text): - """Account numbers and ARNs out of anything in the public progress file.""" + """Account numbers, ARNs and emails out of anything in a progress report.""" t = re.sub(r"arn:aws[a-z-]*:[^\s\"']+", "", str(text or "")) t = re.sub(r"\b\d{12}\b", "", t) return re.sub(r"[^\s@\"'<>]+@[^\s@\"'<>]+\.[a-z]{2,}", "", t, flags=re.I) -def write_status(props, phase, message, url=None, s3=None): - bucket = props.get("StatusBucket") - if not bucket: +def progress_base(props): + """Hosted CoderHelm, where the install guide reads progress ("" for none).""" + url = (props.get("HybridUrl") or "https://api.coderhelm.com").rstrip("/") + return "" if url == "off" else url + + +def write_status(props, phase, message, url=None, opener=None): + """Reports a phase to hosted CoderHelm with the guide's key (StatusKey): + only the guide that made the key can read it, and nothing in this + account is public. Never fails the install: the guide then asks for the + stack's Outputs instead.""" + sid, key = str(props.get("StatusId") or ""), str(props.get("StatusKey") or "") + base = progress_base(props) + if not (re.fullmatch(r"[a-z0-9]{16}", sid) and re.fullmatch(r"[A-Za-z0-9]{32,64}", key) and base): return - s3 = s3 or client("s3") - if phase == "deploying": - # Lets anyone read status.json (only that file). Account-wide Block - # Public Access may refuse it: the guide then asks for the stack's - # Outputs instead, and the install goes on. - try: - s3.put_bucket_policy(Bucket=bucket, Policy=json.dumps({"Version": "2012-10-17", "Statement": [{ - "Sid": "InstallGuideReadsProgress", "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", - "Resource": f"arn:aws:s3:::{bucket}/status.json"}]})) - except Exception as e: - log("progress file stays private:", e) doc = {"schema": 1, "phase": phase, "message": redact(message)[:300], "url": url, "version": props.get("Version"), "stage": props.get("Stage"), "region": props.get("Region"), "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} - s3.put_object(Bucket=bucket, Key="status.json", Body=json.dumps(doc).encode(), - ContentType="application/json", CacheControl="no-store") + req = urllib.request.Request(f"{base}/api/install-progress/{sid}", method="PUT", + data=json.dumps({"key": key, "doc": doc}).encode(), + headers={"content-type": "application/json", "user-agent": "coderhelm-install"}) + try: + with (opener or urllib.request.urlopen)(req, timeout=8) as r: + log("progress reported:", phase, getattr(r, "status", 200)) + except Exception as e: + log("progress not reported:", e) + + +def retire_progress_bucket(props, s3=None): + """Older releases kept the guide's progress in a public bucket, + coderhelm-setup-. Its policy, files and the bucket itself go; + a bucket that isn't there is fine. Returns a problem or "".""" + sid = str(props.get("StatusId") or "") + if not re.fullmatch(r"[a-z0-9]{16}", sid): + return "" + bucket = f"coderhelm-setup-{sid}" + s3 = s3 or client("s3") + try: + s3.delete_bucket_policy(Bucket=bucket) + empty_bucket(bucket, s3) + s3.delete_bucket(Bucket=bucket) + log("deleted the old progress bucket", bucket) + except Exception as e: + if "NoSuchBucket" in type(e).__name__ + str(e): + return "" + log("old progress bucket not deleted:", bucket, e) + return f"bucket {bucket}: {e}" + return "" def delete_certificates(prefix, acm=None): @@ -760,6 +788,10 @@ def handle(event, context=None): if kind == "Custom::Status": phase = props.get("Phase", "done") + if rt in ("Create", "Update"): + # An older release's public progress bucket goes on the first + # update (CloudFormation then finds it gone), and after setup. + retire_progress_bucket(props) if rt == "Create": msg = "CoderHelm is running" if phase == "done" else "Creating your CoderHelm (about 20 minutes)" write_status(props, "done" if phase == "done" else "deploying", msg, props.get("Url") if phase == "done" else None) @@ -815,11 +847,9 @@ def handle(event, context=None): if mode is None: return "SUCCESS", "nothing to do", {} certs, problems, records = delete_certificates(props["Prefix"]) - if props.get("StatusBucket"): - try: - empty_bucket(props["StatusBucket"]) - except Exception as e: - problems.append(f"bucket {props['StatusBucket']}: {e}") + gone = retire_progress_bucket(props) + if gone: + problems.append(gone) groups, more = delete_named_log_groups(props.get("LogGroupNames") or [], props["Prefix"]) problems += more if mode == "rollback" or not keep_data(props, stack=stack): diff --git a/lambda/tests/test_native_helper.py b/lambda/tests/test_native_helper.py index 53d9ff8b..2d1d42af 100644 --- a/lambda/tests/test_native_helper.py +++ b/lambda/tests/test_native_helper.py @@ -328,7 +328,22 @@ def opener(req, timeout=None): class Status(unittest.TestCase): - def test_failed_create_reports_cloudformations_reason(self): + KEY = "K" * 40 + PROPS = {"StatusId": "abcd1234efgh5678", "StatusKey": KEY, "Version": "v1.2.3", "Stage": "prod", "Region": "eu-west-1"} + + def opener(self, sent): + class R: + status = 204 + def __enter__(self): + return self + def __exit__(self, *a): + return False + def go(req, timeout=None): + sent.append(req) + return R() + return go + + def test_failed_create_reports_cloudformations_reason_to_coderhelm_never_to_a_bucket(self): cfn = FakeCfn("ROLLBACK_IN_PROGRESS") cfn.events = [ {"LogicalResourceId": "Web", "ResourceStatus": "CREATE_FAILED", "ResourceStatusReason": "Resource creation cancelled"}, @@ -337,19 +352,56 @@ def test_failed_create_reports_cloudformations_reason(self): h._clients["cloudformation@None"] = cfn s3 = FakeS3() h._clients["s3@None"] = s3 + sent = [] + real = h.urllib.request.urlopen + h.urllib.request.urlopen = self.opener(sent) try: status, reason, _ = h.handle({"ResourceType": "Custom::Status", "RequestType": "Delete", "LogicalResourceId": "S", - "ResourceProperties": {"Phase": "start", "RootStackId": "r", "StatusBucket": "b"}}) + "ResourceProperties": dict(self.PROPS, Phase="start", RootStackId="r")}) finally: + h.urllib.request.urlopen = real h._clients.clear() - doc = json.loads(s3.objects["status.json"]["data"]) - self.assertEqual(doc["phase"], "failed") - self.assertIn("Api: Embedded stack failed: Role is invalid", doc["message"]) - self.assertNotIn("download", doc["message"]) - + self.assertEqual(status, "SUCCESS") + self.assertEqual(s3.objects, {}, "nothing is written to a bucket") + self.assertEqual(len(sent), 1) + req = sent[0] + self.assertEqual((req.get_method(), req.full_url), ("PUT", "https://api.coderhelm.com/api/install-progress/abcd1234efgh5678")) + body = json.loads(req.data) + self.assertEqual(body["key"], self.KEY) + self.assertNotIn(self.KEY, req.full_url, "the key never goes in a URL") + self.assertEqual(body["doc"]["phase"], "failed") + self.assertIn("Api: Embedded stack failed: Role is invalid", body["doc"]["message"]) + + def test_no_key_or_hybrid_off_reports_nothing(self): + for props in ({**self.PROPS, "StatusKey": ""}, {**self.PROPS, "StatusId": ""}, {**self.PROPS, "HybridUrl": "off"}, {**self.PROPS, "StatusKey": "short"}): + sent = [] + h.write_status(props, "deploying", "x", opener=self.opener(sent)) + self.assertEqual(sent, [], props) + + def test_an_unreachable_coderhelm_never_fails_the_install(self): + def down(req, timeout=None): + raise OSError("connection refused") + h.write_status(self.PROPS, "deploying", "x", opener=down) + + def test_an_older_releases_public_bucket_is_deleted_policy_first(self): + calls = [] + class S3(FakeS3): + def delete_bucket_policy(self, Bucket): + calls.append(("policy", Bucket)) + def delete_bucket(self, Bucket): + calls.append(("bucket", Bucket)) + s3 = S3(versions=[{"Key": "status.json", "VersionId": "null"}]) + self.assertEqual(h.retire_progress_bucket(self.PROPS, s3=s3), "") + self.assertEqual(calls, [("policy", "coderhelm-setup-abcd1234efgh5678"), ("bucket", "coderhelm-setup-abcd1234efgh5678")]) + self.assertEqual(s3.deleted, ["status.json"]) + + def test_no_bucket_is_fine_and_a_bad_id_touches_nothing(self): + class Gone(FakeS3): + def delete_bucket_policy(self, Bucket): + raise RuntimeError("NoSuchBucket: The specified bucket does not exist") + self.assertEqual(h.retire_progress_bucket(self.PROPS, s3=Gone()), "") + self.assertEqual(h.retire_progress_bucket({"StatusId": "../other"}, s3=None), "") -if __name__ == "__main__": - unittest.main() class FakeLogs: diff --git a/services/gateway/src/main.rs b/services/gateway/src/main.rs index c4722755..84cd5cdf 100644 --- a/services/gateway/src/main.rs +++ b/services/gateway/src/main.rs @@ -1376,6 +1376,22 @@ async fn main() -> Result<(), Error> { // Opt-in pings from self-host installers: public, no session, a // fixed set of non-identifying fields (routes::telemetry). .route("/api/telemetry/ping", post(routes::telemetry::ping)) + // The install guide's progress on hosted CoderHelm, read and written + // with a key only the guide and the stack have, so nothing in the + // install's account is public (routes::install_progress). 404 on a + // self-hosted install. + .route( + "/api/install-progress", + post(routes::install_progress::create), + ) + .route( + "/api/install-progress/:id", + put(routes::install_progress::report), + ) + .route( + "/api/install-progress/:id/read", + post(routes::install_progress::read), + ) // Where GitHub's app manifest flow lands on a self-hosted install // (routes::setup). 404 on hosted. .route( diff --git a/services/gateway/src/middleware/cors.rs b/services/gateway/src/middleware/cors.rs index ccfd3b3b..978fc8ac 100644 --- a/services/gateway/src/middleware/cors.rs +++ b/services/gateway/src/middleware/cors.rs @@ -56,6 +56,15 @@ pub fn public_methods(path: &str) -> Option<&'static str> { if path == "/api/setup/hybrid" || path.starts_with("/api/setup/hybrid/") { return Some("GET, POST"); } + // The install guide's progress (routes::install_progress): the key goes + // in the body, so Content-Type is the only header it needs. + if path == "/api/install-progress" + || path + .strip_prefix("/api/install-progress/") + .is_some_and(|r| !r.is_empty()) + { + return Some("POST, PUT"); + } None } @@ -444,6 +453,12 @@ mod tests { Some("GET, POST") ); assert_eq!(public_methods("/api/teams"), None); + assert_eq!(public_methods("/api/install-progress"), Some("POST, PUT")); + assert_eq!( + public_methods("/api/install-progress/abcd1234efgh5678/read"), + Some("POST, PUT") + ); + assert_eq!(public_methods("/api/install-progress/"), None); assert_eq!( parse_origins(" https://a.example/, ,https://b.example"), vec!["https://a.example", "https://b.example"] diff --git a/services/gateway/src/routes/install_progress.rs b/services/gateway/src/routes/install_progress.rs new file mode 100644 index 00000000..a19e5a0b --- /dev/null +++ b/services/gateway/src/routes/install_progress.rs @@ -0,0 +1,452 @@ +//! The install guide's progress, kept on hosted CoderHelm so nothing in a +//! self-hosted install's AWS account is ever public. +//! +//! 1. The guide makes a random progress id and key in the browser and +//! registers the id with the key's SHA-256 (POST /api/install-progress). +//! Create only: an id that exists is refused. +//! 2. The id and the key go to the stack as launch parameters (StatusId, +//! StatusKey, NoEcho). The stack's install helper reports each phase with +//! the key (PUT /api/install-progress/:id). +//! 3. The guide reads it back with the key (POST /api/install-progress/:id/read). +//! +//! Without the key nobody can read or write a progress document, and the id +//! alone says nothing. A document holds the phase, a short message with +//! account numbers, ARNs and emails taken out, the install's address and the +//! DNS records for its domain: exactly what the guide shows. +//! +//! Stored on the settings table (pk INSTALL_PROGRESS, sk ID#), expiring +//! 14 days after the last change. Hosted only: 404 on a self-hosted install. + +use aws_sdk_dynamodb::types::AttributeValue; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use subtle::ConstantTimeEq; +use tracing::warn; + +use crate::AppState; + +const KEEP_DAYS: i64 = 14; +const PHASES: &[&str] = &[ + "starting", + "preparing", + "deploying", + "finishing", + "done", + "failed", +]; +/// The largest document accepted, in bytes of JSON. +const MAX_DOC: usize = 8 * 1024; + +fn id_ok(id: &str) -> bool { + id.len() == 16 + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) +} + +fn key_ok(key: &str) -> bool { + (32..=64).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric()) +} + +fn hash_ok(h: &str) -> bool { + h.len() == 64 + && h.bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) +} + +/// The key's SHA-256, lowercase hex: what is stored and compared. +pub fn key_hash(key: &str) -> String { + hex::encode(Sha256::digest(key.as_bytes())) +} + +fn short(v: &Value, n: usize) -> bool { + match v { + Value::Null => true, + Value::String(s) => { + s.len() <= n + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + } + _ => false, + } +} + +fn host_ok(s: &str) -> bool { + !s.is_empty() + && s.len() <= 253 + && s.split('.').count() >= 2 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.' || b == b'_') +} + +/// Why a progress document is refused, or None when it is fine. Only the +/// fields the guide shows, each with a plain shape. +pub fn problem(doc: &Value) -> Option<&'static str> { + let Some(o) = doc.as_object() else { + return Some("not an object"); + }; + const FIELDS: &[&str] = &[ + "schema", + "phase", + "message", + "url", + "version", + "stage", + "region", + "updatedAt", + "domain", + "part", + "step", + "steps", + ]; + if o.keys().any(|k| !FIELDS.contains(&k.as_str())) { + return Some("unknown field"); + } + if o.get("schema") != Some(&json!(1)) { + return Some("schema"); + } + if !o + .get("phase") + .and_then(Value::as_str) + .is_some_and(|p| PHASES.contains(&p)) + { + return Some("phase"); + } + if !o + .get("message") + .is_none_or(|m| m.as_str().is_some_and(|s| s.len() <= 300) || m.is_null()) + { + return Some("message"); + } + if !o.get("url").is_none_or(|u| { + u.is_null() + || u.as_str().is_some_and(|s| { + s.strip_prefix("https://") + .is_some_and(|h| host_ok(h.trim_end_matches('/'))) + }) + }) { + return Some("url"); + } + for k in ["version", "stage", "region"] { + if !o.get(k).is_none_or(|v| short(v, 40)) { + return Some("version, stage or region"); + } + } + if !o + .get("updatedAt") + .is_none_or(|v| v.as_str().is_some_and(|s| s.len() <= 40)) + { + return Some("updatedAt"); + } + let count = + |v: Option<&Value>| v.is_none_or(|n| n.is_null() || n.as_u64().is_some_and(|n| n <= 1000)); + if !count(o.get("step")) || !count(o.get("steps")) { + return Some("step"); + } + if let Some(p) = o.get("part").filter(|p| !p.is_null()) { + let name_ok = p.get("name").and_then(Value::as_str).is_some_and(|n| { + (1..=40).contains(&n.len()) + && n.chars() + .all(|c| c.is_ascii_alphanumeric() || " .-".contains(c)) + }); + let extra = p.as_object().is_some_and(|m| { + m.keys() + .any(|k| !["name", "done", "total"].contains(&k.as_str())) + }); + if !name_ok || extra || !count(p.get("done")) || !count(p.get("total")) { + return Some("part"); + } + } + if let Some(d) = o.get("domain").filter(|d| !d.is_null()) { + let Some(d) = d.as_object() else { + return Some("domain"); + }; + if !d + .get("hostname") + .and_then(Value::as_str) + .is_some_and(host_ok) + { + return Some("domain.hostname"); + } + if !d.get("state").is_none_or(|s| short(s, 20)) { + return Some("domain.state"); + } + let records = d + .get("records") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]); + if records.len() > 4 { + return Some("domain.records"); + } + for r in records { + let field = |k: &str, n: usize| { + r.get(k) + .and_then(Value::as_str) + .is_some_and(|s| s.len() <= n) + }; + let name_ok = r.get("name").and_then(Value::as_str).is_some_and(host_ok); + if !(name_ok && field("value", 253) && field("type", 30) && field("purpose", 20)) { + return Some("domain.records"); + } + } + } + None +} + +fn not_here() -> Response { + StatusCode::NOT_FOUND.into_response() +} + +fn refused(why: &str) -> Response { + (StatusCode::BAD_REQUEST, Json(json!({ "error": why }))).into_response() +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Create { + pub id: String, + pub key_hash: String, +} + +/// POST /api/install-progress (public): the guide registers a progress id +/// with its key's hash before it opens AWS. 201, or 409 when the id exists. +pub async fn create(State(state): State>, Json(c): Json) -> Response { + if common::public_urls::self_hosted() { + return not_here(); + } + if !id_ok(&c.id) || !hash_ok(&c.key_hash) { + return refused("id is 16 lowercase letters and digits; key_hash is a SHA-256 in hex"); + } + let now = chrono::Utc::now(); + let s = |v: &str| AttributeValue::S(v.to_string()); + let result = state + .dynamo + .put_item() + .table_name(&state.config.settings_table_name) + .item("pk", s("INSTALL_PROGRESS")) + .item("sk", s(&format!("ID#{}", c.id))) + .item("key_hash", s(&c.key_hash)) + .item("created_at", s(&now.to_rfc3339())) + .item( + "ttl", + AttributeValue::N((now.timestamp() + KEEP_DAYS * 86_400).to_string()), + ) + .condition_expression("attribute_not_exists(sk)") + .send() + .await; + match result { + Ok(_) => StatusCode::CREATED.into_response(), + Err(e) + if e.as_service_error() + .is_some_and(|se| se.is_conditional_check_failed_exception()) => + { + ( + StatusCode::CONFLICT, + Json(json!({ "error": "This progress id is taken. Start again for a new one." })), + ) + .into_response() + } + Err(e) => { + warn!(error = %e, "install progress: not created"); + StatusCode::SERVICE_UNAVAILABLE.into_response() + } + } +} + +/// The stored item when `key` matches its hash; Err is the answer to give. +async fn authorized( + state: &AppState, + id: &str, + key: &str, +) -> Result, Response> { + if !id_ok(id) || !key_ok(key) { + return Err(refused("id or key")); + } + let got = state + .dynamo + .get_item() + .table_name(&state.config.settings_table_name) + .key("pk", AttributeValue::S("INSTALL_PROGRESS".into())) + .key("sk", AttributeValue::S(format!("ID#{id}"))) + .consistent_read(true) + .send() + .await + .map_err(|e| { + warn!(error = %e, "install progress: not read"); + StatusCode::SERVICE_UNAVAILABLE.into_response() + })?; + let item = got.item.ok_or_else(not_here)?; + let stored = item + .get("key_hash") + .and_then(|v| v.as_s().ok()) + .cloned() + .unwrap_or_default(); + // Unknown id and wrong key answer the same. + if !bool::from(key_hash(key).as_bytes().ct_eq(stored.as_bytes())) { + return Err(not_here()); + } + Ok(item) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Report { + pub key: String, + pub doc: Value, +} + +/// PUT /api/install-progress/:id (public, the key in the body): the install +/// helper's report for one phase. 204. +pub async fn report( + State(state): State>, + Path(id): Path, + Json(r): Json, +) -> Response { + if common::public_urls::self_hosted() { + return not_here(); + } + let body = r.doc.to_string(); + if body.len() > MAX_DOC { + return refused("the document is too large"); + } + if let Some(why) = problem(&r.doc) { + return refused(why); + } + if let Err(res) = authorized(&state, &id, &r.key).await { + return res; + } + let now = chrono::Utc::now(); + let result = state + .dynamo + .update_item() + .table_name(&state.config.settings_table_name) + .key("pk", AttributeValue::S("INSTALL_PROGRESS".into())) + .key("sk", AttributeValue::S(format!("ID#{id}"))) + .update_expression("SET doc = :d, updated_at = :t, #ttl = :ttl") + .condition_expression("attribute_exists(sk)") + .expression_attribute_names("#ttl", "ttl") + .expression_attribute_values(":d", AttributeValue::S(body)) + .expression_attribute_values(":t", AttributeValue::S(now.to_rfc3339())) + .expression_attribute_values( + ":ttl", + AttributeValue::N((now.timestamp() + KEEP_DAYS * 86_400).to_string()), + ) + .send() + .await; + match result { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + warn!(error = %e, "install progress: not stored"); + StatusCode::SERVICE_UNAVAILABLE.into_response() + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Read { + pub key: String, +} + +/// POST /api/install-progress/:id/read (public, the key in the body): the +/// latest report, or `{"doc": null}` while the install hasn't sent one (an +/// older release never does: the guide then reads its old progress file). +pub async fn read( + State(state): State>, + Path(id): Path, + Json(r): Json, +) -> Response { + if common::public_urls::self_hosted() { + return not_here(); + } + let item = match authorized(&state, &id, &r.key).await { + Ok(i) => i, + Err(res) => return res, + }; + let doc = item + .get("doc") + .and_then(|v| v.as_s().ok()) + .and_then(|s| serde_json::from_str::(s).ok()) + .unwrap_or(Value::Null); + Json(json!({ "doc": doc })).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn good() -> Value { + json!({ + "schema": 1, "phase": "done", "message": "CoderHelm is ready", "url": "https://d111test.cloudfront.net", + "version": "v1.2.3", "stage": "prod", "region": "eu-west-1", "updatedAt": "2026-11-03T10:00:00Z", + "domain": { "hostname": "coderhelm.example.com", "state": "validating", "records": [ + { "purpose": "certificate", "name": "_a1.coderhelm.example.com", "type": "CNAME", "value": "_b2.acm-validations.aws" }, + { "purpose": "address", "name": "coderhelm.example.com", "type": "CNAME", "value": "d111test.cloudfront.net" } + ] } + }) + } + + #[test] + fn ids_keys_and_hashes_have_one_shape() { + assert!(id_ok("abcd1234efgh5678")); + assert!(!id_ok("ABCD1234efgh5678") && !id_ok("abcd1234") && !id_ok("abcd1234efgh567/")); + assert!(key_ok(&"a".repeat(32)) && key_ok(&"Z9".repeat(32))); + assert!( + !key_ok(&"a".repeat(31)) + && !key_ok(&"a".repeat(65)) + && !key_ok(&format!("{}-", "a".repeat(32))) + ); + let h = key_hash("k".repeat(40).as_str()); + assert!(hash_ok(&h)); + assert!(!hash_ok(&h.to_uppercase()) && !hash_ok(&h[1..])); + } + + #[test] + fn a_document_carries_only_what_the_guide_shows() { + assert_eq!(problem(&good()), None); + let with = |k: &str, v: Value| { + let mut d = good(); + d[k] = v; + problem(&d) + }; + assert_eq!(with("schema", json!(2)), Some("schema")); + assert_eq!(with("phase", json!("pwned")), Some("phase")); + assert_eq!(with("message", json!("x".repeat(301))), Some("message")); + assert_eq!( + with("url", json!("http://d111test.cloudfront.net")), + Some("url") + ); + assert_eq!(with("url", json!("javascript:alert(1)")), Some("url")); + assert_eq!( + with("region", json!("eu-west-1