diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 56738f3..28fd732 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -45,15 +45,21 @@ jobs: - run: bun run test - run: bun run build env: + AGENTFORMATION_DEPLOYMENT: agentformation + AUTH_URL: https://localhost AUTH_COGNITO_ID: build-client AUTH_COGNITO_SECRET: build-secret AUTH_COGNITO_IDENTITY_PROVIDER: IdentityCenter AUTH_COGNITO_ISSUER: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_build AUTH_SECRET: build-only-secret-with-at-least-32-characters AWS_REGION: us-east-1 + CONTROL_TABLE: build-control + PROVISIONING_STATE_MACHINE_ARN: arn:aws:states:us-east-1:000000000000:stateMachine:build-only USER_REGISTRY_TABLE: build-users UPLOAD_BUCKET: build-uploads SESSION_DOCUMENT_NAME: build-terminal + UPLOAD_DELIVERY_DOCUMENT_NAME: build-upload-delivery + OAUTH_RELAY_DOCUMENT_NAME: build-oauth-relay - name: Build and smoke-test the production container run: | docker build --tag agentformation-web:test . @@ -61,7 +67,8 @@ jobs: --name agentformation-web-test \ --publish 127.0.0.1:3000:3000 \ --env HOSTNAME=container-internal \ - --env AUTH_URL=http://localhost:3000 \ + --env AGENTFORMATION_DEPLOYMENT=agentformation \ + --env AUTH_URL=https://localhost \ --env AUTH_TRUST_HOST=true \ --env AUTH_COGNITO_ID=container-client \ --env AUTH_COGNITO_SECRET=container-secret \ @@ -69,14 +76,19 @@ jobs: --env AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_container \ --env AUTH_SECRET=container-only-secret-with-at-least-32-characters \ --env AWS_REGION=us-east-1 \ + --env CONTROL_TABLE=container-control \ + --env PROVISIONING_STATE_MACHINE_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:container-only \ --env USER_REGISTRY_TABLE=container-users \ --env UPLOAD_BUCKET=container-uploads \ --env SESSION_DOCUMENT_NAME=container-terminal \ + --env UPLOAD_DELIVERY_DOCUMENT_NAME=container-upload-delivery \ + --env OAUTH_RELAY_DOCUMENT_NAME=container-oauth-relay \ agentformation-web:test trap 'docker stop agentformation-web-test >/dev/null 2>&1 || true' EXIT for _ in {1..30}; do if curl --fail --silent --show-error http://127.0.0.1:3000/api/health | grep -F '"status":"ok"' && \ - curl --fail --silent --show-error http://127.0.0.1:3000/ | grep -F AgentFormation; then + curl --fail --silent --show-error http://127.0.0.1:3000/ | grep -F AgentFormation && \ + docker exec agentformation-web-test id -u | grep -Fx 1001; then exit 0 fi sleep 2 diff --git a/README.md b/README.md index 3870d55..32af7d2 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ # AgentFormation -**Spin up private, persistent EC2 workspaces for coding agents in your own AWS account.** +**Spin up private, persistent EC2 workspaces for coding agents in your AWS org**

License: BSD 2-Clause Security policy

-

Run Codex and Claude Code from a browser while the workspace stays on a private EC2 runtime—with no public IP and no inbound SSH.

+

Run CLI agent harnesses from a browser while the workspace stays on a private EC2 runtime with no public IP and no inbound SSH.

Quick start · Architecture · Documentation · Security · Contributing

@@ -72,6 +72,7 @@ AWS IAM Identity Center --SAML--> Cognito bridge --OIDC--> App Runner - an App Runner web terminal - a restricted Step Functions job that can create only the fixed runtime stack - a DynamoDB identity-to-runtime registry +- a short-lived DynamoDB request-control table shared by every web instance - a short-lived, encrypted S3 upload staging area You need an organization instance of IAM Identity Center, permission to add a @@ -279,3 +280,5 @@ or uptime commitment. Bugs and improvements are welcome through GitHub issues an pull requests. Licensed under the [BSD 2-Clause License](LICENSE). + +_AgentFormation is still experimental—review the security model and try it in a non-production AWS account before depending on it._ diff --git a/docs/configuration.md b/docs/configuration.md index 4bd62f8..219c2d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,7 +13,7 @@ cp agentformation.example.json agentformation.local.json | Field | What to enter | When to change it | | ----------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `deploymentName` | A lowercase name using letters, numbers, and hyphens | Keep it stable for the life of one deployment. Changing it creates a separate set of AWS resources. | +| `deploymentName` | A lowercase name using letters, numbers, and hyphens; do not use the reserved `-runtime-` segment | Keep it stable for the life of one deployment. Changing it creates a separate set of AWS resources. | | `region` | The AWS Region for the deployment | Choose a Region supported by the required AWS services and configured Bedrock models. | | `publicUrl` | Empty, or the exact `https://` origin of an active App Runner custom domain | Leave empty for the generated App Runner address. Do not add a path or trailing slash. | | `networkMode` | `private-nat` or `private-endpoints` | `private-nat` is the normal starting point. The endpoint mode adds AWS service endpoints but still keeps internet access for developer tools. | diff --git a/docs/maintainer-release-checklist.md b/docs/maintainer-release-checklist.md index f57c009..1812bfd 100644 --- a/docs/maintainer-release-checklist.md +++ b/docs/maintainer-release-checklist.md @@ -76,8 +76,8 @@ account and complete this checklist. - Review findings in the account or organization IAM Access Analyzer. AgentFormation intentionally does not create this account-wide service; if no analyzer exists, record that operator gap instead of marking the review complete. -- Confirm the ECR scan for the exact web image tag completed, then review every - reported finding before release. +- Confirm the ECR scan for the exact web image digest deployed to App Runner + completed, then review every reported finding before release. - Run `./agentformation destroy --confirm DELETE` after the review window and verify no tagged EC2, EBS, NAT, App Runner, ECR, S3, DynamoDB, Cognito, Secrets Manager, Step Functions, Image Builder, AMI, or snapshot resources remain. diff --git a/docs/privacy.md b/docs/privacy.md index a1d0862..9a22971 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -8,10 +8,23 @@ The deployed system stores or processes: - assigned employees' email addresses and federated Cognito identifiers; - the mapping between an employee and an EC2 runtime; - environment-creation status and AWS Step Functions execution history; +- short-lived request counters and upload claims in DynamoDB; - encrypted runtime files on EBS; -- uploaded files in S3 until copied or expired; and +- uploaded files in S3 until copied or expired; +- terminal tab labels in that browser's local storage until app sign-out; and - normal AWS service, access, build, and application logs. +Each browser upload is limited to 50 MiB at the S3 write boundary, restricted to +one random key and declared content type, copied to a server-owned sealed key +before delivery, and deleted from staging after the runtime command finishes. +The one-day bucket rule is a backstop if immediate cleanup cannot be confirmed. + +Copying terminal text places it in the device's system clipboard. AgentFormation +clears its own selection state after a successful copy, but browsers do not offer +a safe, reliable way for a web app to erase the system clipboard later. Treat +copied secrets like any other clipboard secret and replace them before sharing or +leaving the device unattended. + The IAM Identity Center metadata address or downloaded XML contains organization-specific SAML endpoints and public signing certificates. The address belongs only in ignored `agentformation.local.json`; a fallback XML file diff --git a/docs/security-model.md b/docs/security-model.md index 9ed2568..5d9fb16 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -13,6 +13,12 @@ AgentFormation has three important trust levels: any runtime. AgentFormation does not claim to protect runtime data from the AWS account owner. +Permission to update an AgentFormation CloudFormation stack is administrative +access to that deployment. Stack parameters select trusted roles, tables, +secrets, images, and runtime resources, so operators should limit stack updates +to the same dedicated deployers they trust with the underlying AWS resources. +An optional `cloudFormationRoleArn` limits what CloudFormation can create; it +does not make an untrusted `UpdateStack` caller safe. ## Company sign-in @@ -53,13 +59,33 @@ For every terminal or upload request, the server: 5. targets only the instance in that record. The client never submits an instance ID. Runtime IDs are not returned by the -session-start API. Browser termination requests also carry an HMAC proof that -binds the Systems Manager session ID to the signed-in subject. +session-start API. Browser resume and termination requests carry an expiring +HMAC proof that binds the Systems Manager session ID to both the signed-in +subject and the currently assigned runtime instance. The server consistently +re-reads that assignment immediately before each privileged runtime action. + +Systems Manager returns a short-lived token and regional WebSocket address so a +Session Manager client can open the data channel. AgentFormation passes those +values to the signed-in browser, validates that the address is the expected +regional `ssmmessages` host and session path, and limits the Content Security +Policy to that AWS endpoint. The browser never receives the EC2 role credentials. State-changing environment, terminal, upload, and OAuth-relay routes accept only -same-origin JSON requests. Their success and error responses disable browser and -intermediary caching. The web app uses a new script nonce for every request instead -of allowing arbitrary inline scripts in its Content Security Policy. +same-origin JSON requests. They authenticate before parsing a body and stop +reading JSON after a small fixed limit. Their success and error responses disable +browser and intermediary caching. The web app uses a new script nonce for every +request instead of allowing arbitrary inline scripts in its Content Security +Policy. + +A second, short-lived DynamoDB table holds atomic request counters, in-flight +operation leases, termination deduplication records, and upload-completion +claims. Because all App Runner instances use the same table, switching instances +or restarting the service does not reset these controls. Limits are per federated +subject: environment creation is capped at four starts per hour; upload staging +is capped at 60 files and 2 GiB per hour; terminal and relay calls have narrower +minute or hourly limits. One privileged operation of the same kind is allowed in +flight for a subject and resource at a time. Expiring records are removed by +DynamoDB TTL. The OAuth relay exists for command-line tools that listen for a one-time callback inside the private runtime. It accepts only plain HTTP URLs whose host is exactly @@ -76,11 +102,31 @@ object with its own restricted role, stores it in a private temporary file, and removes that file after the request. The web service deletes the staging object after the command; the bucket lifecycle is a cleanup backstop. +The web role cannot use the general-purpose `AWS-RunShellScript` document. +Uploads and OAuth callbacks each use a separate, deployment-owned command +document whose parameters have strict character patterns and are exposed to the +script only as environment variables. The documents contain fixed commands, +and every callback and upload operation runs as the unprivileged +`agentformation` user. This keeps a web-service compromise from turning the +file-transfer path into an arbitrary root command. + +Browser file uploads use a one-key presigned POST whose policy enforces a 50 MiB +write limit. Before returning that policy, the server records the exact subject, +runtime, key, filename, content type, and size. Completion atomically claims that +record once, copies the matching object generation to a server-owned sealed key, +revalidates the runtime assignment, and only then asks the runtime to download +it. The runtime downloads to a private temporary filename, verifies the byte +count, and atomically renames the complete file into its visible destination. +If command completion cannot be confirmed, the upload claim stays in its +in-progress state until its TTL; that claim, rather than the shorter request +lease, prevents a duplicate delivery while the command may still be running. + ## Self-service environment creation A signed-in employee can ask the App Runner service to start one fixed Step -Functions job. The web role can start that exact job; it cannot call -CloudFormation itself. +Functions job. The web role can start that exact job and read the safe status +fields from events for the authenticated subject's deterministic runtime stack; +it cannot create, update, or delete CloudFormation stacks. The job: @@ -92,7 +138,17 @@ The job: hash; 5. supplies network, AMI, instance size, volume, model, and upload settings chosen by the operator during deployment; and -6. records the resulting tagged EC2 instance only after CloudFormation succeeds. +6. polls CloudFormation and instance discovery for bounded periods; +7. confirms the federated user is still enabled immediately before activation; +8. records the resulting tagged EC2 instance only after CloudFormation succeeds; + and +9. deletes a failed or superseded runtime stack before making a clean retry + available. + +The active runtime template remains stored under its content hash for as long as +the deployment uses it. After a successful update, older template hashes receive +a superseded tag and a fresh 30-day retirement window so both stable deployments +and already-running setup jobs keep a valid template. A separate CloudFormation service role can create only the named AgentFormation runtime IAM roles, the operator-selected AMI/network dependencies, and tagged @@ -100,8 +156,12 @@ runtime instances. The setup job cannot accept a browser-provided template URL, AMI, subnet, security group, IAM policy, model, or instance size. The DynamoDB conditional write makes repeated button presses idempotent: an active -or in-progress employee record is not replaced. A failed setup can be retried -using the same reviewed stack name. +or current in-progress employee record is not replaced. A failed setup is +eligible for immediate retry after cleanup. A setup still marked as provisioning +becomes retryable after 100 minutes, beyond the workflow's 90-minute hard timeout. +Every retry is reconciled against the deterministic stack before the same +reviewed stack name is reused, and activation/failure writes must still match the +exact reservation start time. ## Network and host controls @@ -112,10 +172,25 @@ using the same reviewed stack name. - Runtime EBS volumes and staged uploads are encrypted at rest. - The upload bucket rejects requests that do not use HTTPS. - Upload objects expire after one day and are deleted after they reach a runtime. +- Full teardown removes the web service first, stops and confirms every running + provisioning job, removes the setup state machine, and only then enumerates + runtime stacks. It also cancels active image builds and removes deployment-tagged + AMIs and snapshots before deleting the image pipeline. Teardown temporarily + blocks all writes to the staging bucket while every current object, version, + and delete marker is removed. If teardown stops before the foundation stack is + deleted, the sanitized original bucket policy is restored. - The default network uses one NAT gateway. Optional AWS service endpoints reduce some traffic through that gateway but do not remove the need for internet access to Git hosts, package registries, and similar developer services. +Runtime internet access is intentional: a general coding environment needs to +reach source hosts, package registries, and the APIs its owner chooses to use. +The runtime security group therefore allows outbound traffic while allowing no +inbound connections. Organizations that require destination filtering should add +their normal egress proxy, DNS policy, or network firewall and allow the developer +services they support; the reference template cannot guess a safe universal host +allowlist. + ## Credentials and model access The runtime uses its EC2 instance role. Long-lived AWS access keys are not placed @@ -137,35 +212,73 @@ broader AWS-managed policy. Bedrock access, provider terms, quotas, and regional model availability are controlled separately by AWS. -The runtime image pins the AWS CLI, Claude Code, and Codex versions. The AWS and -Codex installer scripts are checksum-checked before execution. AWS's installer +The runtime image pins the AWS CLI, Node.js, Bun, Claude Code, and Codex versions. +The AWS, Node.js, Bun, and Codex downloads are checksum-checked before execution. +AWS's installer then verifies the AWS CLI package signature with its embedded AWS CLI team key; the Codex installer verifies the selected release archive digest. +The App Runner image is pushed to the foundation stack's ECR repository, read +back from ECR by digest, and deployed with that immutable digest rather than a +mutable tag. The deploy command rejects a repository outside the current AWS +account and Region. + +The Image Builder parent identifier deliberately follows AWS's `x.x.x` form for +the selected Ubuntu LTS stream so a newly built image receives AWS's current +patched base. Every completed AMI is immutable, recorded by ID, and used only for +environments created from that reviewed build. Operators who need a frozen base +version can pin the parent identifier, but then take responsibility for advancing +it when AWS publishes security updates. + ## User lifecycle - Assigning an Identity Center group allows its members to sign in. Each member can create one runtime from the fixed setup job. -- `users disable` disables the federated Cognito profile, marks the registry record - disabled, and stops EC2 while preserving its encrypted disk. +- `users disable` disables the federated Cognito profile, requests Cognito's + global token revocation, moves any registry record to a non-active state, + terminates active Session Manager connections, and stops EC2 while preserving + its encrypted disk. - `users enable` starts the preserved instance, re-enables the profile, and restores the active registry record. -- `users purge --confirm DELETE` deletes the federated profile, CloudFormation - runtime, and persistent disk. +- `users purge --confirm DELETE` first revokes live access and stops matching + provisioning, then places a conditional purge lock on the exact disabled + registry record. Re-enable refuses that lock. Purge rechecks Cognito before it + deletes every runtime stack tagged for that deployment and subject, its + persistent disk, and the federated profile. Before any deletion, a re-enabled + profile aborts purge and releases the exact lock. After profile deletion, the + locked row is atomically replaced with a privacy-minimal `purged` marker that + contains only the subject, status, update time, and DynamoDB expiry time. The + marker remains for two hours. Every Auth.js token refresh checks that marker + and clears a revoked session; a browser that never refreshes expires within + one hour. Old browser sessions therefore cannot outlive the marker or create + another runtime before DynamoDB expires it. Remove the Identity Center application or group assignment as the source-of-truth offboarding step. If an assigned employee is purged but remains assigned, their next valid company sign-in can create a new federated profile and runtime. -An already-open Systems Manager terminal may remain usable briefly while disable -or group removal propagates. Use `users disable` and terminate active Systems -Manager sessions when immediate eviction is required. +Signing out of AgentFormation clears the app session and that browser's saved tab +metadata. It intentionally does not sign the person out of IAM Identity Center, +because that organization-wide session may also be serving other company apps. +As a result, signing back in can be immediate while the Identity Center session +is still valid. AgentFormation app sessions expire after one hour. + +Group removal prevents the next federation but cannot recall an already-issued +application session by itself. Use `users disable` for immediate app-side +revocation: it disables the Cognito profile, requests Cognito's global token +revocation, and changes the runtime registry so every new privileged request +fails even if an already-issued app session has not expired. It also stops an +in-progress setup and the EC2 instance. An already-open Systems Manager data +channel can remain usable only until the instance stop or session termination +reaches it. ## Compromise scope The App Runner role can start the fixed setup job for an enabled federated subject and can manage terminal sessions and staged uploads across all tagged runtimes in -this deployment. A compromise of the service role or server application can +this deployment. Its shared request-control table protects AWS capacity from an +abusive signed-in caller, but it is not a boundary against compromise of the web +role itself. A compromise of the service role or server application can therefore affect every managed runtime. It still cannot submit an arbitrary CloudFormation template or choose more privileged runtime settings. diff --git a/scripts/check.sh b/scripts/check.sh index d2b592d..661e96f 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -18,12 +18,16 @@ AUTH_COGNITO_ID=build-client \ AUTH_COGNITO_SECRET=build-secret \ AUTH_COGNITO_IDENTITY_PROVIDER=IdentityCenter \ AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_build \ +AUTH_URL=https://localhost \ AGENTFORMATION_DEPLOYMENT=agentformation \ PROVISIONING_STATE_MACHINE_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:build-only \ AWS_REGION=us-east-1 \ USER_REGISTRY_TABLE=build-users \ +CONTROL_TABLE=build-control \ UPLOAD_BUCKET=build-uploads \ SESSION_DOCUMENT_NAME=build-terminal \ +UPLOAD_DELIVERY_DOCUMENT_NAME=build-upload-delivery \ +OAUTH_RELAY_DOCUMENT_NAME=build-oauth-relay \ bun run build cd "$ROOT_DIR" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 92d5d34..24d04c9 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -14,22 +14,23 @@ DEPLOYMENT="$(deployment_name)" ACCOUNT_ID="$(aws_cli sts get-caller-identity --query Account --output text)" CALLER_ARN="$(aws_cli sts get-caller-identity --query Arn --output text)" AWS_PARTITION="$(cut -d: -f2 <<<"$CALLER_ARN")" -if [[ "$AWS_PARTITION" == "aws-cn" ]]; then - AWS_URL_SUFFIX="amazonaws.com.cn" -else - AWS_URL_SUFFIX="amazonaws.com" -fi DOMAIN_SUFFIX="$(hash_text "$ACCOUNT_ID:$DEPLOYMENT" | cut -c1-10)" DOMAIN_PREFIX="$DEPLOYMENT-$DOMAIN_SUFFIX" IDENTITY_CENTER_METADATA_URL="$(config '.identityCenter.metadataUrl // ""')" IDENTITY_CENTER_METADATA_FILE="$(config '.identityCenter.metadataFile // ""')" CONFIGURED_PUBLIC_URL="$(config '(.publicUrl // "") | rtrimstr("/")')" + +if [[ -n "$CONFIGURED_PUBLIC_URL" ]]; then + require_https_origin "publicUrl" "$CONFIGURED_PUBLIC_URL" +fi if [[ -n "$IDENTITY_CENTER_METADATA_FILE" && "$IDENTITY_CENTER_METADATA_FILE" != /* ]]; then IDENTITY_CENTER_METADATA_FILE="$ROOT_DIR/$IDENTITY_CENTER_METADATA_FILE" fi CONFIGURE_CASE_INSENSITIVE_USERNAMES=true -if aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/dev/null 2>&1; then +FOUNDATION_EXISTS=false +if stack_exists "$(foundation_stack)"; then + FOUNDATION_EXISTS=true EXISTING_USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" EXISTING_USERNAME_CASE_SENSITIVE="$(aws_cli cognito-idp describe-user-pool \ --user-pool-id "$EXISTING_USER_POOL_ID" \ @@ -47,7 +48,10 @@ if aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/d *) fail "Could not determine the existing Cognito username case-sensitivity setting" ;; - esac + esac +else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || exit "$stack_status" fi print_identity_center_setup() { @@ -59,7 +63,8 @@ print_identity_center_setup() { acs_url="$(jq -r '[.[] | select(.OutputKey == "CognitoSamlAcsUrl") | .OutputValue][0] // ""' <<<"$outputs")" audience="$(jq -r '[.[] | select(.OutputKey == "CognitoSamlAudience") | .OutputValue][0] // ""' <<<"$outputs")" if [[ ! "$acs_url" =~ ^https:// ]] || [[ "$audience" != urn:amazon:cognito:sp:* ]]; then - user_pool_id="$(jq -er '[.[] | select(.OutputKey == "UserPoolId") | .OutputValue][0]' <<<"$outputs")" + user_pool_id="$(jq -r '[.[] | select(.OutputKey == "UserPoolId") | .OutputValue][0] // ""' <<<"$outputs")" + [[ -n "$user_pool_id" ]] || fail "The foundation stack did not return the Cognito user pool ID" if [[ "$AWS_PARTITION" == "aws-cn" ]]; then cognito_domain_suffix="amazoncognito.com.cn" else @@ -72,8 +77,7 @@ print_identity_center_setup() { say "SAML audience: $audience" } -if [[ -z "$IDENTITY_CENTER_METADATA_URL" && -z "$IDENTITY_CENTER_METADATA_FILE" ]] && \ - aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/dev/null 2>&1; then +if [[ -z "$IDENTITY_CENTER_METADATA_URL" && -z "$IDENTITY_CENTER_METADATA_FILE" && "$FOUNDATION_EXISTS" == "true" ]]; then say "Identity Center setup is required before this existing deployment can be updated" print_identity_center_setup fail "Assign an IAM Identity Center group to a custom SAML application, then set identityCenter.metadataUrl (preferred) or identityCenter.metadataFile" @@ -83,10 +87,38 @@ deploy_stack "$(network_stack)" templates/network.yaml \ DeploymentName="$DEPLOYMENT" \ NetworkMode="$(config '.networkMode')" -deploy_stack "$(foundation_stack)" templates/foundation.yaml \ - DeploymentName="$DEPLOYMENT" \ - CognitoDomainPrefix="$DOMAIN_PREFIX" \ - ConfigureCaseInsensitiveUsernames="$CONFIGURE_CASE_INSENSITIVE_USERNAMES" +INITIAL_PUBLIC_URL="$CONFIGURED_PUBLIC_URL" +if [[ -z "$INITIAL_PUBLIC_URL" ]]; then + if stack_exists "$(web_stack)"; then + INITIAL_PUBLIC_URL="$(stack_output "$(web_stack)" ServiceUrl)" + require_https_origin "The existing web service URL" "$INITIAL_PUBLIC_URL" + else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || exit "$stack_status" + # A localhost callback exists only long enough to bootstrap a genuinely new + # deployment. Read failures on an existing web stack must stop the deploy. + INITIAL_PUBLIC_URL="https://localhost" + fi +fi + +deploy_foundation_stack() { + local configure_identity_center="$1" + local public_url="$2" + deploy_stack "$(foundation_stack)" templates/foundation.yaml \ + DeploymentName="$DEPLOYMENT" \ + CognitoDomainPrefix="$DOMAIN_PREFIX" \ + ConfigureCaseInsensitiveUsernames="$CONFIGURE_CASE_INSENSITIVE_USERNAMES" \ + ConfigureIdentityCenterClient="$configure_identity_center" \ + InitialCallbackUrl="$public_url/api/auth/callback/cognito" \ + InitialLogoutUrl="$public_url/" \ + UploadAllowedOrigin="$public_url" +} + +if [[ "$FOUNDATION_EXISTS" == "false" ]]; then + # The user pool must exist before IAM Identity Center can be configured. The + # bootstrap stack has no app client, so local Cognito login is never exposed. + deploy_foundation_stack false "$INITIAL_PUBLIC_URL" +fi if [[ -z "$IDENTITY_CENTER_METADATA_URL" && -z "$IDENTITY_CENTER_METADATA_FILE" ]]; then say "The identity bootstrap is ready" @@ -96,41 +128,6 @@ if [[ -z "$IDENTITY_CENTER_METADATA_URL" && -z "$IDENTITY_CENTER_METADATA_FILE" fi USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" -CLIENT_ID="$(stack_output "$(foundation_stack)" UserPoolClientId)" -CLIENT_SECRET_ARN="$(stack_output "$(foundation_stack)" CognitoClientSecretArn)" - -configure_cognito_client() { - local callback_urls_json="$1" - local logout_urls_json="$2" - local url - local -a callback_urls=() - local -a logout_urls=() - while IFS= read -r url; do - [[ -n "$url" ]] && callback_urls+=("$url") - done < <(jq -r '.[]' <<<"$callback_urls_json") - while IFS= read -r url; do - [[ -n "$url" ]] && logout_urls+=("$url") - done < <(jq -r '.[]' <<<"$logout_urls_json") - [[ "${#callback_urls[@]}" -gt 0 && "${#logout_urls[@]}" -gt 0 ]] || \ - fail "Cognito callback and logout URLs cannot be empty" - - aws_cli cognito-idp update-user-pool-client \ - --user-pool-id "$USER_POOL_ID" \ - --client-id "$CLIENT_ID" \ - --supported-identity-providers IdentityCenter \ - --explicit-auth-flows ALLOW_REFRESH_TOKEN_AUTH \ - --allowed-o-auth-flows code \ - --allowed-o-auth-scopes openid email profile \ - --allowed-o-auth-flows-user-pool-client \ - --callback-urls "${callback_urls[@]}" \ - --logout-urls "${logout_urls[@]}" \ - --prevent-user-existence-errors ENABLED \ - --enable-token-revocation \ - --access-token-validity 60 \ - --id-token-validity 60 \ - --refresh-token-validity 1 \ - --token-validity-units AccessToken=minutes,IdToken=minutes,RefreshToken=days >/dev/null -} say "Connecting the Cognito bridge to IAM Identity Center" if [[ -n "$IDENTITY_CENTER_METADATA_URL" ]]; then @@ -161,14 +158,16 @@ else --idp-identifiers identity-center >/dev/null fi -CURRENT_CLIENT_URLS="$(aws_cli cognito-idp describe-user-pool-client \ - --user-pool-id "$USER_POOL_ID" \ - --client-id "$CLIENT_ID" \ - --query 'UserPoolClient.{callbacks:CallbackURLs,logouts:LogoutURLs}' \ - --output json)" -configure_cognito_client \ - "$(jq -c '.callbacks' <<<"$CURRENT_CLIENT_URLS")" \ - "$(jq -c '.logouts' <<<"$CURRENT_CLIENT_URLS")" +deploy_foundation_stack true "$INITIAL_PUBLIC_URL" +CLIENT_ID="$(stack_output "$(foundation_stack)" UserPoolClientId)" +CLIENT_SECRET_ARN="$(stack_output "$(foundation_stack)" CognitoClientSecretArn)" +AUTH_SECRET_ARN="$(stack_output "$(foundation_stack)" AuthSecretArn)" +USER_REGISTRY_TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" +CONTROL_TABLE_NAME="$(stack_output "$(foundation_stack)" ControlTableName)" +UPLOAD_BUCKET="$(stack_output "$(foundation_stack)" UploadBucketName)" +TERMINAL_SESSION_DOCUMENT_NAME="$(stack_output "$(foundation_stack)" TerminalSessionDocumentName)" +UPLOAD_DELIVERY_DOCUMENT_NAME="$(stack_output "$(foundation_stack)" UploadDeliveryDocumentName)" +OAUTH_RELAY_DOCUMENT_NAME="$(stack_output "$(foundation_stack)" OAuthRelayDocumentName)" say "Storing the generated Cognito client secret without printing it" aws_cli cognito-idp describe-user-pool-client \ @@ -244,26 +243,80 @@ else say "Reusing the latest tested AMI from the current image pipeline" fi -AMI_ID="$(aws_cli imagebuilder get-image \ - --image-build-version-arn "$IMAGE_ARN" \ - --query 'image.outputResources.amis[0].image' \ - --output text)" -[[ "$AMI_ID" =~ ^ami-[0-9a-f]+$ ]] || fail "Image Builder did not return an AMI ID" +BUILT_IMAGE="$(aws_cli imagebuilder get-image \ + --image-build-version-arn "$IMAGE_ARN" \ + --output json)" +jq -e --arg region "$(region)" --arg account "$ACCOUNT_ID" ' + (.image.outputResources.amis | type == "array" and length > 0) and + all(.image.outputResources.amis[]; + .region == $region and .accountId == $account and + (.image | test("^ami-([0-9a-f]{8}|[0-9a-f]{17})$"))) +' <<<"$BUILT_IMAGE" >/dev/null || fail "Image Builder returned an AMI outside this AWS account and region" +AMI_ID="$(jq -er --arg region "$(region)" ' + [.image.outputResources.amis[] | select(.region == $region) | .image] | first +' <<<"$BUILT_IMAGE")" +[[ "$AMI_ID" =~ ^ami-([0-9a-f]{8}|[0-9a-f]{17})$ ]] || fail "Image Builder did not return an AMI ID" +AMI_DESCRIPTION="$(aws_cli ec2 describe-images --image-ids "$AMI_ID" --output json)" +AMI_SNAPSHOT_ID_LINES="$(jq -er '[.Images[]?.BlockDeviceMappings[]?.Ebs.SnapshotId] | unique | join("\n")' \ + <<<"$AMI_DESCRIPTION")" +while IFS= read -r snapshot_id; do + [[ -n "$snapshot_id" ]] || continue + [[ "$snapshot_id" =~ ^snap-([0-9a-f]{8}|[0-9a-f]{17})$ ]] || \ + fail "The built AMI returned an invalid snapshot ID" +done <<<"$AMI_SNAPSHOT_ID_LINES" + +tag_image_resource() { + local resource_id="$1" + local attempt=1 tag_result tag_error + while [[ "$attempt" -le 30 ]]; do + if tag_error="$(aws_cli ec2 create-tags \ + --resources "$resource_id" \ + --tags "Key=AgentFormationDeployment,Value=$DEPLOYMENT" 2>&1)"; then + if tag_result="$(aws_cli ec2 describe-tags \ + --filters \ + "Name=resource-id,Values=$resource_id" \ + "Name=key,Values=AgentFormationDeployment" \ + --output json 2>&1)" && \ + jq -e --arg resource "$resource_id" --arg deployment "$DEPLOYMENT" ' + any(.Tags[]?; + .ResourceId == $resource and + .Key == "AgentFormationDeployment" and + .Value == $deployment) + ' <<<"$tag_result" >/dev/null; then + return 0 + fi + fi + [[ "$attempt" -lt 30 ]] || { + printf '%s\n' "${tag_error:-${tag_result:-EC2 did not confirm the resource tag}}" >&2 + fail "The built AMI or snapshot could not be tagged for teardown" + } + sleep 2 + attempt=$((attempt + 1)) + done +} + +say "Tagging the built AMI and snapshots for complete teardown" +tag_image_resource "$AMI_ID" +while IFS= read -r snapshot_id; do + [[ -n "$snapshot_id" ]] || continue + tag_image_resource "$snapshot_id" +done <<<"$AMI_SNAPSHOT_ID_LINES" aws_cli ssm put-parameter \ --name "$(ami_parameter_path)" \ --type String \ --value "$AMI_ID" \ --overwrite >/dev/null -UPLOAD_BUCKET="$(stack_output "$(foundation_stack)" UploadBucketName)" RUNTIME_TEMPLATE_HASH="$(hash_text "$(<"$ROOT_DIR/templates/runtime.yaml")")" RUNTIME_TEMPLATE_KEY="provisioning/runtime-$RUNTIME_TEMPLATE_HASH.yaml" -RUNTIME_TEMPLATE_URL="https://$UPLOAD_BUCKET.s3.$(region).$AWS_URL_SUFFIX/$RUNTIME_TEMPLATE_KEY" say "Publishing the reviewed runtime template under its content hash" -aws_cli s3 cp \ - "$ROOT_DIR/templates/runtime.yaml" \ - "s3://$UPLOAD_BUCKET/$RUNTIME_TEMPLATE_KEY" \ - --sse AES256 >/dev/null +aws_cli s3api put-object \ + --bucket "$UPLOAD_BUCKET" \ + --key "$RUNTIME_TEMPLATE_KEY" \ + --body "$ROOT_DIR/templates/runtime.yaml" \ + --server-side-encryption AES256 \ + --tagging 'agentformation-lifecycle=current' \ + --expected-bucket-owner "$ACCOUNT_ID" >/dev/null CLAUDE_MODEL="$(config '.models.claude')" CLAUDE_PROFILE="$(aws_cli bedrock get-inference-profile \ @@ -271,16 +324,17 @@ CLAUDE_PROFILE="$(aws_cli bedrock get-inference-profile \ --output json)" || fail "The configured Claude inference profile is unavailable" CLAUDE_PROFILE_ARN="$(jq -er '.inferenceProfileArn' <<<"$CLAUDE_PROFILE")" CLAUDE_MODEL_ARNS="$(jq -er '[.models[].modelArn] | select(length > 0) | join(",")' <<<"$CLAUDE_PROFILE")" +RUNTIME_SUBNET_ID="$(stack_output "$(network_stack)" PrivateSubnetId)" +RUNTIME_SECURITY_GROUP_ID="$(stack_output "$(network_stack)" RuntimeSecurityGroupId)" deploy_stack "$(provisioning_stack)" templates/provisioning.yaml \ DeploymentName="$DEPLOYMENT" \ UserPoolId="$USER_POOL_ID" \ - UserRegistryTableName="$(stack_output "$(foundation_stack)" UserRegistryTableName)" \ - RuntimeTemplateUrl="$RUNTIME_TEMPLATE_URL" \ + UserRegistryTableName="$USER_REGISTRY_TABLE_NAME" \ RuntimeTemplateBucket="$UPLOAD_BUCKET" \ RuntimeTemplateKey="$RUNTIME_TEMPLATE_KEY" \ - RuntimeSubnetId="$(stack_output "$(network_stack)" PrivateSubnetId)" \ - RuntimeSecurityGroupId="$(stack_output "$(network_stack)" RuntimeSecurityGroupId)" \ + RuntimeSubnetId="$RUNTIME_SUBNET_ID" \ + RuntimeSecurityGroupId="$RUNTIME_SECURITY_GROUP_ID" \ RuntimeAmiId="$AMI_ID" \ InstanceType="$(config '.runtime.instanceType')" \ Architecture="$ARCHITECTURE" \ @@ -290,8 +344,73 @@ deploy_stack "$(provisioning_stack)" templates/provisioning.yaml \ ClaudeFoundationModelArns="$CLAUDE_MODEL_ARNS" \ CodexModelId="$(config '.models.codex')" \ UploadBucketName="$UPLOAD_BUCKET" +PROVISIONING_STATE_MACHINE_ARN="$(stack_output "$(provisioning_stack)" StateMachineArn)" + +retire_superseded_runtime_templates() { + local provisioning_template_keys parsed_template_keys template_key template_tags + say "Retiring superseded runtime templates without touching the live template" + if ! provisioning_template_keys="$(aws_cli s3api list-objects-v2 \ + --bucket "$UPLOAD_BUCKET" \ + --prefix provisioning/ \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --query 'Contents[].Key' \ + --output json)"; then + printf 'WARNING: Superseded runtime templates could not be listed; deployment remains ready.\n' >&2 + return 0 + fi + if ! parsed_template_keys="$(jq -er '(. // []) | map(select(type == "string")) | join("\n")' \ + <<<"$provisioning_template_keys")"; then + printf 'WARNING: Superseded runtime template results could not be read; deployment remains ready.\n' >&2 + return 0 + fi + + while IFS= read -r template_key; do + [[ -n "$template_key" ]] || continue + if [[ ! "$template_key" =~ ^provisioning/runtime-[0-9a-f]{64}\.yaml$ ]]; then + printf 'WARNING: Skipping an unexpected object under the provisioning prefix.\n' >&2 + continue + fi + [[ "$template_key" == "$RUNTIME_TEMPLATE_KEY" ]] && continue + if ! template_tags="$(aws_cli s3api get-object-tagging \ + --bucket "$UPLOAD_BUCKET" \ + --key "$template_key" \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --query TagSet \ + --output json)"; then + printf 'WARNING: A superseded runtime template could not be inspected; continuing.\n' >&2 + continue + fi + if jq -e 'any(.[]; .Key == "agentformation-lifecycle" and .Value == "superseded")' \ + <<<"$template_tags" >/dev/null; then + continue + fi + # The self-copy applies the retirement tag, resets its expiry window, and + # deliberately replaces irrelevant object metadata on this YAML object. + if ! aws_cli s3api copy-object \ + --bucket "$UPLOAD_BUCKET" \ + --key "$template_key" \ + --copy-source "$UPLOAD_BUCKET/$template_key" \ + --metadata-directive REPLACE \ + --tagging-directive REPLACE \ + --tagging 'agentformation-lifecycle=superseded' \ + --server-side-encryption AES256 \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --expected-source-bucket-owner "$ACCOUNT_ID" >/dev/null; then + printf 'WARNING: A superseded runtime template could not be retired; continuing.\n' >&2 + fi + done <<<"$parsed_template_keys" +} REPOSITORY_URI="$(stack_output "$(foundation_stack)" WebRepositoryUri)" +REPOSITORY_NAME="$(stack_output "$(foundation_stack)" WebRepositoryName)" +if [[ "$AWS_PARTITION" == "aws-cn" ]]; then + ECR_URL_SUFFIX="amazonaws.com.cn" +else + ECR_URL_SUFFIX="amazonaws.com" +fi +EXPECTED_REPOSITORY_URI="$ACCOUNT_ID.dkr.ecr.$(region).$ECR_URL_SUFFIX/$REPOSITORY_NAME" +[[ "$REPOSITORY_URI" == "$EXPECTED_REPOSITORY_URI" ]] || \ + fail "The foundation stack returned a web repository outside this account or region" IMAGE_TAG="$(date -u +%Y%m%d%H%M%S)" REGISTRY_HOST="${REPOSITORY_URI%%/*}" DOCKER_CONFIG_DIR="$STATE_DIR/docker" @@ -346,59 +465,51 @@ DOCKER_HOST="$DOCKER_ENDPOINT" docker --config "$DOCKER_CONFIG_DIR" buildx build cleanup_docker_auth trap - EXIT +WEB_IMAGE_DIGEST="$(aws_cli ecr describe-images \ + --repository-name "$REPOSITORY_NAME" \ + --image-ids "imageTag=$IMAGE_TAG" \ + --query 'imageDetails[0].imageDigest' \ + --output text)" +[[ "$WEB_IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]] || \ + fail "ECR did not return a valid digest for the web image" +WEB_IMAGE_IDENTIFIER="$REPOSITORY_URI@$WEB_IMAGE_DIGEST" + deploy_web_stack() { local public_url="$1" deploy_stack "$(web_stack)" templates/web.yaml \ DeploymentName="$DEPLOYMENT" \ - ImageIdentifier="$REPOSITORY_URI:$IMAGE_TAG" \ + ImageIdentifier="$WEB_IMAGE_IDENTIFIER" \ PublicUrl="$public_url" \ UserPoolClientId="$CLIENT_ID" \ - CognitoIssuer="$(stack_output "$(foundation_stack)" CognitoIssuer)" \ + UserPoolId="$USER_POOL_ID" \ CognitoClientSecretArn="$CLIENT_SECRET_ARN" \ - AuthSecretArn="$(stack_output "$(foundation_stack)" AuthSecretArn)" \ - UserRegistryTableName="$(stack_output "$(foundation_stack)" UserRegistryTableName)" \ - UploadBucketName="$(stack_output "$(foundation_stack)" UploadBucketName)" \ - TerminalSessionDocumentName="$(stack_output "$(foundation_stack)" TerminalSessionDocumentName)" \ - ProvisioningStateMachineArn="$(stack_output "$(provisioning_stack)" StateMachineArn)" + AuthSecretArn="$AUTH_SECRET_ARN" \ + UserRegistryTableName="$USER_REGISTRY_TABLE_NAME" \ + ControlTableName="$CONTROL_TABLE_NAME" \ + UploadBucketName="$UPLOAD_BUCKET" \ + TerminalSessionDocumentName="$TERMINAL_SESSION_DOCUMENT_NAME" \ + UploadDeliveryDocumentName="$UPLOAD_DELIVERY_DOCUMENT_NAME" \ + OAuthRelayDocumentName="$OAUTH_RELAY_DOCUMENT_NAME" \ + ProvisioningStateMachineArn="$PROVISIONING_STATE_MACHINE_ARN" } -INITIAL_PUBLIC_URL="$CONFIGURED_PUBLIC_URL" -if [[ -z "$INITIAL_PUBLIC_URL" ]]; then - INITIAL_PUBLIC_URL="$(stack_output "$(web_stack)" ServiceUrl 2>/dev/null || true)" -fi -if [[ ! "$INITIAL_PUBLIC_URL" =~ ^https?:// ]]; then - INITIAL_PUBLIC_URL='http://localhost:3000' -fi deploy_web_stack "$INITIAL_PUBLIC_URL" SERVICE_URL="$(stack_output "$(web_stack)" ServiceUrl)" +require_https_origin "The deployed web service URL" "$SERVICE_URL" PUBLIC_URL="${CONFIGURED_PUBLIC_URL:-$SERVICE_URL}" +require_https_origin "The final public URL" "$PUBLIC_URL" if [[ "$INITIAL_PUBLIC_URL" != "$PUBLIC_URL" ]]; then say "Applying the final public address to Auth.js" deploy_web_stack "$PUBLIC_URL" fi say "Updating Cognito callback URLs for the public web address" -configure_cognito_client \ - "$(jq -cn --arg publicUrl "$PUBLIC_URL" '[($publicUrl + "/api/auth/callback/cognito")]')" \ - "$(jq -cn --arg publicUrl "$PUBLIC_URL" '[($publicUrl + "/")]')" - -say "Restricting browser uploads to the deployed web address" -UPLOAD_CORS_CONFIGURATION="$(jq -cn --arg publicUrl "$PUBLIC_URL" '{ - CORSRules: [{ - AllowedHeaders: ["content-type"], - AllowedMethods: ["PUT"], - AllowedOrigins: [$publicUrl], - ExposeHeaders: ["ETag"], - MaxAgeSeconds: 300 - }] -}')" -aws_cli s3api put-bucket-cors \ - --bucket "$(stack_output "$(foundation_stack)" UploadBucketName)" \ - --cors-configuration "$UPLOAD_CORS_CONFIGURATION" +deploy_foundation_stack true "$PUBLIC_URL" cat >"$STATE_DIR/deployment.json" </dev/null 2>&1; then - PIPELINE_ARN="$(stack_output "$(image_stack)" ImagePipelineArn)" - while IFS= read -r IMAGE_ARN; do - [[ -n "$IMAGE_ARN" ]] || continue - while IFS= read -r AMI_ID; do - [[ "$AMI_ID" =~ ^ami-[0-9a-f]+$ ]] || continue - SNAPSHOT_IDS="$(aws_cli ec2 describe-images --image-ids "$AMI_ID" --query 'Images[].BlockDeviceMappings[].Ebs.SnapshotId' --output text 2>/dev/null || true)" - aws_cli ec2 deregister-image --image-id "$AMI_ID" || true - for snapshot_id in $SNAPSHOT_IDS; do - [[ "$snapshot_id" =~ ^snap-[0-9a-f]+$ ]] || continue - aws_cli ec2 delete-snapshot --snapshot-id "$snapshot_id" || true - done - done < <(aws_cli imagebuilder get-image --image-build-version-arn "$IMAGE_ARN" --query 'image.outputResources.amis[].image' --output text | tr '\t' '\n') - aws_cli imagebuilder delete-image --image-build-version-arn "$IMAGE_ARN" || true - done < <(aws_cli imagebuilder list-image-pipeline-images --image-pipeline-arn "$PIPELINE_ARN" --query 'imageSummaryList[].arn' --output text | tr '\t' '\n') +assert_named_stack_owned() { + local stack="$1" + local description + description="$(aws_cli cloudformation describe-stacks \ + --stack-name "$stack" \ + --output json)" || return 1 + jq -e --arg stack "$stack" --arg deployment "$DEPLOYMENT" ' + (.Stacks // []) | length == 1 and + .[0].StackName == $stack and + any(.[0].Tags[]?; + .Key == "AgentFormationDeployment" and .Value == $deployment) + ' <<<"$description" >/dev/null || \ + fail "The named stack is not tagged for this deployment: $stack" +} + +delete_named_stack() { + local stack="$1" + local stack_status + if stack_exists "$stack"; then + assert_named_stack_owned "$stack" + delete_stack "$stack" + return + else + stack_status=$? + fi + [[ "$stack_status" -eq 1 ]] || return "$stack_status" +} + +list_runtime_stacks() { + local stacks + stacks="$(aws_cli cloudformation describe-stacks --output json)" || return 1 + jq -er --arg prefix "$DEPLOYMENT-runtime-" --arg deployment "$DEPLOYMENT" ' + [.Stacks[]? | select(.StackName | startswith($prefix))] as $candidates + | if ([$candidates[] | select( + ([.Tags[]? | select(.Key == "AgentFormationDeployment" and .Value == $deployment)] | length) == 0 + )] | length) > 0 + then error("a runtime-prefix stack is not tagged for this deployment") + else [$candidates[].StackName] | unique + end + ' <<<"$stacks" +} + +list_deployment_image_builds() { + local recipe_name="$DEPLOYMENT-image-recipe" + local arn_prefix aws_region + local image_versions version_arns version_arn build_versions new_builds + local builds='[]' + aws_region="$(region)" || return 1 + arn_prefix="arn:$AWS_PARTITION:imagebuilder:$aws_region:$ACCOUNT_ID:image/$recipe_name/" + + image_versions="$(aws_cli imagebuilder list-images \ + --owner Self \ + --include-deprecated \ + --filters "name=name,values=$recipe_name" \ + --output json)" || return 1 + jq -e --arg recipe "$recipe_name" --arg owner "$ACCOUNT_ID" --arg prefix "$arn_prefix" ' + (.imageVersionList // []) | all(.[]; + .name == $recipe and .owner == $owner and (.arn | startswith($prefix))) + ' <<<"$image_versions" >/dev/null || fail "Image Builder returned an image version outside this deployment" + version_arns="$(jq -er '[.imageVersionList[]?.arn] | unique | join("\n")' <<<"$image_versions")" || return 1 + + while IFS= read -r version_arn; do + [[ -n "$version_arn" ]] || continue + [[ "$version_arn" == "$arn_prefix"* ]] || fail "Image Builder returned an invalid image version ARN" + build_versions="$(aws_cli imagebuilder list-image-build-versions \ + --image-version-arn "$version_arn" \ + --output json)" || return 1 + jq -e --arg recipe "$recipe_name" --arg owner "$ACCOUNT_ID" --arg prefix "$arn_prefix" ' + (.imageSummaryList // []) | all(.[]; + .name == $recipe and .owner == $owner and (.arn | startswith($prefix))) + ' <<<"$build_versions" >/dev/null || fail "Image Builder returned an image build outside this deployment" + new_builds="$(jq -ce '(.imageSummaryList // []) | select(type == "array")' <<<"$build_versions")" || return 1 + builds="$(jq -cn --argjson current "$builds" --argjson additions "$new_builds" \ + '$current + $additions | unique_by(.arn)')" || return 1 + done <<<"$version_arns" + + printf '%s\n' "$builds" +} + +quiesce_image_builder_outputs() { + local builds active_arns image_arn cancel_result + local quiet_passes=0 attempt=1 + + say "Stopping any Image Builder work before removing images" + while [[ "$attempt" -le 120 ]]; do + builds="$(list_deployment_image_builds)" || return 1 + jq -e 'type == "array"' <<<"$builds" >/dev/null || return 1 + active_arns="$(jq -er '[.[] | select( + (.state.status // "") as $status | + (["AVAILABLE", "CANCELLED", "FAILED", "DEPRECATED", "DELETED", "DISABLED"] | index($status)) == null + ) | .arn] | unique | join("\n")' <<<"$builds")" || return 1 + if [[ -z "$active_arns" ]]; then + quiet_passes=$((quiet_passes + 1)) + say "Confirming Image Builder is quiet ($quiet_passes/5)" + [[ "$quiet_passes" -ge 5 ]] && return 0 + sleep 30 + else + quiet_passes=0 + while IFS= read -r image_arn; do + [[ -n "$image_arn" ]] || continue + if ! cancel_result="$(aws_cli imagebuilder cancel-image-creation \ + --image-build-version-arn "$image_arn" 2>&1)"; then + if [[ "$cancel_result" != *InvalidRequestException* && \ + "$cancel_result" != *ResourceNotFoundException* ]]; then + printf '%s\n' "$cancel_result" >&2 + return 1 + fi + fi + done <<<"$active_arns" + say "Waiting for Image Builder cancellation to finish" + sleep 15 + fi + attempt=$((attempt + 1)) + done + + printf 'ERROR: Image Builder did not become quiet before the teardown deadline\n' >&2 + return 1 +} + +delete_deployment_image_outputs() { + local builds builder_ami_ids builder_ami_id image_result image_error image_missing + local tagged_images image_descriptions tagged_snapshots legacy_snapshots + local all_legacy_snapshots='[]' + local ami_ids ami_id snapshot_ids snapshot_id image_arns image_arn + local deregister_result delete_result delete_image_result remaining_builds + local remaining_build_count remaining_ami_count remaining_snapshot_count quiet_passes attempt aws_region + + builds="$(list_deployment_image_builds)" || return 1 + aws_region="$(region)" || return 1 + jq -e --arg region "$aws_region" --arg account "$ACCOUNT_ID" ' + all(.[].outputResources.amis[]?; + .region == $region and .accountId == $account and + (.image | test("^ami-([0-9a-f]{8}|[0-9a-f]{17})$"))) + ' <<<"$builds" >/dev/null || fail "Image Builder returned an AMI outside this account and region" + builder_ami_ids="$(jq -er '[.[].outputResources.amis[]?.image] | unique | join("\n")' <<<"$builds")" + tagged_images="$(aws_cli ec2 describe-images \ + --owners self \ + --filters "Name=tag:AgentFormationDeployment,Values=$DEPLOYMENT" \ + --output json)" || return 1 + jq -e --arg deployment "$DEPLOYMENT" ' + (.Images // []) | all(.[]; + any(.Tags[]?; .Key == "AgentFormationDeployment" and .Value == $deployment)) + ' <<<"$tagged_images" >/dev/null || fail "EC2 returned an AMI outside this deployment" + image_descriptions="$(jq -c '.Images // []' <<<"$tagged_images")" + + while IFS= read -r builder_ami_id; do + [[ -n "$builder_ami_id" ]] || continue + [[ "$builder_ami_id" =~ ^ami-([0-9a-f]{8}|[0-9a-f]{17})$ ]] || fail "Image Builder returned an invalid AMI ID" + image_missing=false + if ! jq -e --arg ami "$builder_ami_id" 'any(.[]; .ImageId == $ami)' <<<"$image_descriptions" >/dev/null; then + if ! image_result="$(aws_cli ec2 describe-images --image-ids "$builder_ami_id" --output json 2>&1)"; then + image_error="$image_result" + if [[ "$image_error" != *InvalidAMIID.NotFound* && "$image_error" != *InvalidAMIID.Unavailable* ]]; then + printf '%s\n' "$image_error" >&2 + return 1 + fi + image_missing=true + else + # The validated Image Builder record is the authority for older or + # partially completed deployments whose imperative EC2 tags are absent. + jq -e --arg ami "$builder_ami_id" --arg owner "$ACCOUNT_ID" ' + (.Images // []) | length == 1 and + .[0].ImageId == $ami and + .[0].OwnerId == $owner + ' <<<"$image_result" >/dev/null || fail "An Image Builder AMI is outside this AWS account" + image_descriptions="$(jq -cn --argjson current "$image_descriptions" --argjson additions "$(jq -c '.Images' <<<"$image_result")" \ + '$current + $additions | unique_by(.ImageId)')" + fi + fi + + # Older AgentFormation releases did not explicitly tag EBS snapshots. + # Use the AWS-documented CreateImage description only when the validated + # Image Builder AMI has already disappeared and its block-device mapping + # can no longer identify the snapshots from the source of truth. + if [[ "$image_missing" == "true" ]]; then + legacy_snapshots="$(aws_cli ec2 describe-snapshots \ + --owner-ids self \ + --filters "Name=description,Values=Created by CreateImage(*) for $builder_ami_id*" \ + --output json)" || return 1 + jq -e --arg ami "$builder_ami_id" ' + (.Snapshots // []) | all(.[]; + (.Description // "") | + test("^Created by CreateImage\\(i-(?:[0-9a-f]{8}|[0-9a-f]{17})\\) for " + $ami + "(?: from vol-(?:[0-9a-f]{8}|[0-9a-f]{17}))?$") + ) + ' <<<"$legacy_snapshots" >/dev/null || fail "EC2 returned an unrelated legacy snapshot" + all_legacy_snapshots="$(jq -cn \ + --argjson current "$all_legacy_snapshots" \ + --argjson additions "$(jq -c '.Snapshots // []' <<<"$legacy_snapshots")" \ + '$current + $additions | unique_by(.SnapshotId)')" || return 1 + fi + done <<<"$builder_ami_ids" + + tagged_snapshots="$(aws_cli ec2 describe-snapshots \ + --owner-ids self \ + --filters "Name=tag:AgentFormationDeployment,Values=$DEPLOYMENT" \ + --output json)" + jq -e --arg deployment "$DEPLOYMENT" ' + (.Snapshots // []) | all(.[]; + any(.Tags[]?; .Key == "AgentFormationDeployment" and .Value == $deployment)) + ' <<<"$tagged_snapshots" >/dev/null || fail "EC2 returned a snapshot outside this deployment" + + ami_ids="$(jq -er '[.[].ImageId] | unique | join("\n")' <<<"$image_descriptions")" + snapshot_ids="$(jq -nre \ + --argjson images "$image_descriptions" \ + --argjson tagged "$tagged_snapshots" \ + --argjson legacy "$all_legacy_snapshots" ' + ([ + $images[]?.BlockDeviceMappings[]?.Ebs.SnapshotId, + $tagged.Snapshots[]?.SnapshotId, + $legacy[]?.SnapshotId + ] | map(select(type == "string")) | unique | join("\n")) + ')" + + while IFS= read -r ami_id; do + [[ -n "$ami_id" ]] || continue + [[ "$ami_id" =~ ^ami-([0-9a-f]{8}|[0-9a-f]{17})$ ]] || fail "EC2 returned an invalid deployment AMI ID" + if ! deregister_result="$(aws_cli ec2 deregister-image --image-id "$ami_id" 2>&1)"; then + if [[ "$deregister_result" != *InvalidAMIID.NotFound* && "$deregister_result" != *InvalidAMIID.Unavailable* ]]; then + printf '%s\n' "$deregister_result" >&2 + return 1 + fi + fi + done <<<"$ami_ids" + + while IFS= read -r snapshot_id; do + [[ -n "$snapshot_id" ]] || continue + [[ "$snapshot_id" =~ ^snap-([0-9a-f]{8}|[0-9a-f]{17})$ ]] || fail "EC2 returned an invalid deployment snapshot ID" + attempt=1 + while true; do + if delete_result="$(aws_cli ec2 delete-snapshot --snapshot-id "$snapshot_id" 2>&1)"; then + break + fi + if [[ "$delete_result" == *InvalidSnapshot.NotFound* ]]; then + break + fi + if [[ "$delete_result" == *InvalidSnapshot.InUse* && "$attempt" -lt 30 ]]; then + sleep 2 + attempt=$((attempt + 1)) + continue + fi + printf '%s\n' "$delete_result" >&2 + return 1 + done + done <<<"$snapshot_ids" + + image_arns="$(jq -er '[.[].arn] | unique | join("\n")' <<<"$builds")" + while IFS= read -r image_arn; do + [[ -n "$image_arn" ]] || continue + if ! delete_image_result="$(aws_cli imagebuilder delete-image \ + --image-build-version-arn "$image_arn" 2>&1)"; then + if [[ "$delete_image_result" != *ResourceNotFoundException* ]]; then + printf '%s\n' "$delete_image_result" >&2 + return 1 + fi + fi + done <<<"$image_arns" + + attempt=1 + quiet_passes=0 + while [[ "$attempt" -le 30 ]]; do + remaining_builds="$(list_deployment_image_builds)" || return 1 + tagged_images="$(aws_cli ec2 describe-images \ + --owners self \ + --filters "Name=tag:AgentFormationDeployment,Values=$DEPLOYMENT" \ + --output json)" || return 1 + tagged_snapshots="$(aws_cli ec2 describe-snapshots \ + --owner-ids self \ + --filters "Name=tag:AgentFormationDeployment,Values=$DEPLOYMENT" \ + --output json)" || return 1 + remaining_build_count="$(jq -er 'length' <<<"$remaining_builds")" + remaining_ami_count="$(jq -er '(.Images // []) | length' <<<"$tagged_images")" + remaining_snapshot_count="$(jq -er '(.Snapshots // []) | length' <<<"$tagged_snapshots")" + [[ "$remaining_build_count" =~ ^[0-9]+$ && \ + "$remaining_ami_count" =~ ^[0-9]+$ && \ + "$remaining_snapshot_count" =~ ^[0-9]+$ ]] || \ + fail "AWS returned an invalid image cleanup count" + if [[ "$remaining_build_count" -eq 0 && \ + "$remaining_ami_count" -eq 0 && \ + "$remaining_snapshot_count" -eq 0 ]]; then + quiet_passes=$((quiet_passes + 1)) + [[ "$quiet_passes" -ge 2 ]] && return 0 + else + quiet_passes=0 + fi + [[ "$attempt" -lt 30 ]] || fail "Image Builder or EC2 image resources remained after cleanup" + sleep 2 + attempt=$((attempt + 1)) + done + +} + +empty_upload_bucket() { + local bucket="$1" + local account_id="$2" + local multipart_listing multipart_uploads multipart_lines multipart_count multipart_upload + local upload_key upload_id abort_result multipart_attempt=1 + local version_listing version_delete_request version_count version_attempt=1 + local delete_result + + # Incomplete multipart uploads are not objects or versions, but they still + # keep S3 from deleting the bucket. The temporary bucket policy prevents new + # uploads while this loop repeatedly drains the first page. + while true; do + multipart_listing="$(aws_cli s3api list-multipart-uploads \ + --bucket "$bucket" \ + --max-uploads 1000 \ + --no-paginate \ + --expected-bucket-owner "$account_id" \ + --output json)" || return 1 + multipart_uploads="$(jq -ce '(.Uploads // []) | select(type == "array")' \ + <<<"$multipart_listing")" || return 1 + multipart_count="$(jq -er 'length' <<<"$multipart_uploads")" || return 1 + multipart_lines="$(jq -c '.[]' <<<"$multipart_uploads")" || return 1 + [[ "$multipart_count" =~ ^[0-9]+$ ]] || fail "S3 returned an invalid multipart upload count" + [[ "$multipart_count" -gt 0 ]] || break + + while IFS= read -r multipart_upload; do + [[ -n "$multipart_upload" ]] || continue + upload_key="$(jq -er '.Key | select(type == "string" and length > 0)' \ + <<<"$multipart_upload")" || return 1 + upload_id="$(jq -er '.UploadId | select(type == "string" and length > 0)' \ + <<<"$multipart_upload")" || return 1 + if ! abort_result="$(aws_cli s3api abort-multipart-upload \ + --bucket "$bucket" \ + --key="$upload_key" \ + --upload-id="$upload_id" \ + --expected-bucket-owner "$account_id" 2>&1)"; then + if [[ "$abort_result" != *NoSuchUpload* ]]; then + printf '%s\n' "$abort_result" >&2 + return 1 + fi + fi + done <<<"$multipart_lines" + [[ "$multipart_attempt" -lt 120 ]] || \ + fail "Multipart uploads did not drain before the teardown deadline" + multipart_attempt=$((multipart_attempt + 1)) + done + + # Delete every version and delete marker. S3 exposes objects from an + # unversioned bucket here with VersionId "null", so one loop safely covers + # both the default bucket and a bucket whose operator enabled versioning. + # --no-paginate keeps each delete at S3's 1,000-object request limit. + while true; do + version_listing="$(aws_cli s3api list-object-versions \ + --bucket "$bucket" \ + --max-keys 1000 \ + --no-paginate \ + --expected-bucket-owner "$account_id" \ + --output json)" || return 1 + version_delete_request="$(jq -cae ' + (.Versions // []) as $versions | + (.DeleteMarkers // []) as $markers | + if ($versions | type) == "array" and ($markers | type) == "array" + then {Objects: ( + [$versions[] | {Key, VersionId}] + + [$markers[] | {Key, VersionId}] + ), Quiet: false} + else error("S3 returned malformed object-version arrays") + end + ' <<<"$version_listing")" || return 1 + version_count="$(jq -er '.Objects | length' <<<"$version_delete_request")" || return 1 + [[ "$version_count" =~ ^[0-9]+$ ]] || fail "S3 returned an invalid version count" + [[ "$version_count" -gt 0 ]] || break + delete_result="$(printf '%s' "$version_delete_request" | \ + aws_cli s3api delete-objects \ + --bucket "$bucket" \ + --expected-bucket-owner "$account_id" \ + --delete file:///dev/stdin \ + --output json)" || return 1 + jq -e --argjson requested "$version_count" ' + ((.Errors // []) | length) == 0 and + ((.Deleted // []) | length) == $requested + ' <<<"$delete_result" >/dev/null || \ + fail "Some versioned upload bucket objects could not be deleted" + [[ "$version_attempt" -lt 120 ]] || \ + fail "Object versions did not drain before the teardown deadline" + version_attempt=$((version_attempt + 1)) + done + +} + +assert_no_runtime_stacks() { + local remaining remaining_count quiet_passes=0 + while [[ "$quiet_passes" -lt 2 ]]; do + remaining="$(list_runtime_stacks)" + remaining_count="$(jq -er 'length' <<<"$remaining")" + [[ "$remaining_count" =~ ^[0-9]+$ ]] || fail "CloudFormation returned an invalid runtime stack count" + [[ "$remaining_count" -eq 0 ]] || fail "A runtime stack reappeared during teardown" + quiet_passes=$((quiet_passes + 1)) + [[ "$quiet_passes" -ge 2 ]] || sleep 2 + done +} + +# Remove the browser first so no new provisioning request can be accepted. +delete_named_stack "$(web_stack)" + +# Then stop every already-running workflow before taking the authoritative +# runtime-stack snapshot. Keep the provisioning stack until its runtime +# CloudFormation service role is no longer needed. +stop_all_provisioning_executions || fail "Provisioning executions could not be stopped; teardown did not continue" + +RUNTIME_STACKS="$(list_runtime_stacks)" +while IFS= read -r runtime_stack; do + [[ -n "$runtime_stack" ]] || continue + delete_stack "$runtime_stack" +done <<<"$(jq -r '.[]' <<<"$RUNTIME_STACKS")" +assert_no_runtime_stacks +delete_named_stack "$(provisioning_stack)" + +IMAGE_STACK_PRESENT=false +if stack_exists "$(image_stack)"; then + assert_named_stack_owned "$(image_stack)" + IMAGE_STACK_PRESENT=true +else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || exit "$stack_status" +fi +quiesce_image_builder_outputs || fail "Image Builder could not be stopped; teardown did not continue" +delete_deployment_image_outputs +if [[ "$IMAGE_STACK_PRESENT" == "true" ]]; then delete_stack "$(image_stack)" fi -if aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/dev/null 2>&1; then +if stack_exists "$(foundation_stack)"; then + assert_named_stack_owned "$(foundation_stack)" + # Every supported foundation stack owns an upload bucket. Without this + # required output, teardown cannot prove or drain that bucket safely. BUCKET="$(stack_output "$(foundation_stack)" UploadBucketName)" - REPOSITORY="$(stack_output "$(foundation_stack)" WebRepositoryName)" - aws_cli s3 rm "s3://$BUCKET" --recursive - IMAGE_IDS="$(aws_cli ecr list-images --repository-name "$REPOSITORY" --query 'imageIds' --output json)" - if [[ "$(jq 'length' <<<"$IMAGE_IDS")" -gt 0 ]]; then - aws_cli ecr batch-delete-image --repository-name "$REPOSITORY" --image-ids "$IMAGE_IDS" >/dev/null + [[ "$BUCKET" =~ ^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$ && "$BUCKET" != *..* ]] || \ + fail "The foundation stack returned an invalid upload bucket name" + BUCKET_TAGS="$(aws_cli s3api get-bucket-tagging \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --output json)" + jq -e --arg deployment "$DEPLOYMENT" ' + any(.TagSet[]?; .Key == "AgentFormationDeployment" and .Value == $deployment) + ' <<<"$BUCKET_TAGS" >/dev/null || fail "The upload bucket is not tagged for this deployment" + ORIGINAL_BUCKET_POLICY_PRESENT=true + if ! POLICY_RESULT="$(aws_cli s3api get-bucket-policy \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --query Policy \ + --output text 2>&1)"; then + if [[ "$POLICY_RESULT" == *NoSuchBucketPolicy* ]]; then + ORIGINAL_BUCKET_POLICY_PRESENT=false + POLICY_RESULT='{"Version":"2012-10-17","Statement":[]}' + else + printf '%s\n' "$POLICY_RESULT" >&2 + fail "The upload bucket policy could not be read" + fi + fi + ORIGINAL_BUCKET_POLICY="$(jq -ce ' + .Statement = ((.Statement // []) | if type == "array" then . else [.] end + | map(select(.Sid != "DenyNewUploadsDuringTeardown"))) + ' <<<"$POLICY_RESULT")" + ORIGINAL_STATEMENT_COUNT="$(jq -er '.Statement | length' <<<"$ORIGINAL_BUCKET_POLICY")" + [[ "$ORIGINAL_STATEMENT_COUNT" =~ ^[0-9]+$ ]] || fail "The upload bucket policy has an invalid statement count" + if [[ "$ORIGINAL_STATEMENT_COUNT" -eq 0 ]]; then + ORIGINAL_BUCKET_POLICY_PRESENT=false fi + FREEZE_POLICY="$(jq -ce \ + --arg partition "$AWS_PARTITION" \ + --arg bucket "$BUCKET" \ + '.Statement += [{Sid:"DenyNewUploadsDuringTeardown",Effect:"Deny",Principal:"*",Action:"s3:PutObject",Resource:("arn:"+$partition+":s3:::"+$bucket+"/*")}]' \ + <<<"$ORIGINAL_BUCKET_POLICY")" + BUCKET_POLICY_FROZEN=false + FOUNDATION_REMOVED=false + restore_bucket_policy() { + local exit_status=$? + trap - EXIT + trap '' INT TERM HUP QUIT + if [[ "$BUCKET_POLICY_FROZEN" == "true" && "$FOUNDATION_REMOVED" != "true" ]]; then + say "Restoring the upload bucket policy after an incomplete teardown" + if [[ "$ORIGINAL_BUCKET_POLICY_PRESENT" == "true" ]]; then + if ! aws_cli s3api put-bucket-policy \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --policy "$ORIGINAL_BUCKET_POLICY"; then + printf 'ERROR: The original upload bucket policy could not be restored\n' >&2 + exit 1 + fi + elif ! aws_cli s3api delete-bucket-policy \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$ACCOUNT_ID"; then + printf 'ERROR: The temporary upload bucket policy could not be removed\n' >&2 + exit 1 + fi + fi + exit "$exit_status" + } + trap restore_bucket_policy EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 131' QUIT + trap 'exit 143' TERM + BUCKET_POLICY_FROZEN=true + aws_cli s3api put-bucket-policy \ + --bucket "$BUCKET" \ + --expected-bucket-owner "$ACCOUNT_ID" \ + --policy "$FREEZE_POLICY" + empty_upload_bucket "$BUCKET" "$ACCOUNT_ID" + # The foundation template owns an EmptyOnDelete ECR repository, so + # CloudFormation handles image pagination and partial failures itself. delete_stack "$(foundation_stack)" + FOUNDATION_REMOVED=true + BUCKET_POLICY_FROZEN=false + trap - EXIT INT TERM HUP QUIT +else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || exit "$stack_status" fi -delete_stack "$(network_stack)" +delete_named_stack "$(network_stack)" say "AgentFormation AWS resources were deleted" diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 80482e5..8a218e7 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -9,7 +9,7 @@ docker buildx version >/dev/null || fail "Docker buildx is required" require_config say "Checking local configuration" -config '.deploymentName | test("^[a-z][a-z0-9-]{2,31}$")' >/dev/null +config '.deploymentName | test("^[a-z][a-z0-9-]{2,31}$") and (contains("-runtime-") | not)' >/dev/null config '(.publicUrl // "") | type == "string" and (. == "" or test("^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?$"))' >/dev/null config '.networkMode == "private-nat" or .networkMode == "private-endpoints"' >/dev/null config '(.identityCenter.metadataUrl // "") | type == "string" and (. == "" or test("^https://[^[:space:]]+$"))' >/dev/null diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index f0661a9..cc25445 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -8,13 +8,36 @@ STATE_DIR="$ROOT_DIR/.agentformation" say() { printf '==> %s\n' "$*"; } fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +require_https_origin() { + local label="$1" + local value="$2" + local authority host port + if [[ ! "$value" =~ ^https://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:([0-9]{1,5}))?$ ]]; then + fail "$label must be an HTTPS origin without a path or trailing slash" + fi + port="${BASH_REMATCH[3]:-}" + authority="${value#https://}" + host="${authority%%:*}" + if [[ "$host" == *..* || "$host" == *.-* || "$host" == *-.* ]]; then + fail "$label must be an HTTPS origin without a path or trailing slash" + fi + if [[ -n "$port" ]] && (( 10#$port < 1 || 10#$port > 65535 )); then + fail "$label must use a valid HTTPS port" + fi +} + require_command() { command -v "$1" >/dev/null 2>&1 || fail "$1 is required" } require_config() { + local deployment [[ -f "$CONFIG_FILE" ]] || fail "Copy agentformation.example.json to agentformation.local.json and review it first" jq -e . "$CONFIG_FILE" >/dev/null || fail "$CONFIG_FILE is not valid JSON" + deployment="$(jq -er '.deploymentName | select(type == "string")' "$CONFIG_FILE")" || \ + fail "$CONFIG_FILE must contain a deploymentName string" + [[ "$deployment" =~ ^[a-z][a-z0-9-]{2,31}$ && "$deployment" != *-runtime-* ]] || \ + fail "deploymentName must start with a lowercase letter, contain only lowercase letters, numbers, and hyphens, be 3-32 characters, and not contain -runtime-" } config() { @@ -22,7 +45,13 @@ config() { } deployment_name() { config '.deploymentName'; } -region() { printf '%s\n' "${AWS_REGION:-$(config '.region')}"; } +region() { + if [[ -n "${AWS_REGION:-}" ]]; then + printf '%s\n' "$AWS_REGION" + else + config '.region' + fi +} profile() { printf '%s\n' "${AWS_PROFILE:-${AGENTFORMATION_AWS_PROFILE:-default}}"; } aws_cli() { @@ -36,13 +65,66 @@ provisioning_stack() { printf '%s-provisioning\n' "$(deployment_name)"; } web_stack() { printf '%s-web\n' "$(deployment_name)"; } ami_parameter_path() { printf '/agentformation/%s/runtime-ami\n' "$(deployment_name)"; } +is_user_subject() { + [[ "$1" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] +} + +is_instance_id() { + [[ "$1" =~ ^i-([0-9a-f]{8}|[0-9a-f]{17})$ ]] +} + +runtime_stack_name_for_subject() { + local user_sub="$1" + local subject_part_one subject_part_two subject_part_three _ + is_user_subject "$user_sub" || \ + fail "Cognito returned an invalid federated subject" + IFS=- read -r subject_part_one subject_part_two subject_part_three _ <<<"$user_sub" + printf '%s-runtime-%s%s%s\n' \ + "$(deployment_name)" \ + "$subject_part_one" \ + "$subject_part_two" \ + "$subject_part_three" +} + +stack_exists() { + local stack="$1" + local result + if result="$(aws_cli cloudformation describe-stacks --stack-name "$stack" --output json 2>&1)"; then + return 0 + fi + if [[ "$result" == *ValidationError* && "$result" == *"does not exist"* ]]; then + return 1 + fi + printf '%s\n' "$result" >&2 + return 2 +} + stack_output() { local stack="$1" local key="$2" - aws_cli cloudformation describe-stacks \ + local value + value="$(aws_cli cloudformation describe-stacks \ --stack-name "$stack" \ --query "Stacks[0].Outputs[?OutputKey=='$key'].OutputValue | [0]" \ - --output text + --output text)" || return 1 + if [[ -z "$value" || "$value" == "None" ]]; then + printf 'ERROR: CloudFormation stack %s did not return the required %s output\n' "$stack" "$key" >&2 + return 1 + fi + printf '%s\n' "$value" +} + +stack_output_optional() { + local stack="$1" + local key="$2" + local value + value="$(aws_cli cloudformation describe-stacks \ + --stack-name "$stack" \ + --query "Stacks[0].Outputs[?OutputKey=='$key'].OutputValue | [0]" \ + --output text)" || return 1 + if [[ -n "$value" && "$value" != "None" ]]; then + printf '%s\n' "$value" + fi } hash_text() { @@ -74,30 +156,157 @@ find_cognito_user_by_email() { jq -er '.[0] | [.Username, (.Attributes[] | select(.Name == "sub").Value)] | @tsv' <<<"$users" } -stop_provisioning_executions() { - local user_sub="$1" - local state_machine_arn executions execution_arn input_subject - if ! aws_cli cloudformation describe-stacks --stack-name "$(provisioning_stack)" >/dev/null 2>&1; then - return +quiesce_provisioning_executions() { + local user_sub="${1:-}" + local state_machine_arn executions execution_arns execution_arn execution_input input_subject + local matching_found quiet_passes=0 attempt=1 stack_status stop_result + + if stack_exists "$(provisioning_stack)"; then + : + else + stack_status=$? + [[ "$stack_status" -eq 1 ]] && return 0 + return "$stack_status" fi + state_machine_arn="$(stack_output "$(provisioning_stack)" StateMachineArn)" - executions="$(aws_cli stepfunctions list-executions \ - --state-machine-arn "$state_machine_arn" \ - --status-filter RUNNING \ - --output json)" - while IFS= read -r execution_arn; do - [[ -n "$execution_arn" ]] || continue - input_subject="$(aws_cli stepfunctions describe-execution \ - --execution-arn "$execution_arn" \ - --query input \ - --output text | jq -r '.subject // ""')" - if [[ "$input_subject" == "$user_sub" ]]; then - aws_cli stepfunctions stop-execution \ + [[ "$state_machine_arn" =~ ^arn:[A-Za-z0-9-]+:states:[A-Za-z0-9-]+:[0-9]{12}:stateMachine:[A-Za-z0-9+=,.@_-]+$ ]] || { + printf 'ERROR: The provisioning stack returned an invalid state machine ARN\n' >&2 + return 1 + } + + # Stop requests are asynchronous. Require two consecutive empty reads so a + # teardown cannot race one last state transition into a new runtime stack. + while [[ "$attempt" -le 30 ]]; do + executions="$(aws_cli stepfunctions list-executions \ + --state-machine-arn "$state_machine_arn" \ + --status-filter RUNNING \ + --output json)" || return 1 + execution_arns="$(jq -er '[.executions[]?.executionArn] | join("\n")' <<<"$executions")" || return 1 + matching_found=false + + while IFS= read -r execution_arn; do + [[ -n "$execution_arn" ]] || continue + if [[ -n "$user_sub" ]]; then + execution_input="$(aws_cli stepfunctions describe-execution \ + --execution-arn "$execution_arn" \ + --query input \ + --output text)" || return 1 + if ! input_subject="$(jq -er ' + if type == "object" and (.subject | type == "string") + then .subject + else error("missing subject") + end + ' <<<"$execution_input" 2>/dev/null)"; then + printf 'ERROR: A running provisioning execution has invalid input; refusing to continue teardown\n' >&2 + return 1 + fi + if ! is_user_subject "$input_subject"; then + printf 'ERROR: A running provisioning execution has an invalid federated subject; refusing to continue teardown\n' >&2 + return 1 + fi + [[ "$input_subject" == "$user_sub" ]] || continue + fi + + matching_found=true + if ! stop_result="$(aws_cli stepfunctions stop-execution \ --execution-arn "$execution_arn" \ --error AdministratorDisabledUser \ - --cause 'The federated user was disabled by an administrator.' >/dev/null + --cause 'The federated user or deployment was disabled by an administrator.' 2>&1)"; then + if [[ "$stop_result" != *ExecutionDoesNotExist* && \ + ! ( "$stop_result" == *ValidationException* && "$stop_result" == *"not RUNNING"* ) ]]; then + printf '%s\n' "$stop_result" >&2 + return 1 + fi + fi + done <<<"$execution_arns" + + if [[ "$matching_found" == "false" ]]; then + quiet_passes=$((quiet_passes + 1)) + [[ "$quiet_passes" -ge 2 ]] && return 0 + else + quiet_passes=0 + fi + sleep 2 + attempt=$((attempt + 1)) + done + + printf 'ERROR: Provisioning executions did not stop within 60 seconds\n' >&2 + return 1 +} + +stop_provisioning_executions() { + local user_sub="$1" + is_user_subject "$user_sub" || { + printf 'ERROR: Refusing to inspect provisioning executions for an invalid federated subject\n' >&2 + return 1 + } + quiesce_provisioning_executions "$user_sub" +} + +stop_all_provisioning_executions() { + quiesce_provisioning_executions +} + +terminate_runtime_sessions() { + local instance_id="$1" + local sessions session_id + local termination_failed=false + is_instance_id "$instance_id" || return 1 + if ! sessions="$(aws_cli ssm describe-sessions \ + --state Active \ + --filters "key=Target,value=$instance_id" \ + --query 'Sessions[].SessionId' \ + --output json)"; then + return 1 + fi + while IFS= read -r session_id; do + [[ -n "$session_id" ]] || continue + if ! aws_cli ssm terminate-session --session-id "$session_id" >/dev/null; then + termination_failed=true fi - done < <(jq -r '.executions[].executionArn' <<<"$executions") + done < <(jq -r '.[]' <<<"$sessions") + [[ "$termination_failed" == "false" ]] +} + +cut_off_runtime_instance() { + local instance_id="$1" + local state_result state stop_result + is_instance_id "$instance_id" || return 1 + + if ! state_result="$(aws_cli ec2 describe-instances \ + --instance-ids "$instance_id" \ + --query 'Reservations[0].Instances[0].State.Name' \ + --output text 2>&1)"; then + if [[ "$state_result" == *InvalidInstanceID.NotFound* ]]; then + return 0 + fi + printf '%s\n' "$state_result" >&2 + return 1 + fi + state="$state_result" + + case "$state" in + None | stopping | stopped | shutting-down | terminated) + return 0 + ;; + pending | running) + if ! terminate_runtime_sessions "$instance_id"; then + printf 'WARNING: Some Session Manager connections could not be terminated; stopping the instance remains the final cutoff.\n' >&2 + fi + if ! stop_result="$(aws_cli ec2 stop-instances --instance-ids "$instance_id" 2>&1)"; then + if [[ "$stop_result" == *InvalidInstanceID.NotFound* || "$stop_result" == *IncorrectInstanceState* ]]; then + return 0 + fi + printf '%s\n' "$stop_result" >&2 + return 1 + fi + ;; + *) + printf 'ERROR: EC2 returned an unexpected state for %s: %s\n' "$instance_id" "$state" >&2 + return 1 + ;; + esac } read_option() { diff --git a/scripts/lib/stacks.sh b/scripts/lib/stacks.sh index beb49ca..7e83d34 100755 --- a/scripts/lib/stacks.sh +++ b/scripts/lib/stacks.sh @@ -25,12 +25,27 @@ deploy_stack() { aws_cli "${arguments[@]}" } -delete_stack() { +delete_stack_if_present() { local stack="$1" - if ! aws_cli cloudformation describe-stacks --stack-name "$stack" >/dev/null 2>&1; then - return + local stack_status + if stack_exists "$stack"; then + : + else + stack_status=$? + [[ "$stack_status" -eq 1 ]] && return 1 + return "$stack_status" fi say "Deleting $stack" - aws_cli cloudformation delete-stack --stack-name "$stack" - aws_cli cloudformation wait stack-delete-complete --stack-name "$stack" + aws_cli cloudformation delete-stack --stack-name "$stack" || return 2 + aws_cli cloudformation wait stack-delete-complete --stack-name "$stack" || return 2 +} + +delete_stack() { + local stack="$1" + local delete_status=0 + delete_stack_if_present "$stack" || delete_status=$? + case "$delete_status" in + 0 | 1) return 0 ;; + *) return "$delete_status" ;; + esac } diff --git a/scripts/users-disable.sh b/scripts/users-disable.sh index bb7bd54..b1e999d 100755 --- a/scripts/users-disable.sh +++ b/scripts/users-disable.sh @@ -8,61 +8,356 @@ source "$SCRIPT_DIR/lib/common.sh" source "$SCRIPT_DIR/lib/stacks.sh" require_config -EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" +EMAIL="$(normalize_email "$(prompt_email "$(read_option --email "$@" || true)")")" [[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" -IFS=$'\t' read -r COGNITO_USERNAME USER_SUB < <(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL") - +COGNITO_USER="$(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL")" +IFS=$'\t' read -r COGNITO_USERNAME USER_SUB <<<"$COGNITO_USER" +is_user_subject "$USER_SUB" || fail "Cognito returned an invalid federated subject" +EXPECTED_RUNTIME_STACK="$(runtime_stack_name_for_subject "$USER_SUB")" KEY="$(jq -cn --arg userSub "$USER_SUB" '{userSub:{S:$userSub}}')" -RUNTIME="$(aws_cli dynamodb get-item \ + +REVOCATION_FAILED=false +QUIESCE_FAILED=false +REGISTRY_FAILED=false +CUTOFF_FAILED=false +ACTION=none +SECURED_INSTANCE_IDS="" +PRE_INSTANCE_ID="" +EARLY_STACK_INSTANCE_ID="" + +secure_instance() { + local instance_id="$1" + [[ -n "$instance_id" ]] || return 0 + if ! is_instance_id "$instance_id"; then + CUTOFF_FAILED=true + return 0 + fi + case ":$SECURED_INSTANCE_IDS:" in + *":$instance_id:"*) return 0 ;; + esac + if cut_off_runtime_instance "$instance_id"; then + SECURED_INSTANCE_IDS="${SECURED_INSTANCE_IDS:+$SECURED_INSTANCE_IDS:}$instance_id" + ACTION=stopped + else + CUTOFF_FAILED=true + fi +} + +say "Disabling company sign-in before changing the assigned runtime" +if ! aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME"; then + printf 'WARNING: Cognito did not confirm that company sign-in was disabled; continuing with the runtime cutoff.\n' >&2 + REVOCATION_FAILED=true +fi +if ! aws_cli cognito-idp admin-user-global-sign-out --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME"; then + printf 'WARNING: Cognito did not confirm global token revocation; continuing with the runtime cutoff.\n' >&2 + REVOCATION_FAILED=true +fi + +# Cut off the deterministic stack's instance before any registry or Step +# Functions call can fail. A first best-effort registry read covers an instance +# that was replaced but has not reached the stack output yet. +if PRE_RUNTIME="$(aws_cli dynamodb get-item \ --table-name "$TABLE_NAME" \ --key "$KEY" \ --consistent-read \ - --output json)" -STATUS="$(jq -r '.Item.status.S // ""' <<<"$RUNTIME")" -INSTANCE_ID="$(jq -r '.Item.instanceId.S // ""' <<<"$RUNTIME")" -RUNTIME_STACK="$(jq -r '.Item.runtimeStackName.S // ""' <<<"$RUNTIME")" - -say "Disabling company sign-in before changing the assigned runtime" -aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" -UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + --output json 2>/dev/null)"; then + if jq -e '(.Item // {}) | has("instanceId")' <<<"$PRE_RUNTIME" >/dev/null; then + if PRE_INSTANCE_ID="$(jq -er ' + .Item.instanceId | + if type == "object" and (.S | type == "string") then .S + else error("invalid instanceId") end + ' <<<"$PRE_RUNTIME")"; then + secure_instance "$PRE_INSTANCE_ID" + else + CUTOFF_FAILED=true + fi + fi +fi +if stack_exists "$EXPECTED_RUNTIME_STACK"; then + if EARLY_STACK_INSTANCE_ID="$(stack_output_optional "$EXPECTED_RUNTIME_STACK" InstanceId)"; then + secure_instance "$EARLY_STACK_INSTANCE_ID" + else + CUTOFF_FAILED=true + fi +else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || CUTOFF_FAILED=true +fi -if [[ -z "$STATUS" ]]; then - say "The user is disabled; no runtime has been created" - exit +if ! stop_provisioning_executions "$USER_SUB"; then + printf 'WARNING: Matching provisioning jobs could not all be stopped.\n' >&2 + QUIESCE_FAILED=true fi -if [[ "$STATUS" == "provisioning" ]]; then - aws_cli dynamodb update-item \ +STATUS="" +INSTANCE_ID="" +STATE_LOCKED=false +REGISTRY_ITEM_PRESENT=false +STATUS_PRESENT=false +INSTANCE_ID_PRESENT=false +OBSERVED_STACK_PRESENT=false +OBSERVED_STARTED_AT_PRESENT=false +PURGE_LOCK_PRESENT=false +PURGE_LOCK_EXPIRES_AT_PRESENT=false + +for ATTEMPT in 1 2 3; do + if ! RUNTIME="$(aws_cli dynamodb get-item \ --table-name "$TABLE_NAME" \ --key "$KEY" \ - --update-expression 'SET #status = :failed, updatedAt = :updatedAt' \ - --condition-expression '#status = :provisioning' \ - --expression-attribute-names '{"#status":"status"}' \ - --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":failed":{S:"failed"},":provisioning":{S:"provisioning"},":updatedAt":{S:$updatedAt}}')" >/dev/null - stop_provisioning_executions "$USER_SUB" - if [[ "$RUNTIME_STACK" == "$(deployment_name)-runtime-"* ]]; then - delete_stack "$RUNTIME_STACK" - fi - say "The user is disabled and the unfinished runtime was removed" - exit -fi + --consistent-read \ + --output json)"; then + REGISTRY_FAILED=true + break + fi + if [[ "$(jq '(.Item // {}) | length' <<<"$RUNTIME")" == "0" ]]; then + # The registry is authoritative for browser access, but a stack can still + # exist if setup was interrupted between CloudFormation and DynamoDB. Add + # a disabled marker before returning so an already-issued Auth.js session + # cannot start a new runtime after Cognito sign-out. + STATUS="" + INSTANCE_ID="" + REGISTRY_ITEM_PRESENT=false + DISABLED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + DISABLED_ITEM="$(jq -cn \ + --arg userSub "$USER_SUB" \ + --arg email "$EMAIL" \ + --arg stack "$EXPECTED_RUNTIME_STACK" \ + --arg updatedAt "$DISABLED_AT" ' + {userSub:{S:$userSub},email:{S:$email},runtimeStackName:{S:$stack},status:{S:"disabled"},updatedAt:{S:$updatedAt}} + ')" + if is_instance_id "$EARLY_STACK_INSTANCE_ID"; then + DISABLED_ITEM="$(jq -c --arg instance "$EARLY_STACK_INSTANCE_ID" \ + '. + {instanceId:{S:$instance}}' <<<"$DISABLED_ITEM")" + elif is_instance_id "$PRE_INSTANCE_ID"; then + DISABLED_ITEM="$(jq -c --arg instance "$PRE_INSTANCE_ID" \ + '. + {instanceId:{S:$instance}}' <<<"$DISABLED_ITEM")" + fi + if PUT_RESULT="$(aws_cli dynamodb put-item \ + --table-name "$TABLE_NAME" \ + --item "$DISABLED_ITEM" \ + --condition-expression 'attribute_not_exists(userSub)' 2>&1)"; then + STATE_LOCKED=true + break + fi + if [[ "$PUT_RESULT" != *ConditionalCheckFailedException* ]]; then + printf '%s\n' "$PUT_RESULT" >&2 + REGISTRY_FAILED=true + break + fi + if [[ "$ATTEMPT" -lt 3 ]]; then + sleep "$ATTEMPT" + continue + fi + break + fi + REGISTRY_ITEM_PRESENT=true + + STATUS_PRESENT=false + INSTANCE_ID_PRESENT=false + OBSERVED_STACK_PRESENT=false + OBSERVED_STARTED_AT_PRESENT=false + PURGE_LOCK_PRESENT=false + PURGE_LOCK_EXPIRES_AT_PRESENT=false + STATUS="" + INSTANCE_ID="" + OBSERVED_STACK="" + OBSERVED_STARTED_AT="" + PURGE_LOCK="" + PURGE_LOCK_EXPIRES_AT="" + if jq -e '.Item | has("status")' <<<"$RUNTIME" >/dev/null; then + STATUS_PRESENT=true + STATUS="$(jq -er '.Item.status | if type == "object" and (.S | type == "string") then .S else error("invalid status") end' <<<"$RUNTIME")" || { + REGISTRY_FAILED=true + break + } + fi + if jq -e '.Item | has("instanceId")' <<<"$RUNTIME" >/dev/null; then + INSTANCE_ID_PRESENT=true + INSTANCE_ID="$(jq -er '.Item.instanceId | if type == "object" and (.S | type == "string") then .S else error("invalid instanceId") end' <<<"$RUNTIME")" || { + REGISTRY_FAILED=true + break + } + if ! is_instance_id "$INSTANCE_ID"; then + CUTOFF_FAILED=true + REGISTRY_FAILED=true + break + fi + fi + if jq -e '.Item | has("runtimeStackName")' <<<"$RUNTIME" >/dev/null; then + OBSERVED_STACK_PRESENT=true + OBSERVED_STACK="$(jq -er '.Item.runtimeStackName | if type == "object" and (.S | type == "string") then .S else error("invalid runtimeStackName") end' <<<"$RUNTIME")" || { + REGISTRY_FAILED=true + break + } + fi + if jq -e '.Item | has("provisioningStartedAt")' <<<"$RUNTIME" >/dev/null; then + OBSERVED_STARTED_AT_PRESENT=true + OBSERVED_STARTED_AT="$(jq -er '.Item.provisioningStartedAt | if type == "object" and (.S | type == "string") then .S else error("invalid provisioningStartedAt") end' <<<"$RUNTIME")" || { + REGISTRY_FAILED=true + break + } + fi + if jq -e '.Item | has("purgeLock")' <<<"$RUNTIME" >/dev/null; then + PURGE_LOCK_PRESENT=true + PURGE_LOCK="$(jq -er '.Item.purgeLock | if type == "object" and (.S | type == "string" and length > 0) then .S else error("invalid purgeLock") end' <<<"$RUNTIME")" || { + REGISTRY_FAILED=true + break + } + fi + if jq -e '.Item | has("purgeLockExpiresAt")' <<<"$RUNTIME" >/dev/null; then + PURGE_LOCK_EXPIRES_AT_PRESENT=true + PURGE_LOCK_EXPIRES_AT="$(jq -er '.Item.purgeLockExpiresAt | if type == "object" and (.N | type == "string" and test("^[1-9][0-9]*$")) then .N else error("invalid purgeLockExpiresAt") end' <<<"$RUNTIME")" || { + REGISTRY_FAILED=true + break + } + fi + if [[ "$PURGE_LOCK_PRESENT" != "$PURGE_LOCK_EXPIRES_AT_PRESENT" ]]; then + REGISTRY_FAILED=true + break + fi + NOW_EPOCH="$(date +%s)" + [[ "$NOW_EPOCH" =~ ^[0-9]+$ ]] || { + REGISTRY_FAILED=true + break + } + if [[ "$PURGE_LOCK_PRESENT" == "true" && "$PURGE_LOCK_EXPIRES_AT" -ge "$NOW_EPOCH" ]]; then + printf 'WARNING: A permanent purge is already in progress for this user.\n' >&2 + REGISTRY_FAILED=true + break + fi + secure_instance "$INSTANCE_ID" -if [[ "$STATUS" == "active" || "$STATUS" == "disabled" ]]; then - aws_cli dynamodb update-item \ + UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + SET_ACTIONS='#status = :disabled, updatedAt = :updatedAt' + REMOVE_ACTIONS='#enable, #enableExpires' + UPDATE_CONDITION='attribute_exists(userSub)' + UPDATE_NAMES='{"#status":"status","#instance":"instanceId","#stack":"runtimeStackName","#startedAt":"provisioningStartedAt","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' + UPDATE_VALUES="$(jq -cn --arg updatedAt "$UPDATED_AT" '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt}}')" + if [[ "$STATUS_PRESENT" == "true" ]]; then + UPDATE_CONDITION+=' AND #status = :observed' + UPDATE_VALUES="$(jq -c --arg observed "$STATUS" '. + {":observed":{S:$observed}}' <<<"$UPDATE_VALUES")" + else + UPDATE_CONDITION+=' AND attribute_not_exists(#status)' + fi + if [[ "$INSTANCE_ID_PRESENT" == "true" ]]; then + UPDATE_CONDITION+=' AND #instance = :observedInstance' + UPDATE_VALUES="$(jq -c --arg observedInstance "$INSTANCE_ID" '. + {":observedInstance":{S:$observedInstance}}' <<<"$UPDATE_VALUES")" + else + UPDATE_CONDITION+=' AND attribute_not_exists(#instance)' + fi + if [[ "$OBSERVED_STACK_PRESENT" == "true" ]]; then + UPDATE_CONDITION+=' AND #stack = :observedStack' + UPDATE_VALUES="$(jq -c --arg observedStack "$OBSERVED_STACK" '. + {":observedStack":{S:$observedStack}}' <<<"$UPDATE_VALUES")" + else + UPDATE_CONDITION+=' AND attribute_not_exists(#stack)' + fi + if [[ "$OBSERVED_STARTED_AT_PRESENT" == "true" ]]; then + UPDATE_CONDITION+=' AND #startedAt = :observedStartedAt' + UPDATE_VALUES="$(jq -c --arg observedStartedAt "$OBSERVED_STARTED_AT" '. + {":observedStartedAt":{S:$observedStartedAt}}' <<<"$UPDATE_VALUES")" + else + UPDATE_CONDITION+=' AND attribute_not_exists(#startedAt)' + fi + if [[ "$PURGE_LOCK_PRESENT" == "true" ]]; then + UPDATE_CONDITION+=' AND #purge = :observedPurge AND #purgeExpires = :observedPurgeExpires' + UPDATE_VALUES="$(jq -c \ + --arg observedPurge "$PURGE_LOCK" \ + --arg observedPurgeExpires "$PURGE_LOCK_EXPIRES_AT" \ + '. + {":observedPurge":{S:$observedPurge},":observedPurgeExpires":{N:$observedPurgeExpires}}' \ + <<<"$UPDATE_VALUES")" + REMOVE_ACTIONS+=', #purge, #purgeExpires' + else + UPDATE_CONDITION+=' AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires)' + fi + if [[ "$STATUS" != "provisioning" && "$STATUS" != "failed" ]] && \ + is_instance_id "$EARLY_STACK_INSTANCE_ID"; then + SET_ACTIONS+=', #instance = :securedInstance' + UPDATE_VALUES="$(jq -c --arg securedInstance "$EARLY_STACK_INSTANCE_ID" \ + '. + {":securedInstance":{S:$securedInstance}}' <<<"$UPDATE_VALUES")" + fi + if [[ "$STATUS" == "provisioning" || "$STATUS" == "failed" ]]; then + # An unfinished stack is removed below. Do not leave its stale instance + # pointer on the disabled marker that users-enable.sh later consumes. + REMOVE_ACTIONS="${REMOVE_ACTIONS:+$REMOVE_ACTIONS, }#instance" + fi + UPDATE_EXPRESSION="SET $SET_ACTIONS" + if [[ -n "$REMOVE_ACTIONS" ]]; then + UPDATE_EXPRESSION+=" REMOVE $REMOVE_ACTIONS" + fi + if UPDATE_RESULT="$(aws_cli dynamodb update-item \ --table-name "$TABLE_NAME" \ --key "$KEY" \ - --update-expression 'SET #status = :disabled, updatedAt = :updatedAt' \ - --expression-attribute-names '{"#status":"status"}' \ - --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt}}')" >/dev/null -fi + --update-expression "$UPDATE_EXPRESSION" \ + --condition-expression "$UPDATE_CONDITION" \ + --expression-attribute-names "$UPDATE_NAMES" \ + --expression-attribute-values "$UPDATE_VALUES" 2>&1)"; then + STATE_LOCKED=true + break + fi + + if [[ "$UPDATE_RESULT" != *ConditionalCheckFailedException* ]]; then + printf '%s\n' "$UPDATE_RESULT" >&2 + REGISTRY_FAILED=true + break + fi + if [[ "$ATTEMPT" -lt 3 ]]; then + sleep "$ATTEMPT" + fi +done -if [[ "$INSTANCE_ID" =~ ^i-[0-9a-f]+$ ]]; then - aws_cli ec2 stop-instances --instance-ids "$INSTANCE_ID" >/dev/null +if [[ "$STATE_LOCKED" != "true" ]]; then + REGISTRY_FAILED=true fi -if [[ "$STATUS" == "active" || "$STATUS" == "disabled" ]]; then - say "The user is disabled and the runtime is stopping; its disk is preserved" + +STACK_PRESENT=false +STACK_INSTANCE_ID="" +if stack_exists "$EXPECTED_RUNTIME_STACK"; then + STACK_PRESENT=true + if STACK_INSTANCE_ID="$(stack_output_optional "$EXPECTED_RUNTIME_STACK" InstanceId)"; then + secure_instance "$STACK_INSTANCE_ID" + else + CUTOFF_FAILED=true + fi else - say "The user is disabled; there is no active runtime to stop" + stack_status=$? + [[ "$stack_status" -eq 1 ]] || CUTOFF_FAILED=true fi + +SHOULD_DELETE=false +if [[ "$STACK_PRESENT" == "true" && "$REGISTRY_FAILED" == "false" ]]; then + case "$STATUS" in + provisioning | failed) SHOULD_DELETE=true ;; + active | disabled) ;; + *) + if ! is_instance_id "$INSTANCE_ID" && ! is_instance_id "$STACK_INSTANCE_ID"; then + SHOULD_DELETE=true + fi + ;; + esac +fi + +if [[ "$SHOULD_DELETE" == "true" ]]; then + delete_stack "$EXPECTED_RUNTIME_STACK" + ACTION=deleted +fi + +if [[ "$REVOCATION_FAILED" == "true" || "$QUIESCE_FAILED" == "true" || "$REGISTRY_FAILED" == "true" || "$CUTOFF_FAILED" == "true" ]]; then + fail "The runtime cutoff was attempted, but sign-out, provisioning shutdown, or registry locking did not fully complete; run this command again" +fi + +case "$ACTION" in + deleted) + say "The user is disabled and the unfinished runtime was removed" + ;; + stopped) + say "The user is disabled and runtime access is stopped; its disk is preserved" + ;; + *) + if [[ "$REGISTRY_ITEM_PRESENT" == "false" && "$STACK_PRESENT" == "false" ]]; then + say "The user is disabled; no assigned runtime exists" + else + say "The user is disabled; no running instance was found, and any managed disk is preserved" + fi + ;; +esac diff --git a/scripts/users-enable.sh b/scripts/users-enable.sh index ea5a5ce..abc331f 100755 --- a/scripts/users-enable.sh +++ b/scripts/users-enable.sh @@ -6,11 +6,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" require_config -EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" +EMAIL="$(normalize_email "$(prompt_email "$(read_option --email "$@" || true)")")" [[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" -IFS=$'\t' read -r COGNITO_USERNAME USER_SUB < <(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL") +COGNITO_USER="$(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL")" +IFS=$'\t' read -r COGNITO_USERNAME USER_SUB <<<"$COGNITO_USER" +is_user_subject "$USER_SUB" || fail "Cognito returned an invalid federated subject" +EXPECTED_RUNTIME_STACK="$(runtime_stack_name_for_subject "$USER_SUB")" KEY="$(jq -cn --arg userSub "$USER_SUB" '{userSub:{S:$userSub}}')" RUNTIME="$(aws_cli dynamodb get-item \ @@ -18,36 +21,214 @@ RUNTIME="$(aws_cli dynamodb get-item \ --key "$KEY" \ --consistent-read \ --output json)" -INSTANCE_ID="$(jq -r '.Item.instanceId.S // ""' <<<"$RUNTIME")" +REGISTRY_ITEM_PRESENT=true +if [[ "$(jq '(.Item // {}) | length' <<<"$RUNTIME")" == "0" ]]; then + REGISTRY_ITEM_PRESENT=false +fi +INSTANCE_ID_PRESENT=false +INSTANCE_ID="" +if [[ "$REGISTRY_ITEM_PRESENT" == "true" ]] && \ + jq -e '.Item | has("instanceId")' <<<"$RUNTIME" >/dev/null; then + INSTANCE_ID_PRESENT=true + INSTANCE_ID="$(jq -er ' + .Item.instanceId | + if type == "object" and (.S | type == "string") then .S + else error("invalid instanceId") end + ' <<<"$RUNTIME")" || fail "The runtime registry contains an invalid instance ID" + is_instance_id "$INSTANCE_ID" || fail "The runtime registry contains an invalid instance ID" +fi STATUS="$(jq -r '.Item.status.S // ""' <<<"$RUNTIME")" -if [[ -z "$STATUS" || "$STATUS" == "failed" ]]; then - aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" - say "The user is enabled and can create or retry an environment" +if [[ "$REGISTRY_ITEM_PRESENT" == "false" ]]; then + UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + ENABLE_ITEM="$(jq -cn \ + --arg userSub "$USER_SUB" \ + --arg email "$EMAIL" \ + --arg stack "$EXPECTED_RUNTIME_STACK" \ + --arg updatedAt "$UPDATED_AT" ' + {userSub:{S:$userSub},email:{S:$email},runtimeStackName:{S:$stack},status:{S:"failed"},updatedAt:{S:$updatedAt}} + ')" + if ! aws_cli dynamodb put-item \ + --table-name "$TABLE_NAME" \ + --item "$ENABLE_ITEM" \ + --condition-expression 'attribute_not_exists(userSub)' >/dev/null; then + fail "The runtime changed while it was being enabled; sign-in remains disabled" + fi + REGISTRY_ITEM_PRESENT=true + STATUS=failed +fi + +ENABLE_LEASE_SECONDS=300 +ENABLE_LEASE_NOW="$(date +%s)" +[[ "$ENABLE_LEASE_NOW" =~ ^[0-9]+$ ]] || fail "The local clock did not return a valid timestamp" +ENABLE_LEASE_EXPIRES_AT="$((ENABLE_LEASE_NOW + ENABLE_LEASE_SECONDS))" +ENABLE_TOKEN="${ENABLE_LEASE_NOW}-$$-${RANDOM}-${RANDOM}-${RANDOM}-${RANDOM}" + +guard_enable_status() { + local expected_status="$1" + local phase="$2" + local updated_at + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ "$phase" == "claim" ]]; then + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET updatedAt = :updatedAt, #enable = :enable, #enableExpires = :enableExpires' \ + --condition-expression '#status = :expected AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires) AND ((attribute_not_exists(#enable) AND attribute_not_exists(#enableExpires)) OR (attribute_exists(#enable) AND attribute_exists(#enableExpires) AND #enableExpires < :now))' \ + --expression-attribute-names '{"#status":"status","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' \ + --expression-attribute-values "$(jq -cn \ + --arg expected "$expected_status" \ + --arg updatedAt "$updated_at" \ + --arg enable "$ENABLE_TOKEN" \ + --arg enableExpires "$ENABLE_LEASE_EXPIRES_AT" \ + --arg now "$ENABLE_LEASE_NOW" \ + '{":expected":{S:$expected},":updatedAt":{S:$updatedAt},":enable":{S:$enable},":enableExpires":{N:$enableExpires},":now":{N:$now}}')" >/dev/null + else + [[ "$phase" == "complete" ]] || fail "Internal enable phase is invalid" + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET updatedAt = :updatedAt REMOVE #enable, #enableExpires' \ + --condition-expression '#status = :expected AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires) AND #enable = :enable AND #enableExpires = :enableExpires' \ + --expression-attribute-names '{"#status":"status","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' \ + --expression-attribute-values "$(jq -cn \ + --arg expected "$expected_status" \ + --arg updatedAt "$updated_at" \ + --arg enable "$ENABLE_TOKEN" \ + --arg enableExpires "$ENABLE_LEASE_EXPIRES_AT" \ + '{":expected":{S:$expected},":updatedAt":{S:$updatedAt},":enable":{S:$enable},":enableExpires":{N:$enableExpires}}')" >/dev/null + fi +} + +guard_disabled_without_instance() { + local phase="$1" + local updated_at + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ "$phase" == "claim" ]]; then + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET updatedAt = :updatedAt, #enable = :enable, #enableExpires = :enableExpires' \ + --condition-expression '#status = :disabled AND attribute_not_exists(instanceId) AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires) AND ((attribute_not_exists(#enable) AND attribute_not_exists(#enableExpires)) OR (attribute_exists(#enable) AND attribute_exists(#enableExpires) AND #enableExpires < :now))' \ + --expression-attribute-names '{"#status":"status","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' \ + --expression-attribute-values "$(jq -cn \ + --arg updatedAt "$updated_at" \ + --arg enable "$ENABLE_TOKEN" \ + --arg enableExpires "$ENABLE_LEASE_EXPIRES_AT" \ + --arg now "$ENABLE_LEASE_NOW" \ + '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt},":enable":{S:$enable},":enableExpires":{N:$enableExpires},":now":{N:$now}}')" >/dev/null + else + [[ "$phase" == "complete" ]] || fail "Internal enable phase is invalid" + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET #status = :failed, updatedAt = :updatedAt REMOVE #enable, #enableExpires' \ + --condition-expression '#status = :disabled AND attribute_not_exists(instanceId) AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires) AND #enable = :enable AND #enableExpires = :enableExpires' \ + --expression-attribute-names '{"#status":"status","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' \ + --expression-attribute-values "$(jq -cn \ + --arg updatedAt "$updated_at" \ + --arg enable "$ENABLE_TOKEN" \ + --arg enableExpires "$ENABLE_LEASE_EXPIRES_AT" \ + '{":failed":{S:"failed"},":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt},":enable":{S:$enable},":enableExpires":{N:$enableExpires}}')" >/dev/null + fi +} + +guard_disabled_with_instance() { + local phase="$1" + local updated_at + updated_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ "$phase" == "claim" ]]; then + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET updatedAt = :updatedAt, #enable = :enable, #enableExpires = :enableExpires' \ + --condition-expression '#status = :disabled AND #instance = :observedInstance AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires) AND ((attribute_not_exists(#enable) AND attribute_not_exists(#enableExpires)) OR (attribute_exists(#enable) AND attribute_exists(#enableExpires) AND #enableExpires < :now))' \ + --expression-attribute-names '{"#status":"status","#instance":"instanceId","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' \ + --expression-attribute-values "$(jq -cn \ + --arg updatedAt "$updated_at" \ + --arg observedInstance "$INSTANCE_ID" \ + --arg enable "$ENABLE_TOKEN" \ + --arg enableExpires "$ENABLE_LEASE_EXPIRES_AT" \ + --arg now "$ENABLE_LEASE_NOW" \ + '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt},":observedInstance":{S:$observedInstance},":enable":{S:$enable},":enableExpires":{N:$enableExpires},":now":{N:$now}}')" >/dev/null + else + [[ "$phase" == "complete" ]] || fail "Internal enable phase is invalid" + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET #status = :active, updatedAt = :updatedAt REMOVE #enable, #enableExpires' \ + --condition-expression '#status = :disabled AND #instance = :observedInstance AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires) AND #enable = :enable AND #enableExpires = :enableExpires' \ + --expression-attribute-names '{"#status":"status","#instance":"instanceId","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt","#enable":"enableToken","#enableExpires":"enableTokenExpiresAt"}' \ + --expression-attribute-values "$(jq -cn \ + --arg updatedAt "$updated_at" \ + --arg observedInstance "$INSTANCE_ID" \ + --arg enable "$ENABLE_TOKEN" \ + --arg enableExpires "$ENABLE_LEASE_EXPIRES_AT" \ + '{":active":{S:"active"},":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt},":observedInstance":{S:$observedInstance},":enable":{S:$enable},":enableExpires":{N:$enableExpires}}')" >/dev/null + fi +} + +disable_cognito_after_failed_enable() { + if ! aws_cli cognito-idp admin-disable-user \ + --user-pool-id "$USER_POOL_ID" \ + --username "$COGNITO_USERNAME"; then + printf 'WARNING: Cognito sign-in could not be disabled again; check the user before retrying.\n' >&2 + fi +} + +enable_existing_status() { + local expected_status="$1" + local success_message="$2" + guard_enable_status "$expected_status" claim || \ + fail "A permanent purge, another enable, or another runtime change is in progress; no change was made" + if ! aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME"; then + fail "Cognito did not confirm that company sign-in was enabled" + fi + if ! guard_enable_status "$expected_status" complete; then + disable_cognito_after_failed_enable + fail "The runtime changed while it was being enabled; check Cognito sign-in before retrying" + fi + say "$success_message" +} + +if [[ "$STATUS" == "failed" ]]; then + enable_existing_status "failed" "The user is enabled and can create or retry an environment" exit fi if [[ "$STATUS" == "active" ]]; then - aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" - say "The user is enabled and the runtime is already active" + enable_existing_status "active" "The user is enabled and the runtime is already active" exit fi [[ "$STATUS" == "disabled" ]] || fail "That runtime is not ready to be enabled" -[[ "$INSTANCE_ID" =~ ^i-[0-9a-f]+$ ]] || fail "The disabled runtime does not have a valid instance ID" +if [[ "$INSTANCE_ID_PRESENT" == "false" ]]; then + guard_disabled_without_instance claim || \ + fail "A permanent purge, another enable, or another runtime change is in progress; no change was made" + say "Restoring sign-in so the user can create a runtime" + aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" + if ! guard_disabled_without_instance complete; then + disable_cognito_after_failed_enable + fail "The runtime changed while it was being enabled; check Cognito sign-in before retrying" + fi + say "The user is enabled and can create or retry an environment" + exit +fi + +guard_disabled_with_instance claim || \ + fail "A permanent purge, another enable, or another runtime change is in progress; no change was made" say "Starting the preserved runtime before restoring sign-in" aws_cli ec2 start-instances --instance-ids "$INSTANCE_ID" >/dev/null -aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" -UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -if ! aws_cli dynamodb update-item \ - --table-name "$TABLE_NAME" \ - --key "$KEY" \ - --update-expression 'SET #status = :active, updatedAt = :updatedAt' \ - --condition-expression '#status = :disabled' \ - --expression-attribute-names '{"#status":"status"}' \ - --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":active":{S:"active"},":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt}}')" >/dev/null; then - aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" || true - fail "The runtime changed while it was being enabled; sign-in remains disabled" +if ! aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME"; then + cut_off_runtime_instance "$INSTANCE_ID" || \ + printf 'WARNING: The preserved runtime could not be stopped after the enable attempt failed.\n' >&2 + fail "Cognito did not confirm that company sign-in was enabled" +fi +if ! guard_disabled_with_instance complete; then + disable_cognito_after_failed_enable + cut_off_runtime_instance "$INSTANCE_ID" || \ + printf 'WARNING: The preserved runtime could not be stopped after the enable attempt failed.\n' >&2 + fail "The runtime changed while it was being enabled; check Cognito sign-in before retrying" fi say "The user is enabled and the preserved runtime is starting" diff --git a/scripts/users-purge.sh b/scripts/users-purge.sh index 598a42d..4055798 100755 --- a/scripts/users-purge.sh +++ b/scripts/users-purge.sh @@ -9,25 +9,318 @@ source "$SCRIPT_DIR/lib/stacks.sh" require_config [[ "$(read_option --confirm "$@" || true)" == "DELETE" ]] || fail "Purging requires --confirm DELETE" -EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" +EMAIL="$(normalize_email "$(prompt_email "$(read_option --email "$@" || true)")")" [[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" +say "Revoking live access before resolving permanent deletion targets" +"$SCRIPT_DIR/users-disable.sh" --email "$EMAIL" USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" -IFS=$'\t' read -r COGNITO_USERNAME USER_SUB < <(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL") - +COGNITO_USER="$(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL")" +IFS=$'\t' read -r COGNITO_USERNAME USER_SUB <<<"$COGNITO_USER" +is_user_subject "$USER_SUB" || fail "Cognito returned an invalid federated subject" +EXPECTED_RUNTIME_STACK="$(runtime_stack_name_for_subject "$USER_SUB")" +DEPLOYMENT="$(deployment_name)" KEY="$(jq -cn --arg userSub "$USER_SUB" '{userSub:{S:$userSub}}')" -RUNTIME_STACK="$(aws_cli dynamodb get-item \ + +RUNTIME="$(aws_cli dynamodb get-item \ --table-name "$TABLE_NAME" \ --key "$KEY" \ --consistent-read \ - --query 'Item.runtimeStackName.S' \ - --output text)" + --output json)" +REGISTRY_ITEM_PRESENT=false +RECORDED_STACK_PRESENT=false +RECORDED_INSTANCE_ID_PRESENT=false +RECORDED_STACK="" +RECORDED_INSTANCE_ID="" +RECORDED_STATUS="" +RECORDED_UPDATED_AT="" +if [[ "$(jq '(.Item // {}) | length' <<<"$RUNTIME")" != "0" ]]; then + REGISTRY_ITEM_PRESENT=true + RECORDED_STATUS="$(jq -er ' + .Item.status | + if type == "object" and (.S | type == "string") then .S + else error("invalid status") end + ' <<<"$RUNTIME")" || fail "The runtime registry contains an invalid status; nothing was deleted" + [[ "$RECORDED_STATUS" == "disabled" ]] || \ + fail "The runtime changed after access was revoked; nothing was deleted" + RECORDED_UPDATED_AT="$(jq -er ' + .Item.updatedAt | + if type == "object" and (.S | type == "string") then .S + else error("invalid updatedAt") end + ' <<<"$RUNTIME")" || fail "The runtime registry contains an invalid update time; nothing was deleted" + if jq -e '.Item | has("runtimeStackName")' <<<"$RUNTIME" >/dev/null; then + RECORDED_STACK_PRESENT=true + RECORDED_STACK="$(jq -er ' + .Item.runtimeStackName | + if type == "object" and (.S | type == "string") then .S + else error("invalid runtimeStackName") end + ' <<<"$RUNTIME")" || fail "The runtime registry contains an invalid stack name; nothing was deleted" + fi + if jq -e '.Item | has("instanceId")' <<<"$RUNTIME" >/dev/null; then + RECORDED_INSTANCE_ID_PRESENT=true + RECORDED_INSTANCE_ID="$(jq -er ' + .Item.instanceId | + if type == "object" and (.S | type == "string") then .S + else error("invalid instanceId") end + ' <<<"$RUNTIME")" || fail "The runtime registry contains an invalid instance ID; nothing was deleted" + is_instance_id "$RECORDED_INSTANCE_ID" || \ + fail "The runtime registry contains an invalid instance ID; nothing was deleted" + fi +fi + +# Find every stack that CloudFormation tags for this deployment and subject. +# This catches an older stack name without trusting a registry value as a +# deletion target. +ALL_STACKS="$(aws_cli cloudformation describe-stacks --output json)" +MATCHING_STACKS="$(jq -cer \ + --arg deployment "$DEPLOYMENT" \ + --arg subject "$USER_SUB" ' + [.Stacks[]? | select( + any(.Tags[]?; .Key == "AgentFormationDeployment" and .Value == $deployment) and + any(.Tags[]?; .Key == "AgentFormationUserSubject" and .Value == $subject) + ) | .StackName] | unique + ' <<<"$ALL_STACKS")" + +if stack_exists "$EXPECTED_RUNTIME_STACK"; then + jq -e --arg stack "$EXPECTED_RUNTIME_STACK" 'index($stack) != null' <<<"$MATCHING_STACKS" >/dev/null || \ + fail "The expected runtime stack is not tagged for this user; nothing was deleted" +else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || exit "$stack_status" +fi + +if [[ "$RECORDED_STACK_PRESENT" == "true" && -n "$RECORDED_STACK" ]]; then + if [[ "$RECORDED_STACK" != "$EXPECTED_RUNTIME_STACK" ]] && \ + ! jq -e --arg stack "$RECORDED_STACK" 'index($stack) != null' <<<"$MATCHING_STACKS" >/dev/null; then + fail "The registry names a runtime stack that is not tagged for this user; nothing was deleted" + fi +fi + +while IFS= read -r runtime_stack; do + [[ -n "$runtime_stack" ]] || continue + [[ "$runtime_stack" == "$DEPLOYMENT-runtime-"* ]] || \ + fail "A subject-tagged stack falls outside this deployment's runtime namespace; nothing was deleted" +done <<<"$(jq -r '.[]' <<<"$MATCHING_STACKS")" + +STACK_INSTANCE_IDS='[]' +while IFS= read -r runtime_stack; do + [[ -n "$runtime_stack" ]] || continue + if stack_instance_id="$(stack_output_optional "$runtime_stack" InstanceId)"; then + : + else + fail "A runtime stack could not be inspected; nothing was deleted" + fi + if [[ -n "$stack_instance_id" ]]; then + is_instance_id "$stack_instance_id" || fail "A runtime stack returned an invalid instance ID; nothing was deleted" + STACK_INSTANCE_IDS="$(jq -c --arg instance "$stack_instance_id" '. + [$instance] | unique' <<<"$STACK_INSTANCE_IDS")" + fi +done <<<"$(jq -r '.[]' <<<"$MATCHING_STACKS")" + +if [[ "$RECORDED_INSTANCE_ID_PRESENT" == "true" ]] && \ + ! jq -e --arg instance "$RECORDED_INSTANCE_ID" 'index($instance) != null' <<<"$STACK_INSTANCE_IDS" >/dev/null; then + if ! RECORDED_INSTANCE="$(aws_cli ec2 describe-instances \ + --instance-ids "$RECORDED_INSTANCE_ID" \ + --output json 2>&1)"; then + if [[ "$RECORDED_INSTANCE" != *InvalidInstanceID.NotFound* ]]; then + printf '%s\n' "$RECORDED_INSTANCE" >&2 + fail "The registry instance could not be verified; nothing was deleted" + fi + elif [[ "$(jq -r '.Reservations[0].Instances[0].State.Name // "None"' <<<"$RECORDED_INSTANCE")" != "terminated" ]]; then + fail "The registry names a live instance outside every managed runtime stack; nothing was deleted" + fi +fi +INSTANCE_IDS="$STACK_INSTANCE_IDS" + +say "Confirming every subject-bound runtime is stopped before deletion" +while IFS= read -r instance_id; do + [[ -n "$instance_id" ]] || continue + cut_off_runtime_instance "$instance_id" || fail "The assigned runtime could not be stopped; nothing else was deleted" +done <<<"$(jq -r '.[]' <<<"$INSTANCE_IDS")" +stop_provisioning_executions "$USER_SUB" || fail "Provisioning could not be stopped; no runtime pointers were deleted" + +# Lock the exact disabled registry record before any irreversible deletion. +# users-enable.sh refuses every enable path while this lock exists. A later +# users-disable.sh can remove it only after the two-hour recovery deadline. +PURGE_LOCK="$(date -u +%Y%m%dT%H%M%SZ)-$$" +PURGE_LOCK_EXPIRES_AT="$(( $(date +%s) + 2 * 60 * 60 ))" +if [[ "$REGISTRY_ITEM_PRESENT" == "true" ]]; then + LOCK_VALUES="$(jq -cn \ + --arg updatedAt "$RECORDED_UPDATED_AT" \ + --arg purge "$PURGE_LOCK" \ + --arg purgeExpires "$PURGE_LOCK_EXPIRES_AT" \ + '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt},":purge":{S:$purge},":purgeExpires":{N:$purgeExpires}}')" + LOCK_CONDITION='#status = :disabled AND #updatedAt = :updatedAt AND attribute_not_exists(#purge) AND attribute_not_exists(#purgeExpires)' + if [[ "$RECORDED_STACK_PRESENT" == "true" ]]; then + LOCK_CONDITION+=' AND #stack = :stack' + LOCK_VALUES="$(jq -c --arg stack "$RECORDED_STACK" '. + {":stack":{S:$stack}}' <<<"$LOCK_VALUES")" + else + LOCK_CONDITION+=' AND attribute_not_exists(#stack)' + fi + if [[ "$RECORDED_INSTANCE_ID_PRESENT" == "true" ]]; then + LOCK_CONDITION+=' AND #instance = :instance' + LOCK_VALUES="$(jq -c --arg instance "$RECORDED_INSTANCE_ID" '. + {":instance":{S:$instance}}' <<<"$LOCK_VALUES")" + else + LOCK_CONDITION+=' AND attribute_not_exists(#instance)' + fi + if ! aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET #purge = :purge, #purgeExpires = :purgeExpires' \ + --condition-expression "$LOCK_CONDITION" \ + --expression-attribute-names '{"#status":"status","#updatedAt":"updatedAt","#stack":"runtimeStackName","#instance":"instanceId","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt"}' \ + --expression-attribute-values "$LOCK_VALUES" >/dev/null; then + fail "The runtime changed before purge could lock it; nothing was deleted" + fi +else + RECORDED_STATUS=disabled + RECORDED_UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + RECORDED_STACK_PRESENT=true + RECORDED_STACK="$EXPECTED_RUNTIME_STACK" + TOMBSTONE_ITEM="$(jq -cn \ + --arg userSub "$USER_SUB" \ + --arg email "$EMAIL" \ + --arg stack "$RECORDED_STACK" \ + --arg updatedAt "$RECORDED_UPDATED_AT" \ + --arg purge "$PURGE_LOCK" \ + --arg purgeExpires "$PURGE_LOCK_EXPIRES_AT" ' + {userSub:{S:$userSub},email:{S:$email},runtimeStackName:{S:$stack},status:{S:"disabled"},updatedAt:{S:$updatedAt},purgeLock:{S:$purge},purgeLockExpiresAt:{N:$purgeExpires}} + ')" + if ! aws_cli dynamodb put-item \ + --table-name "$TABLE_NAME" \ + --item "$TOMBSTONE_ITEM" \ + --condition-expression 'attribute_not_exists(userSub)' >/dev/null; then + fail "The runtime changed before purge could lock it; nothing was deleted" + fi + REGISTRY_ITEM_PRESENT=true +fi -aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" -stop_provisioning_executions "$USER_SUB" -if [[ "$RUNTIME_STACK" == "$(deployment_name)-runtime-"* ]]; then - delete_stack "$RUNTIME_STACK" +read_cognito_state() { + aws_cli cognito-idp admin-get-user \ + --user-pool-id "$USER_POOL_ID" \ + --username "$COGNITO_USERNAME" \ + --output json +} + +cognito_state_is_disabled() { + local cognito_state="$1" + jq -e \ + --arg username "$COGNITO_USERNAME" \ + --arg subject "$USER_SUB" ' + .Username == $username and + .Enabled == false and + any(.UserAttributes[]?; .Name == "sub" and .Value == $subject) + ' <<<"$cognito_state" >/dev/null +} + +release_purge_lock() { + local release_values + release_values="$(jq -cn \ + --arg purge "$PURGE_LOCK" \ + --arg purgeExpires "$PURGE_LOCK_EXPIRES_AT" \ + '{":disabled":{S:"disabled"},":purge":{S:$purge},":purgeExpires":{N:$purgeExpires}}')" + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'REMOVE #purge, #purgeExpires' \ + --condition-expression '#status = :disabled AND #purge = :purge AND #purgeExpires = :purgeExpires' \ + --expression-attribute-names '{"#status":"status","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt"}' \ + --expression-attribute-values "$release_values" >/dev/null +} + +if ! COGNITO_STATE="$(read_cognito_state)"; then + fail "Company sign-in status could not be confirmed before purge; the purge lock was preserved" +fi +if ! cognito_state_is_disabled "$COGNITO_STATE"; then + if ! release_purge_lock; then + fail "Company sign-in changed before purge, and the purge lock could not be released safely" + fi + fail "Company sign-in was re-enabled before purge; nothing was deleted and the purge lock was released" +fi + +REMOVED_RUNTIME=false +while IFS= read -r runtime_stack; do + [[ -n "$runtime_stack" ]] || continue + if delete_stack_if_present "$runtime_stack"; then + REMOVED_RUNTIME=true + else + delete_status=$? + [[ "$delete_status" -eq 1 ]] || exit "$delete_status" + fi +done <<<"$(jq -r '.[]' <<<"$MATCHING_STACKS")" + +# With Cognito disabled and matching executions quiesced, no runtime stack may +# reappear between deletion and removal of the registry pointer. +REMAINING_STACKS="$(aws_cli cloudformation describe-stacks --output json | jq -cer \ + --arg deployment "$DEPLOYMENT" \ + --arg subject "$USER_SUB" ' + [.Stacks[]? | select( + any(.Tags[]?; .Key == "AgentFormationDeployment" and .Value == $deployment) and + any(.Tags[]?; .Key == "AgentFormationUserSubject" and .Value == $subject) + )] | length + ')" +[[ "$REMAINING_STACKS" -eq 0 ]] || fail "A runtime stack reappeared during purge; the registry and user were preserved" +if stack_exists "$EXPECTED_RUNTIME_STACK"; then + fail "The expected runtime stack reappeared during purge; the registry and user were preserved" +else + stack_status=$? + [[ "$stack_status" -eq 1 ]] || exit "$stack_status" +fi + +if ! COGNITO_STATE="$(read_cognito_state)" || ! cognito_state_is_disabled "$COGNITO_STATE"; then + fail "Company sign-in changed during purge; the deleted runtime and locked registry record were preserved" +fi + +# Delete the federated profile while the purge-locked registry record still +# exists. This closes the only cross-service gap where users-enable.sh could +# otherwise recreate a registry marker after its deletion but before Cognito +# deletion. If the final conditional registry delete fails, the locked record +# remains fail-closed for an identity that no longer exists. +if ! aws_cli cognito-idp admin-delete-user \ + --user-pool-id "$USER_POOL_ID" \ + --username "$COGNITO_USERNAME"; then + fail "Cognito did not confirm permanent profile deletion; the locked registry record was preserved" +fi + +if [[ "$REGISTRY_ITEM_PRESENT" == "true" ]]; then + PURGED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + TOMBSTONE_EXPIRES_AT="$(( $(date +%s) + 2 * 60 * 60 ))" + TOMBSTONE_VALUES="$(jq -cn --arg updatedAt "$RECORDED_UPDATED_AT" \ + --arg purge "$PURGE_LOCK" \ + --arg purgeExpires "$PURGE_LOCK_EXPIRES_AT" \ + '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt},":purge":{S:$purge},":purgeExpires":{N:$purgeExpires}}')" + TOMBSTONE_CONDITION='#status = :disabled AND #updatedAt = :updatedAt AND #purge = :purge AND #purgeExpires = :purgeExpires' + if [[ "$RECORDED_STACK_PRESENT" == "true" ]]; then + TOMBSTONE_CONDITION+=' AND #stack = :stack' + TOMBSTONE_VALUES="$(jq -c --arg stack "$RECORDED_STACK" '. + {":stack":{S:$stack}}' <<<"$TOMBSTONE_VALUES")" + else + TOMBSTONE_CONDITION+=' AND attribute_not_exists(#stack)' + fi + if [[ "$RECORDED_INSTANCE_ID_PRESENT" == "true" ]]; then + TOMBSTONE_CONDITION+=' AND #instance = :instance' + TOMBSTONE_VALUES="$(jq -c --arg instance "$RECORDED_INSTANCE_ID" '. + {":instance":{S:$instance}}' <<<"$TOMBSTONE_VALUES")" + else + TOMBSTONE_CONDITION+=' AND attribute_not_exists(#instance)' + fi + PURGED_ITEM="$(jq -cn \ + --arg userSub "$USER_SUB" \ + --arg updatedAt "$PURGED_AT" \ + --arg expiresAt "$TOMBSTONE_EXPIRES_AT" \ + '{userSub:{S:$userSub},status:{S:"purged"},updatedAt:{S:$updatedAt},expiresAt:{N:$expiresAt}}')" + TOMBSTONE_ARGUMENTS=(dynamodb put-item \ + --table-name "$TABLE_NAME" \ + --item "$PURGED_ITEM" \ + --condition-expression "$TOMBSTONE_CONDITION" \ + --expression-attribute-names '{"#status":"status","#updatedAt":"updatedAt","#stack":"runtimeStackName","#instance":"instanceId","#purge":"purgeLock","#purgeExpires":"purgeLockExpiresAt"}') + if [[ "$(jq 'length' <<<"$TOMBSTONE_VALUES")" -gt 0 ]]; then + TOMBSTONE_ARGUMENTS+=(--expression-attribute-values "$TOMBSTONE_VALUES") + fi + if ! TOMBSTONE_RESULT="$(aws_cli "${TOMBSTONE_ARGUMENTS[@]}" 2>&1)"; then + printf '%s\n' "$TOMBSTONE_RESULT" >&2 + fail "The profile and runtime were deleted, but the locked registry record changed before its privacy tombstone could be written; inspect it before cleanup" + fi +fi +if [[ "$REMOVED_RUNTIME" == "true" ]]; then + say "The user and all assigned runtime stacks, including their persistent disks, were deleted; a short-lived access tombstone remains" +else + say "The user was deleted; no managed runtime stack was present, and a short-lived access tombstone remains" fi -aws_cli dynamodb delete-item --table-name "$TABLE_NAME" --key "$KEY" -aws_cli cognito-idp admin-delete-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" -say "The user, runtime, and persistent runtime disk were deleted" diff --git a/templates/foundation.yaml b/templates/foundation.yaml index c7a968b..9099477 100644 --- a/templates/foundation.yaml +++ b/templates/foundation.yaml @@ -5,18 +5,26 @@ Parameters: DeploymentName: Type: String Default: agentformation - AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' + AllowedPattern: '^(?!.*-runtime-)[a-z][a-z0-9-]{2,31}$' CognitoDomainPrefix: Type: String AllowedPattern: '^[a-z0-9][a-z0-9-]{2,62}$' InitialCallbackUrl: Type: String - Default: http://localhost:3000/api/auth/callback/cognito - AllowedPattern: '^https?://[^ ]+$' + Default: https://localhost/api/auth/callback/cognito + AllowedPattern: '^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?/api/auth/callback/cognito$' InitialLogoutUrl: Type: String - Default: http://localhost:3000/ - AllowedPattern: '^https?://[^ ]+$' + Default: https://localhost/ + AllowedPattern: '^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?/$' + UploadAllowedOrigin: + Type: String + Default: https://localhost + AllowedPattern: '^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$' + ConfigureIdentityCenterClient: + Type: String + Default: 'false' + AllowedValues: ['true', 'false'] ConfigureCaseInsensitiveUsernames: Type: String Default: 'true' @@ -24,6 +32,7 @@ Parameters: Conditions: UseCaseInsensitiveUsernames: !Equals [!Ref ConfigureCaseInsensitiveUsernames, 'true'] + IdentityCenterClientEnabled: !Equals [!Ref ConfigureIdentityCenterClient, 'true'] Resources: UserPool: @@ -50,6 +59,7 @@ Resources: UserPoolClient: Type: AWS::Cognito::UserPoolClient + Condition: IdentityCenterClientEnabled Properties: ClientName: !Sub '${AWS::StackName}-web' UserPoolId: !Ref UserPool @@ -58,6 +68,7 @@ Resources: AllowedOAuthFlowsUserPoolClient: true AllowedOAuthFlows: [code] AllowedOAuthScopes: [openid, email, profile] + SupportedIdentityProviders: [IdentityCenter] CallbackURLs: [!Ref InitialCallbackUrl] LogoutURLs: [!Ref InitialLogoutUrl] PreventUserExistenceErrors: ENABLED @@ -89,6 +100,32 @@ Resources: PointInTimeRecoveryEnabled: true SSESpecification: SSEEnabled: true + TimeToLiveSpecification: + AttributeName: expiresAt + Enabled: true + Tags: + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + + ControlState: + Type: AWS::DynamoDB::Table + Metadata: + checkov: + skip: + - id: CKV_AWS_119 + comment: This table contains short-lived counters and claims only; AWS-managed encryption avoids a customer-key lifecycle for disposable coordination data. + DeletionPolicy: Delete + UpdateReplacePolicy: Delete + Properties: + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - { AttributeName: controlKey, AttributeType: S } + KeySchema: + - { AttributeName: controlKey, KeyType: HASH } + SSESpecification: + SSEEnabled: true + TimeToLiveSpecification: + AttributeName: expiresAt + Enabled: true Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } @@ -104,6 +141,13 @@ Resources: DeletionPolicy: Delete UpdateReplacePolicy: Delete Properties: + CorsConfiguration: + CorsRules: + - AllowedHeaders: [content-type] + AllowedMethods: [POST] + AllowedOrigins: [!Ref UploadAllowedOrigin] + ExposedHeaders: [ETag] + MaxAge: 300 BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: { SSEAlgorithm: AES256 } @@ -121,6 +165,13 @@ Resources: Prefix: uploads/ ExpirationInDays: 1 AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 } + - Id: ExpireOldProvisioningTemplates + Status: Enabled + Prefix: provisioning/ + TagFilters: + - Key: agentformation-lifecycle + Value: superseded + ExpirationInDays: 30 Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } @@ -220,10 +271,186 @@ Resources: Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + UploadDeliveryDocument: + Type: AWS::SSM::Document + Properties: + Name: !Sub '${AWS::StackName}-upload-delivery' + DocumentType: Command + UpdateMethod: NewVersion + Content: + schemaVersion: '2.2' + description: Deliver one sealed upload to its assigned AgentFormation runtime. + parameters: + UploadBucket: + type: String + allowedPattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$' + interpolationType: ENV_VAR + UserSubject: + type: String + allowedPattern: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' + interpolationType: ENV_VAR + UploadId: + type: String + allowedPattern: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' + interpolationType: ENV_VAR + Filename: + type: String + allowedPattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$' + interpolationType: ENV_VAR + TmuxSession: + type: String + allowedPattern: '^[A-Za-z0-9_-]{1,32}$' + interpolationType: ENV_VAR + FileSize: + type: String + allowedPattern: '^[1-9][0-9]{0,7}$' + interpolationType: ENV_VAR + mainSteps: + - action: aws:runShellScript + name: deliverUpload + inputs: + timeoutSeconds: '60' + runCommand: + - | + exec /bin/bash -euo pipefail <<'AGENTFORMATION_UPLOAD' + : "${SSM_UploadBucket:?missing UploadBucket}" + : "${SSM_UserSubject:?missing UserSubject}" + : "${SSM_UploadId:?missing UploadId}" + : "${SSM_Filename:?missing Filename}" + : "${SSM_TmuxSession:?missing TmuxSession}" + : "${SSM_FileSize:?missing FileSize}" + /usr/local/bin/node - /workspace/.uploads /home/agentformation <<'NODE' + const fs = require("node:fs"); + const [uploadsDirectory, runtimeHome] = process.argv.slice(2); + try { + fs.mkdirSync(uploadsDirectory, { mode: 0o700 }); + } catch (error) { + if (error?.code !== "EEXIST") throw error; + } + const directory = fs.openSync( + uploadsDirectory, + fs.constants.O_RDONLY | + fs.constants.O_DIRECTORY | + fs.constants.O_NOFOLLOW, + ); + try { + const runtimeUser = fs.statSync(runtimeHome); + fs.fchownSync(directory, runtimeUser.uid, runtimeUser.gid); + fs.fchmodSync(directory, 0o700); + } finally { + fs.closeSync(directory); + } + NODE + runuser --user agentformation -- /bin/bash -euo pipefail -c ' + upload_bucket="$1" + user_subject="$2" + upload_id="$3" + filename="$4" + tmux_session="$5" + file_size="$6" + destination_directory="/workspace/.uploads/${upload_id}" + destination="${destination_directory}/${filename}" + object_uri="s3://${upload_bucket}/uploads/${user_subject}/${upload_id}/sealed/${filename}" + /usr/bin/tmux has-session -t "$tmux_session" + install -d -m 0700 -- "$destination_directory" + partial_file="$(mktemp --tmpdir="$destination_directory" ".${filename}.XXXXXX.part")" + trap '\''rm -f -- "$partial_file"'\'' EXIT + chmod 0600 -- "$partial_file" + env HOME=/home/agentformation /usr/local/bin/aws s3 cp "$object_uri" "$partial_file" --only-show-errors + test "$(stat -c %s -- "$partial_file")" -eq "$file_size" + mv -f -- "$partial_file" "$destination" + trap - EXIT + ' agentformation "$SSM_UploadBucket" "$SSM_UserSubject" "$SSM_UploadId" "$SSM_Filename" "$SSM_TmuxSession" "$SSM_FileSize" + AGENTFORMATION_UPLOAD + Tags: + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + + OAuthRelayDocument: + Type: AWS::SSM::Document + Properties: + Name: !Sub '${AWS::StackName}-oauth-relay' + DocumentType: Command + UpdateMethod: NewVersion + Content: + schemaVersion: '2.2' + description: Relay one staged OAuth loopback callback inside its assigned AgentFormation runtime. + parameters: + UploadBucket: + type: String + allowedPattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$' + interpolationType: ENV_VAR + UserSubject: + type: String + allowedPattern: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' + interpolationType: ENV_VAR + RelayId: + type: String + allowedPattern: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' + interpolationType: ENV_VAR + mainSteps: + - action: aws:runShellScript + name: relayOAuthCallback + inputs: + timeoutSeconds: '15' + runCommand: + - | + exec /bin/bash -euo pipefail <<'AGENTFORMATION_OAUTH' + : "${SSM_UploadBucket:?missing UploadBucket}" + : "${SSM_UserSubject:?missing UserSubject}" + : "${SSM_RelayId:?missing RelayId}" + object_uri="s3://${SSM_UploadBucket}/uploads/${SSM_UserSubject}/${SSM_RelayId}/oauth-callback.url" + /usr/local/bin/aws s3 cp "$object_uri" - --only-show-errors | + /usr/sbin/runuser --user agentformation -- /usr/local/bin/node -e ' + const chunks = []; + let size = 0; + process.stdin.on("data", (chunk) => { + size += chunk.length; + if (size > 4096) process.exit(1); + chunks.push(chunk); + }); + process.stdin.on("end", () => { + const payload = Buffer.concat(chunks, size); + const value = payload.toString("utf8"); + try { + const url = new URL(value); + const port = Number(url.port); + if ( + !Buffer.from(value, "utf8").equals(payload) || + /[\u0000-\u001f\u007f]/.test(value) || + url.protocol !== "http:" || + url.hostname !== "127.0.0.1" || + url.username || + url.password || + !/^\d+$/.test(url.port) || + !Number.isInteger(port) || + port < 1 || + port > 65535 || + !/^\/callback(?:\/[A-Za-z0-9_-]{8,128})?$/.test(url.pathname) || + !url.search || + url.hash || + url.href !== value + ) { + process.exit(1); + } + const escaped = value + .replaceAll("\\", "\\\\") + .replaceAll("\"", "\\\""); + process.stdout.write(`url = "${escaped}"\n`); + } catch { + process.exit(1); + } + }); + ' | + /usr/sbin/runuser --user agentformation -- /usr/bin/curl --disable --config - --interface 127.0.0.1 --proto '=http' --noproxy '*' --fail --silent --show-error --output /dev/null --max-time 5 --max-filesize 65536 + AGENTFORMATION_OAUTH + Tags: + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + Outputs: UserPoolId: Value: !Ref UserPool UserPoolClientId: + Condition: IdentityCenterClientEnabled Value: !Ref UserPoolClient CognitoIssuer: Value: !Sub 'https://cognito-idp.${AWS::Region}.${AWS::URLSuffix}/${UserPool}' @@ -235,6 +462,8 @@ Outputs: Value: !Sub 'urn:amazon:cognito:sp:${UserPool}' UserRegistryTableName: Value: !Ref UserRegistry + ControlTableName: + Value: !Ref ControlState UploadBucketName: Value: !Ref UploadBucket WebRepositoryUri: @@ -247,3 +476,7 @@ Outputs: Value: !Ref CognitoClientSecret TerminalSessionDocumentName: Value: !Ref TerminalSessionDocument + UploadDeliveryDocumentName: + Value: !Ref UploadDeliveryDocument + OAuthRelayDocumentName: + Value: !Ref OAuthRelayDocument diff --git a/templates/image.yaml b/templates/image.yaml index 11de8e2..86ca44f 100644 --- a/templates/image.yaml +++ b/templates/image.yaml @@ -4,7 +4,7 @@ Description: AgentFormation EC2 Image Builder pipeline for Claude Code and Codex Parameters: DeploymentName: Type: String - AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' + AllowedPattern: '^(?!.*-runtime-)[a-z][a-z0-9-]{2,31}$' NetworkStackName: Type: String MinLength: 1 @@ -40,6 +40,14 @@ Parameters: Type: String Default: 1.3.6 AllowedPattern: '^\d+\.\d+\.\d+$' + BunArm64Sha512: + Type: String + Default: 61a4040188c16a7a0e3ada6a93f73918671f648cb1208910da6d536c78de751989370c735818468a748044592b44873717ca51206028a572af27fdab8cdd4bcd + AllowedPattern: '^[0-9a-f]{128}$' + BunX64Sha512: + Type: String + Default: 7a07e7823d1d7c9f3af1c7f7d04ec1fb27bd29459279b631386e25f583f979678c5c2b5e9e97a7c74cdd2ad027f6ac498042729b900deadaed964f89eb1f0b8a + AllowedPattern: '^[0-9a-f]{128}$' ClaudeCodeVersion: Type: String Default: 2.1.235 @@ -167,7 +175,15 @@ Resources: inputs: commands: - set -euo pipefail - - npm install --global "bun@${BunVersion}" + - if [[ "${Architecture}" == "arm64" ]]; then BUN_PACKAGE="bun-linux-aarch64"; BUN_SHA512="${BunArm64Sha512}"; else BUN_PACKAGE="bun-linux-x64"; BUN_SHA512="${BunX64Sha512}"; fi + - curl -fsSL "https://registry.npmjs.org/@oven/${!BUN_PACKAGE}/-/${!BUN_PACKAGE}-${BunVersion}.tgz" -o /tmp/bun.tgz + - echo "${!BUN_SHA512} /tmp/bun.tgz" | sha512sum -c - + - tar -xzf /tmp/bun.tgz -C /tmp + - install -m 0755 /tmp/package/bin/bun /usr/local/bin/bun + - ln -sfn /usr/local/bin/bun /usr/local/bin/bunx + - rm -rf /tmp/bun.tgz /tmp/package + - bun --version | grep -Fx "${BunVersion}" + - bunx --version | grep -Fx "${BunVersion}" - name: InstallClaudeCode action: ExecuteBash inputs: @@ -198,6 +214,7 @@ Resources: inputs: commands: - set -euo pipefail + - snap refresh amazon-ssm-agent --channel=latest/stable - systemctl enable docker - chmod 0700 /home/agentformation/.codex - chown -R agentformation:agentformation /home/agentformation /workspace @@ -215,6 +232,7 @@ Resources: - /usr/local/bin/aws --version | grep -F "aws-cli/${AwsCliVersion} " - node --version | grep -Fx "v${NodeVersion}" - bun --version | grep -Fx "${BunVersion}" + - bunx --version | grep -Fx "${BunVersion}" - bwrap --version - docker buildx version - git --version @@ -233,10 +251,13 @@ Resources: - /usr/local/bin/aws --version | grep -F "aws-cli/${AwsCliVersion} " - node --version | grep -Fx "v${NodeVersion}" - bun --version | grep -Fx "${BunVersion}" + - bunx --version | grep -Fx "${BunVersion}" - bwrap --version - docker buildx version - systemctl is-enabled docker - systemctl is-active snap.amazon-ssm-agent.amazon-ssm-agent.service + - SSM_AGENT_VERSION="$(/snap/amazon-ssm-agent/current/amazon-ssm-agent -version | awk '{print $NF}')" + - dpkg --compare-versions "$SSM_AGENT_VERSION" ge 3.3.2746.0 - test -d /home/agentformation - test "$(stat -c %U:%G /workspace)" = agentformation:agentformation @@ -267,7 +288,7 @@ Resources: SubnetId: Fn::ImportValue: !Sub '${NetworkStackName}-PrivateSubnetId' SecurityGroupIds: - - Fn::ImportValue: !Sub '${NetworkStackName}-RuntimeSecurityGroupId' + - Fn::ImportValue: !Sub '${NetworkStackName}-BuildSecurityGroupId' TerminateInstanceOnFailure: true ResourceTags: AgentFormationDeployment: !Ref DeploymentName diff --git a/templates/network.yaml b/templates/network.yaml index 28a9aa4..38f013a 100644 --- a/templates/network.yaml +++ b/templates/network.yaml @@ -4,7 +4,7 @@ Description: AgentFormation private runtime network with optional AWS service en Parameters: DeploymentName: Type: String - AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' + AllowedPattern: '^(?!.*-runtime-)[a-z][a-z0-9-]{2,31}$' NetworkMode: Type: String Default: private-nat @@ -131,6 +131,26 @@ Resources: - { Key: Name, Value: !Sub '${AWS::StackName}-runtime' } - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + BuildSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: AgentFormation image builds use outbound web traffic only. + VpcId: !Ref Vpc + SecurityGroupEgress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 0.0.0.0/0 + Description: Ubuntu apt package repositories that still use HTTP. + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + CidrIp: 0.0.0.0/0 + Description: Verified packages and AWS service APIs over TLS. + Tags: + - { Key: Name, Value: !Sub '${AWS::StackName}-build' } + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + EndpointSecurityGroup: Type: AWS::EC2::SecurityGroup Condition: UsePrivateEndpoints @@ -142,6 +162,10 @@ Resources: FromPort: 443 ToPort: 443 SourceSecurityGroupId: !Ref RuntimeSecurityGroup + - IpProtocol: tcp + FromPort: 443 + ToPort: 443 + SourceSecurityGroupId: !Ref BuildSecurityGroup Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } @@ -205,3 +229,6 @@ Outputs: RuntimeSecurityGroupId: Value: !Ref RuntimeSecurityGroup Export: { Name: !Sub '${AWS::StackName}-RuntimeSecurityGroupId' } + BuildSecurityGroupId: + Value: !Ref BuildSecurityGroup + Export: { Name: !Sub '${AWS::StackName}-BuildSecurityGroupId' } diff --git a/templates/provisioning.yaml b/templates/provisioning.yaml index 7a55f06..63b5abb 100644 --- a/templates/provisioning.yaml +++ b/templates/provisioning.yaml @@ -4,19 +4,18 @@ Description: Restricted self-service provisioning for one AgentFormation runtime Parameters: DeploymentName: Type: String - AllowedPattern: "^[a-z][a-z0-9-]{2,31}$" + AllowedPattern: "^(?!.*-runtime-)[a-z][a-z0-9-]{2,31}$" UserPoolId: Type: String - MinLength: 1 + AllowedPattern: '^[A-Za-z0-9-]+_[A-Za-z0-9]+$' UserRegistryTableName: Type: String - MinLength: 1 - RuntimeTemplateUrl: - Type: String - AllowedPattern: "^https://[^ ]+$" + MinLength: 3 + MaxLength: 255 + AllowedPattern: '^[A-Za-z0-9_.-]+$' RuntimeTemplateBucket: Type: String - MinLength: 3 + AllowedPattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$' RuntimeTemplateKey: Type: String AllowedPattern: '^provisioning/runtime-[0-9a-f]{64}\.yaml$' @@ -56,9 +55,10 @@ Parameters: AllowedPattern: "^[A-Za-z0-9._:/-]+$" ClaudeInferenceProfileArn: Type: String - MinLength: 20 + AllowedPattern: '^arn:[A-Za-z0-9-]+:bedrock:[A-Za-z0-9-]+:([0-9]{12})?:(inference-profile|application-inference-profile)/[A-Za-z0-9._:-]+$' ClaudeFoundationModelArns: Type: CommaDelimitedList + AllowedPattern: '^arn:[A-Za-z0-9-]+:bedrock:[A-Za-z0-9-]+::foundation-model/[A-Za-z0-9._:-]+$' CodexModelId: Type: String MinLength: 1 @@ -66,9 +66,104 @@ Parameters: AllowedPattern: "^[A-Za-z0-9._:/-]+$" UploadBucketName: Type: String - MinLength: 3 + AllowedPattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$' + +Rules: + MatchArchitectureAndRuntime: + Assertions: + - Assert: !Or + - !And + - !Equals [!Ref Architecture, arm64] + - !Contains [[t4g.medium, t4g.large, c7g.large, c7g.xlarge, m7g.large, m7g.xlarge], !Ref InstanceType] + - !And + - !Equals [!Ref Architecture, x86_64] + - !Contains [[t3.medium, t3.large, c7i.large, c7i.xlarge, m7i.large, m7i.xlarge], !Ref InstanceType] + AssertDescription: Runtime instance type must match the selected AMI architecture. Resources: + RuntimePermissionsBoundaryPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + ManagedPolicyName: !Sub "${DeploymentName}-runtime-boundary" + Description: Maximum permissions for AgentFormation runtime instance roles. + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: UseSystemsManagerAgent + Effect: Allow + Action: + - ssm:DescribeAssociation + - ssm:GetDeployablePatchSnapshotForInstance + - ssm:GetDocument + - ssm:DescribeDocument + - ssm:GetManifest + - ssm:GetParameter + - ssm:GetParameters + - ssm:ListAssociations + - ssm:ListInstanceAssociations + - ssm:PutInventory + - ssm:PutComplianceItems + - ssm:PutConfigurePackageResult + - ssm:UpdateAssociationStatus + - ssm:UpdateInstanceAssociationStatus + - ssm:UpdateInstanceInformation + - ssmmessages:CreateControlChannel + - ssmmessages:CreateDataChannel + - ssmmessages:OpenControlChannel + - ssmmessages:OpenDataChannel + - ec2messages:AcknowledgeMessage + - ec2messages:DeleteMessage + - ec2messages:FailMessage + - ec2messages:GetEndpoint + - ec2messages:GetMessages + - ec2messages:SendReply + Resource: "*" + - Sid: InvokeConfiguredInferenceProfile + Effect: Allow + Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream] + Resource: !Ref ClaudeInferenceProfileArn + - Sid: InvokeConfiguredFoundationModelsThroughProfile + Effect: Allow + Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream] + Resource: !Ref ClaudeFoundationModelArns + Condition: + StringEquals: + bedrock:InferenceProfileArn: !Ref ClaudeInferenceProfileArn + - Sid: ListModelMetadata + Effect: Allow + Action: [bedrock:ListFoundationModels, bedrock:ListInferenceProfiles] + Resource: "*" + - Sid: ReadConfiguredFoundationModels + Effect: Allow + Action: bedrock:GetFoundationModel + Resource: !Ref ClaudeFoundationModelArns + - Sid: ReadConfiguredInferenceProfile + Effect: Allow + Action: bedrock:GetInferenceProfile + Resource: !Ref ClaudeInferenceProfileArn + - Sid: InvokeConfiguredOpenAIModel + Effect: Allow + Action: bedrock-mantle:CreateInference + Resource: !Sub "arn:${AWS::Partition}:bedrock-mantle:${AWS::Region}:${AWS::AccountId}:project/default" + Condition: + StringEquals: + bedrock-mantle:Model: !Ref CodexModelId + - Sid: AllowMantleModelAutoEnablement + Effect: Allow + Action: [aws-marketplace:Subscribe, aws-marketplace:ViewSubscriptions] + Resource: "*" + Condition: + StringEquals: + aws:CalledViaLast: bedrock-mantle.amazonaws.com + - Sid: ReadDeploymentUploads + Effect: Allow + Action: s3:GetObject + Resource: !Sub "arn:${AWS::Partition}:s3:::${UploadBucketName}/uploads/*" + - Sid: LocateDeploymentUploadBucket + Effect: Allow + Action: s3:GetBucketLocation + Resource: !Sub "arn:${AWS::Partition}:s3:::${UploadBucketName}" + RuntimeCloudFormationRole: Type: AWS::IAM::Role Properties: @@ -84,23 +179,39 @@ Resources: PolicyDocument: Version: "2012-10-17" Statement: - - Sid: ManageNamedRuntimeRoles + - Sid: CreateBoundaryConstrainedRuntimeRoles Effect: Allow Action: - iam:CreateRole + - iam:PutRolePermissionsBoundary + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DeploymentName}-runtime-*" + Condition: + ArnEquals: + iam:PermissionsBoundary: !Ref RuntimePermissionsBoundaryPolicy + - Sid: ManageNamedRuntimeRoles + Effect: Allow + Action: - iam:DeleteRole - iam:GetRole - iam:GetRolePolicy - iam:PutRolePolicy - iam:DeleteRolePolicy + - iam:DeleteRolePermissionsBoundary - iam:ListRolePolicies - iam:ListAttachedRolePolicies - iam:ListInstanceProfilesForRole - iam:TagRole - iam:UntagRole + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DeploymentName}-runtime-*" + - Sid: AttachOnlySystemsManagerCore + Effect: Allow + Action: - iam:AttachRolePolicy - iam:DetachRolePolicy Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DeploymentName}-runtime-*" + Condition: + ArnEquals: + iam:PolicyARN: !Sub "arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore" - Sid: ManageNamedRuntimeInstanceProfiles Effect: Allow Action: @@ -117,6 +228,12 @@ Resources: Condition: StringEquals: iam:PassedToService: ec2.amazonaws.com + - Sid: NeverMutateOrPassTheControlRole + Effect: Deny + NotAction: + - iam:Get* + - iam:List* + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DeploymentName}-runtime-cfn" - PolicyName: ManageRuntimeInstances PolicyDocument: Version: "2012-10-17" @@ -168,6 +285,7 @@ Resources: ProvisioningStateMachineRole: Type: AWS::IAM::Role Properties: + RoleName: !Sub "${DeploymentName}-provisioning-sfn" AssumeRolePolicyDocument: Version: "2012-10-17" Statement: @@ -194,7 +312,7 @@ Resources: Version: "2012-10-17" Statement: - Effect: Allow - Action: [dynamodb:PutItem, dynamodb:UpdateItem] + Action: dynamodb:UpdateItem Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${UserRegistryTableName}" - PolicyName: CreateRestrictedRuntimeStack PolicyDocument: @@ -206,9 +324,12 @@ Resources: Condition: StringEquals: cloudformation:RoleArn: !GetAtt RuntimeCloudFormationRole.Arn - cloudformation:TemplateUrl: !Ref RuntimeTemplateUrl + cloudformation:TemplateUrl: !Sub "https://${RuntimeTemplateBucket}.s3.${AWS::Region}.${AWS::URLSuffix}/${RuntimeTemplateKey}" - Effect: Allow - Action: cloudformation:DescribeStacks + Action: + - cloudformation:DescribeStacks + - cloudformation:DescribeStackResource + - cloudformation:DeleteStack Resource: !Sub "arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${DeploymentName}-runtime-*/*" - Effect: Allow Action: iam:PassRole @@ -216,9 +337,6 @@ Resources: Condition: StringEquals: iam:PassedToService: cloudformation.amazonaws.com - - Effect: Allow - Action: ec2:DescribeInstances - Resource: "*" Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } @@ -229,6 +347,7 @@ Resources: StateMachineType: STANDARD RoleArn: !GetAtt ProvisioningStateMachineRole.Arn Definition: + TimeoutSeconds: 5400 Comment: Validate one federated user and create only that user's reviewed AgentFormation runtime stack. StartAt: PrepareSubjectParts States: @@ -237,6 +356,8 @@ Resources: Parameters: "subject.$": $.subject "email.$": $.email + "requestedAt.$": $.requestedAt + "staleBefore.$": $.staleBefore "subjectParts.$": States.StringSplit($.subject, '-') Next: PrepareRuntimeInput PrepareRuntimeInput: @@ -244,7 +365,12 @@ Resources: Parameters: "subject.$": $.subject "email.$": $.email + "requestedAt.$": $.requestedAt + "staleBefore.$": $.staleBefore "stackName.$": !Sub "States.Format('${DeploymentName}-runtime-{}{}{}', States.ArrayGetItem($.subjectParts, 0), States.ArrayGetItem($.subjectParts, 1), States.ArrayGetItem($.subjectParts, 2))" + stackPollAttempts: 0 + priorCleanupPollAttempts: 0 + failedCleanupPollAttempts: 0 Next: FindFederatedUser FindFederatedUser: Type: Task @@ -254,6 +380,13 @@ Resources: "Filter.$": 'States.Format(''sub = "{}"'', $.subject)' Limit: 2 ResultPath: $.identity + Retry: + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL Next: RequireOneFederatedUser RequireOneFederatedUser: Type: Choice @@ -270,35 +403,167 @@ Resources: Default: InvalidIdentity RegisterProvisioning: Type: Task - Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:putItem" + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:updateItem" Parameters: TableName: !Ref UserRegistryTableName - Item: + Key: userSub: { "S.$": $.subject } - email: { "S.$": $.email } - runtimeStackName: { "S.$": $.stackName } - status: { S: provisioning } - provisioningStartedAt: { "S.$": $$.State.EnteredTime } - updatedAt: { "S.$": $$.State.EnteredTime } - ConditionExpression: "attribute_not_exists(userSub) OR #status = :failed" + UpdateExpression: "SET #email = :email, #stack = :stackName, #status = :provisioning, #startedAt = :requestedAt, #execution = :execution, #updatedAt = :requestedAt REMOVE #instance" + ConditionExpression: "attribute_not_exists(userSub) OR #status = :failed OR (#status = :provisioning AND #startedAt = :requestedAt AND #stack = :stackName AND #execution = :execution) OR (#status = :provisioning AND ((attribute_exists(#startedAt) AND #startedAt < :staleBefore) OR (attribute_not_exists(#startedAt) AND #updatedAt < :staleBefore)))" ExpressionAttributeNames: + "#email": email + "#stack": runtimeStackName "#status": status + "#startedAt": provisioningStartedAt + "#execution": provisioningExecutionId + "#updatedAt": updatedAt + "#instance": instanceId ExpressionAttributeValues: + ":email": { "S.$": $.email } + ":stackName": { "S.$": $.stackName } ":failed": { S: failed } + ":provisioning": { S: provisioning } + ":requestedAt": { "S.$": $.requestedAt } + ":execution": { "S.$": "$$.Execution.Id" } + ":staleBefore": { "S.$": $.staleBefore } ResultPath: $.registryWrite + Retry: + - ErrorEquals: + - DynamoDb.ConditionalCheckFailedException + - DynamoDB.ConditionalCheckFailedException + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL Catch: - ErrorEquals: - DynamoDb.ConditionalCheckFailedException - DynamoDB.ConditionalCheckFailedException ResultPath: $.duplicate Next: AlreadyProvisioned + Next: InspectPriorRuntimeStack + InspectPriorRuntimeStack: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:describeStacks" + Parameters: + "StackName.$": $.stackName + ResultPath: $.priorStack + Retry: + - ErrorEquals: [CloudFormation.ValidationErrorException] + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: [CloudFormation.ValidationErrorException] + ResultPath: $.priorStackMissing + Next: ResetRuntimePolls + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: MarkProvisioningFailed + Next: DeletePriorRuntimeStack + DeletePriorRuntimeStack: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:deleteStack" + Parameters: + "StackName.$": $.stackName + RoleARN: !GetAtt RuntimeCloudFormationRole.Arn + ResultPath: $.deletePriorStack + Retry: + - ErrorEquals: [CloudFormation.ValidationErrorException] + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: [CloudFormation.ValidationErrorException] + ResultPath: $.priorStackMissing + Next: ResetRuntimePolls + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: MarkProvisioningFailed + Next: CanWaitForPriorStackDeletion + CanWaitForPriorStackDeletion: + Type: Choice + Choices: + - Variable: $.priorCleanupPollAttempts + NumericLessThan: 60 + Next: IncrementPriorCleanupPoll + Default: MarkProvisioningFailed + IncrementPriorCleanupPoll: + Type: Pass + Parameters: + "subject.$": $.subject + "email.$": $.email + "requestedAt.$": $.requestedAt + "staleBefore.$": $.staleBefore + "stackName.$": $.stackName + "stackPollAttempts.$": $.stackPollAttempts + "priorCleanupPollAttempts.$": States.MathAdd($.priorCleanupPollAttempts, 1) + "failedCleanupPollAttempts.$": $.failedCleanupPollAttempts + Next: WaitForPriorStackDeletion + WaitForPriorStackDeletion: + Type: Wait + Seconds: 15 + Next: ReadPriorStackDeletion + ReadPriorStackDeletion: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:describeStacks" + Parameters: + "StackName.$": $.stackName + ResultPath: $.priorStack + Retry: + - ErrorEquals: [CloudFormation.ValidationErrorException] + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: [CloudFormation.ValidationErrorException] + ResultPath: $.priorStackMissing + Next: ResetRuntimePolls + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: MarkProvisioningFailed + Next: CheckPriorStackDeletion + CheckPriorStackDeletion: + Type: Choice + Choices: + - Variable: $.priorStack.Stacks[0].StackStatus + StringEquals: DELETE_FAILED + Next: MarkProvisioningFailed + Default: CanWaitForPriorStackDeletion + ResetRuntimePolls: + Type: Pass + Parameters: + "subject.$": $.subject + "email.$": $.email + "requestedAt.$": $.requestedAt + "staleBefore.$": $.staleBefore + "stackName.$": $.stackName + stackPollAttempts: 0 + "priorCleanupPollAttempts.$": $.priorCleanupPollAttempts + failedCleanupPollAttempts: 0 Next: CreateRuntimeStack CreateRuntimeStack: Type: Task Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:createStack" Parameters: "StackName.$": $.stackName - TemplateURL: !Ref RuntimeTemplateUrl + "ClientRequestToken.$": $$.Execution.Name + TemplateURL: !Sub "https://${RuntimeTemplateBucket}.s3.${AWS::Region}.${AWS::URLSuffix}/${RuntimeTemplateKey}" RoleARN: !GetAtt RuntimeCloudFormationRole.Arn Capabilities: [CAPABILITY_NAMED_IAM] OnFailure: DELETE @@ -310,6 +575,8 @@ Resources: ParameterValue: !Ref RuntimeSubnetId - ParameterKey: RuntimeSecurityGroupId ParameterValue: !Ref RuntimeSecurityGroupId + - ParameterKey: RuntimePermissionsBoundaryArn + ParameterValue: !Ref RuntimePermissionsBoundaryPolicy - ParameterKey: UserSubject "ParameterValue.$": $.subject - ParameterKey: AmiId @@ -336,10 +603,36 @@ Resources: - Key: AgentFormationUserSubject "Value.$": $.subject ResultPath: $.createStack + Retry: + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL Catch: - ErrorEquals: [States.ALL] ResultPath: $.provisioningError - Next: MarkProvisioningFailed + Next: BeginFailedStackCleanup + Next: CanWaitForRuntimeStack + CanWaitForRuntimeStack: + Type: Choice + Choices: + - Variable: $.stackPollAttempts + NumericLessThan: 140 + Next: IncrementRuntimeStackPoll + Default: BeginFailedStackCleanup + IncrementRuntimeStackPoll: + Type: Pass + Parameters: + "subject.$": $.subject + "email.$": $.email + "requestedAt.$": $.requestedAt + "staleBefore.$": $.staleBefore + "stackName.$": $.stackName + "stackPollAttempts.$": States.MathAdd($.stackPollAttempts, 1) + "priorCleanupPollAttempts.$": $.priorCleanupPollAttempts + "failedCleanupPollAttempts.$": $.failedCleanupPollAttempts Next: WaitForRuntimeStack WaitForRuntimeStack: Type: Wait @@ -351,10 +644,17 @@ Resources: Parameters: "StackName.$": $.stackName ResultPath: $.stack + Retry: + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL Catch: - ErrorEquals: [States.ALL] ResultPath: $.provisioningError - Next: MarkProvisioningFailed + Next: BeginFailedStackCleanup Next: CheckRuntimeStack CheckRuntimeStack: Type: Choice @@ -363,31 +663,68 @@ Resources: StringEquals: CREATE_COMPLETE Next: FindRuntimeInstance - Variable: $.stack.Stacks[0].StackStatus - StringEquals: CREATE_IN_PROGRESS - Next: WaitForRuntimeStack - Default: MarkProvisioningFailed + StringMatches: "*_IN_PROGRESS" + Next: CanWaitForRuntimeStack + Default: BeginFailedStackCleanup FindRuntimeInstance: Type: Task - Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:ec2:describeInstances" + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:describeStackResource" Parameters: - Filters: - - Name: tag:aws:cloudformation:stack-name - "Values.$": States.Array($.stackName) - - Name: instance-state-name - Values: [pending, running, stopping, stopped] + "StackName.$": $.stackName + LogicalResourceId: RuntimeInstance ResultPath: $.runtime + Retry: + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: BeginFailedStackCleanup Next: RequireRuntimeInstance RequireRuntimeInstance: Type: Choice Choices: - - Variable: $.runtime.Reservations[0].Instances[0].InstanceId + - Variable: $.runtime.StackResourceDetail.PhysicalResourceId IsPresent: true + Next: RecheckFederatedUser + Default: BeginFailedStackCleanup + RecheckFederatedUser: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cognitoidentityprovider:listUsers" + Parameters: + UserPoolId: !Ref UserPoolId + "Filter.$": 'States.Format(''sub = "{}"'', $.subject)' + Limit: 2 + ResultPath: $.identity + Retry: + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 6 + BackoffRate: 2 + MaxDelaySeconds: 20 + JitterStrategy: FULL + Catch: + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: BeginFailedStackCleanup + Next: RequireStillFederatedUser + RequireStillFederatedUser: + Type: Choice + Choices: + - And: + - Variable: $.identity.Users[0].Username + IsPresent: true + - Variable: $.identity.Users[0].Enabled + BooleanEquals: true + - Not: + Variable: $.identity.Users[1].Username + IsPresent: true Next: ActivateRuntime - Default: WaitForRuntimeRecord - WaitForRuntimeRecord: - Type: Wait - Seconds: 10 - Next: FindRuntimeInstance + Default: BeginFailedStackCleanup ActivateRuntime: Type: Task Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:updateItem" @@ -395,19 +732,114 @@ Resources: TableName: !Ref UserRegistryTableName Key: userSub: { "S.$": $.subject } - UpdateExpression: "SET #status = :active, instanceId = :instanceId, updatedAt = :updatedAt" - ConditionExpression: "#status = :provisioning AND runtimeStackName = :stackName" + UpdateExpression: "SET #status = :active, instanceId = :instanceId, updatedAt = :updatedAt REMOVE #execution" + ConditionExpression: "#status = :provisioning AND runtimeStackName = :stackName AND provisioningStartedAt = :requestedAt AND #execution = :execution" ExpressionAttributeNames: "#status": status + "#execution": provisioningExecutionId ExpressionAttributeValues: ":active": { S: active } ":provisioning": { S: provisioning } + ":requestedAt": { "S.$": $.requestedAt } + ":execution": { "S.$": "$$.Execution.Id" } ":instanceId": - { "S.$": "$.runtime.Reservations[0].Instances[0].InstanceId" } + { "S.$": "$.runtime.StackResourceDetail.PhysicalResourceId" } ":stackName": { "S.$": $.stackName } ":updatedAt": { "S.$": $$.State.EnteredTime } ResultPath: $.registryWrite + Retry: + - ErrorEquals: + - DynamoDb.ConditionalCheckFailedException + - DynamoDB.ConditionalCheckFailedException + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 6 + BackoffRate: 2 + MaxDelaySeconds: 20 + JitterStrategy: FULL + Catch: + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: BeginFailedStackCleanup Next: RuntimeReady + BeginFailedStackCleanup: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:deleteStack" + Parameters: + "StackName.$": $.stackName + RoleARN: !GetAtt RuntimeCloudFormationRole.Arn + ResultPath: $.deleteFailedStack + Retry: + - ErrorEquals: [CloudFormation.ValidationErrorException] + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: [CloudFormation.ValidationErrorException] + ResultPath: $.failedStackMissing + Next: MarkProvisioningFailed + - ErrorEquals: [States.ALL] + ResultPath: $.cleanupError + Next: MarkProvisioningFailed + Next: CanWaitForFailedStackDeletion + CanWaitForFailedStackDeletion: + Type: Choice + Choices: + - Variable: $.failedCleanupPollAttempts + NumericLessThan: 60 + Next: IncrementFailedCleanupPoll + Default: MarkProvisioningFailed + IncrementFailedCleanupPoll: + Type: Pass + Parameters: + "subject.$": $.subject + "email.$": $.email + "requestedAt.$": $.requestedAt + "staleBefore.$": $.staleBefore + "stackName.$": $.stackName + "stackPollAttempts.$": $.stackPollAttempts + "priorCleanupPollAttempts.$": $.priorCleanupPollAttempts + "failedCleanupPollAttempts.$": States.MathAdd($.failedCleanupPollAttempts, 1) + Next: WaitForFailedStackDeletion + WaitForFailedStackDeletion: + Type: Wait + Seconds: 15 + Next: ReadFailedStackDeletion + ReadFailedStackDeletion: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:describeStacks" + Parameters: + "StackName.$": $.stackName + ResultPath: $.failedStack + Retry: + - ErrorEquals: [CloudFormation.ValidationErrorException] + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: [CloudFormation.ValidationErrorException] + ResultPath: $.failedStackMissing + Next: MarkProvisioningFailed + - ErrorEquals: [States.ALL] + ResultPath: $.cleanupError + Next: MarkProvisioningFailed + Next: CheckFailedStackDeletion + CheckFailedStackDeletion: + Type: Choice + Choices: + - Variable: $.failedStack.Stacks[0].StackStatus + StringEquals: DELETE_FAILED + Next: MarkProvisioningFailed + Default: CanWaitForFailedStackDeletion MarkProvisioningFailed: Type: Task Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:updateItem" @@ -415,20 +847,43 @@ Resources: TableName: !Ref UserRegistryTableName Key: userSub: { "S.$": $.subject } - UpdateExpression: "SET #status = :failed, updatedAt = :updatedAt" - ConditionExpression: "#status = :provisioning" + UpdateExpression: "SET #status = :failed, updatedAt = :updatedAt REMOVE #execution" + ConditionExpression: "#status = :provisioning AND runtimeStackName = :stackName AND provisioningStartedAt = :requestedAt AND #execution = :execution" ExpressionAttributeNames: "#status": status + "#execution": provisioningExecutionId ExpressionAttributeValues: ":failed": { S: failed } ":provisioning": { S: provisioning } + ":requestedAt": { "S.$": $.requestedAt } + ":execution": { "S.$": "$$.Execution.Id" } + ":stackName": { "S.$": $.stackName } ":updatedAt": { "S.$": $$.State.EnteredTime } ResultPath: $.registryWrite + Retry: + - ErrorEquals: + - DynamoDb.ConditionalCheckFailedException + - DynamoDB.ConditionalCheckFailedException + MaxAttempts: 0 + - ErrorEquals: [States.TaskFailed] + IntervalSeconds: 2 + MaxAttempts: 4 + BackoffRate: 2 + MaxDelaySeconds: 15 + JitterStrategy: FULL + Catch: + - ErrorEquals: + - DynamoDb.ConditionalCheckFailedException + - DynamoDB.ConditionalCheckFailedException + ResultPath: $.superseded + Next: ProvisioningSuperseded Next: ProvisioningFailed AlreadyProvisioned: Type: Succeed RuntimeReady: Type: Succeed + ProvisioningSuperseded: + Type: Succeed InvalidIdentity: Type: Fail Error: InvalidFederatedIdentity diff --git a/templates/runtime.yaml b/templates/runtime.yaml index 6937213..c81e449 100644 --- a/templates/runtime.yaml +++ b/templates/runtime.yaml @@ -4,16 +4,18 @@ Description: One private, persistent AgentFormation runtime assigned to one fede Parameters: DeploymentName: Type: String - AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' + AllowedPattern: '^(?!.*-runtime-)[a-z][a-z0-9-]{2,31}$' RuntimeSubnetId: Type: AWS::EC2::Subnet::Id RuntimeSecurityGroupId: Type: AWS::EC2::SecurityGroup::Id + RuntimePermissionsBoundaryArn: + Type: String + AllowedPattern: '^arn:[A-Za-z0-9-]+:iam::[0-9]{12}:policy/[A-Za-z0-9+=,.@_/-]+$' UserSubject: Type: String NoEcho: true - MinLength: 8 - MaxLength: 128 + AllowedPattern: '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' AmiId: Type: AWS::EC2::Image::Id InstanceType: @@ -36,9 +38,10 @@ Parameters: AllowedPattern: '^[A-Za-z0-9._:/-]+$' ClaudeInferenceProfileArn: Type: String - MinLength: 20 + AllowedPattern: '^arn:[A-Za-z0-9-]+:bedrock:[A-Za-z0-9-]+:([0-9]{12})?:(inference-profile|application-inference-profile)/[A-Za-z0-9._:-]+$' ClaudeFoundationModelArns: Type: CommaDelimitedList + AllowedPattern: '^arn:[A-Za-z0-9-]+:bedrock:[A-Za-z0-9-]+::foundation-model/[A-Za-z0-9._:-]+$' CodexModelId: Type: String MinLength: 1 @@ -46,7 +49,7 @@ Parameters: AllowedPattern: '^[A-Za-z0-9._:/-]+$' UploadBucketName: Type: String - MinLength: 3 + AllowedPattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$' Rules: MatchArchitectureAndRuntime: @@ -71,8 +74,9 @@ Resources: - Effect: Allow Principal: { Service: ec2.amazonaws.com } Action: sts:AssumeRole + PermissionsBoundary: !Ref RuntimePermissionsBoundaryArn ManagedPolicyArns: - - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + - !Sub 'arn:${AWS::Partition}:iam::aws:policy/AmazonSSMManagedInstanceCore' Policies: - PolicyName: InvokeConfiguredBedrockModels PolicyDocument: diff --git a/templates/web.yaml b/templates/web.yaml index ad6c876..97044da 100644 --- a/templates/web.yaml +++ b/templates/web.yaml @@ -4,37 +4,50 @@ Description: AgentFormation App Runner web terminal and least-privilege service Parameters: DeploymentName: Type: String - AllowedPattern: "^[a-z][a-z0-9-]{2,31}$" + AllowedPattern: "^(?!.*-runtime-)[a-z][a-z0-9-]{2,31}$" ImageIdentifier: Type: String - MinLength: 1 + AllowedPattern: "^[0-9]{12}\\.dkr\\.ecr\\.[a-z0-9-]+\\.amazonaws\\.com(?:\\.cn)?/[a-z0-9._/-]+@sha256:[a-f0-9]{64}$" PublicUrl: Type: String - AllowedPattern: "^https?://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$" + AllowedPattern: "^https://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$" UserPoolClientId: Type: String - MinLength: 1 - CognitoIssuer: + AllowedPattern: "^[A-Za-z0-9]+$" + UserPoolId: Type: String - AllowedPattern: "^https://[^ ]+$" + AllowedPattern: "^[A-Za-z0-9-]+_[A-Za-z0-9]+$" CognitoClientSecretArn: Type: String - MinLength: 1 + AllowedPattern: "^arn:[A-Za-z0-9-]+:secretsmanager:[A-Za-z0-9-]+:[0-9]{12}:secret:[A-Za-z0-9/_+=.@-]+$" AuthSecretArn: Type: String - MinLength: 1 + AllowedPattern: "^arn:[A-Za-z0-9-]+:secretsmanager:[A-Za-z0-9-]+:[0-9]{12}:secret:[A-Za-z0-9/_+=.@-]+$" UserRegistryTableName: Type: String - MinLength: 1 - UploadBucketName: + MinLength: 3 + MaxLength: 255 + AllowedPattern: "^[A-Za-z0-9_.-]+$" + ControlTableName: Type: String MinLength: 3 + MaxLength: 255 + AllowedPattern: "^[A-Za-z0-9_.-]+$" + UploadBucketName: + Type: String + AllowedPattern: "^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" TerminalSessionDocumentName: Type: String - MinLength: 1 + AllowedPattern: "^[A-Za-z0-9_.-]{3,128}$" + UploadDeliveryDocumentName: + Type: String + AllowedPattern: "^[A-Za-z0-9_.-]{3,128}$" + OAuthRelayDocumentName: + Type: String + AllowedPattern: "^[A-Za-z0-9_.-]{3,128}$" ProvisioningStateMachineArn: Type: String - MinLength: 20 + AllowedPattern: "^arn:[A-Za-z0-9-]+:states:[A-Za-z0-9-]+:[0-9]{12}:stateMachine:[A-Za-z0-9_+=.@-]+$" Resources: EcrAccessRole: @@ -47,7 +60,7 @@ Resources: Principal: { Service: build.apprunner.amazonaws.com } Action: sts:AssumeRole ManagedPolicyArns: - - arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess + - !Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess" Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } @@ -75,6 +88,17 @@ Resources: - Effect: Allow Action: states:StartExecution Resource: !Ref ProvisioningStateMachineArn + - PolicyName: EnforceSharedRequestLimits + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - dynamodb:DeleteItem + - dynamodb:GetItem + - dynamodb:PutItem + - dynamodb:UpdateItem + Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ControlTableName}" - PolicyName: ReadRuntimeProvisioningProgress PolicyDocument: Version: "2012-10-17" @@ -104,13 +128,15 @@ Resources: Condition: StringEquals: "ssm:resourceTag/AgentFormationDeployment": !Ref DeploymentName - - Sid: UseUploadCommandDocument + - Sid: UseBoundedRuntimeCommandDocuments Effect: Allow Action: ssm:SendCommand - Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunShellScript" + Resource: + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/${UploadDeliveryDocumentName}" + - !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/${OAuthRelayDocumentName}" - Sid: ObserveUploadCommands Effect: Allow - Action: ssm:GetCommandInvocation + Action: [ssm:GetCommandInvocation, ssm:CancelCommand] Resource: "*" - Sid: ResumeAndEndBrowserSessionsInThisAccount Effect: Allow @@ -167,9 +193,13 @@ Resources: - { Name: AWS_REGION, Value: !Ref AWS::Region } - { Name: AGENTFORMATION_DEPLOYMENT, Value: !Ref DeploymentName } - { Name: AUTH_COGNITO_ID, Value: !Ref UserPoolClientId } - - { Name: AUTH_COGNITO_ISSUER, Value: !Ref CognitoIssuer } + - { + Name: AUTH_COGNITO_ISSUER, + Value: !Sub "https://cognito-idp.${AWS::Region}.${AWS::URLSuffix}/${UserPoolId}", + } - { Name: AUTH_COGNITO_IDENTITY_PROVIDER, Value: IdentityCenter } - { Name: USER_REGISTRY_TABLE, Value: !Ref UserRegistryTableName } + - { Name: CONTROL_TABLE, Value: !Ref ControlTableName } - { Name: PROVISIONING_STATE_MACHINE_ARN, Value: !Ref ProvisioningStateMachineArn, @@ -179,6 +209,14 @@ Resources: Name: SESSION_DOCUMENT_NAME, Value: !Ref TerminalSessionDocumentName, } + - { + Name: UPLOAD_DELIVERY_DOCUMENT_NAME, + Value: !Ref UploadDeliveryDocumentName, + } + - { + Name: OAUTH_RELAY_DOCUMENT_NAME, + Value: !Ref OAuthRelayDocumentName, + } RuntimeEnvironmentSecrets: - { Name: AUTH_COGNITO_SECRET, diff --git a/web/Dockerfile b/web/Dockerfile index 25e63ba..3d3661a 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -21,11 +21,15 @@ RUN mkdir -p public && \ AUTH_COGNITO_IDENTITY_PROVIDER=IdentityCenter \ AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_build \ AUTH_URL=https://agentformation.example \ + AWS_REGION=us-east-1 \ AGENTFORMATION_DEPLOYMENT=agentformation \ + CONTROL_TABLE=build-only \ PROVISIONING_STATE_MACHINE_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:build-only \ USER_REGISTRY_TABLE=build-only \ UPLOAD_BUCKET=build-only \ SESSION_DOCUMENT_NAME=build-only \ + UPLOAD_DELIVERY_DOCUMENT_NAME=build-only \ + OAUTH_RELAY_DOCUMENT_NAME=build-only \ bun run build # Build the Next.js application on the host architecture. Turbopack is not diff --git a/web/bun.lock b/web/bun.lock index f2b3bfc..eb939ce 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -11,8 +11,10 @@ "@aws-sdk/client-sfn": "3.1114.0", "@aws-sdk/client-ssm": "3.1114.0", "@aws-sdk/lib-dynamodb": "3.1114.0", + "@aws-sdk/s3-presigned-post": "3.1114.0", "@aws-sdk/s3-request-presigner": "3.1114.0", "@radix-ui/react-slot": "^1.2.4", + "@smithy/node-http-handler": "4.11.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", @@ -105,6 +107,8 @@ "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.43", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw=="], + "@aws-sdk/s3-presigned-post": ["@aws-sdk/s3-presigned-post@3.1114.0", "", { "dependencies": { "@aws-sdk/client-s3": "3.1114.0", "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-MTJaXDjkz7I6fm5t8G640yurJvtHDJO3FuWnhodJjPL4a1NXSj56CbaWnxE2XvZR8tk21xudTK9uRQ/uhg3Txw=="], + "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1114.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-z1HQYonZxSJQj6JxWGTyNzOore/46bQ8ZFNAlZJlVs1Ot5oi4bB9AR409mJJrMH7YNjKaEP7bOx+wDoN/RheNQ=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.45", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA=="], diff --git a/web/package.json b/web/package.json index 8962250..d2b637d 100644 --- a/web/package.json +++ b/web/package.json @@ -20,8 +20,10 @@ "@aws-sdk/client-sfn": "3.1114.0", "@aws-sdk/client-ssm": "3.1114.0", "@aws-sdk/lib-dynamodb": "3.1114.0", + "@aws-sdk/s3-presigned-post": "3.1114.0", "@aws-sdk/s3-request-presigner": "3.1114.0", "@radix-ui/react-slot": "^1.2.4", + "@smithy/node-http-handler": "4.11.2", "@xterm/addon-fit": "^0.10.0", "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", diff --git a/web/src/app/api/environment/route.test.ts b/web/src/app/api/environment/route.test.ts new file mode 100644 index 0000000..fc06c2b --- /dev/null +++ b/web/src/app/api/environment/route.test.ts @@ -0,0 +1,201 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + acquireOperationLease: vi.fn(), + enforceRateLimit: vi.fn(), + getProvisioningStatus: vi.fn(), + getRuntimeForSubject: vi.fn(), + readJsonBody: vi.fn(), + releaseOperationLease: vi.fn(), + requireAuthenticatedIdentity: vi.fn(), + requireSameOriginJson: vi.fn(), + startRuntimeProvisioning: vi.fn(), +})); + +vi.mock("@/lib/admission-control", () => ({ + acquireOperationLease: mocks.acquireOperationLease, + enforceRateLimit: mocks.enforceRateLimit, + releaseOperationLease: mocks.releaseOperationLease, +})); +vi.mock("@/lib/authorization", () => ({ + requireAuthenticatedIdentity: mocks.requireAuthenticatedIdentity, +})); +vi.mock("@/lib/provisioning-status", () => ({ + getProvisioningStatus: mocks.getProvisioningStatus, +})); +vi.mock("@/lib/provisioning", () => ({ + startRuntimeProvisioning: mocks.startRuntimeProvisioning, +})); +vi.mock("@/lib/registry", () => ({ + getRuntimeForSubject: mocks.getRuntimeForSubject, +})); +vi.mock("@/lib/request-security", () => ({ + readJsonBody: mocks.readJsonBody, + requireSameOriginJson: mocks.requireSameOriginJson, +})); + +import { GET, POST } from "./route"; + +const subject = "00000000-0000-4000-8000-000000000000"; +const baseRuntime = { + userSub: subject, + email: "person@example.com", + runtimeStackName: "agentformation-runtime-0000000000004000", + updatedAt: "2026-08-27T12:00:00.000Z", +}; + +function request() { + return new NextRequest("https://agentformation.example/api/environment", { + method: "POST", + body: "{}", + headers: { "content-type": "application/json" }, + }); +} + +describe("environment creation", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireAuthenticatedIdentity.mockResolvedValue({ + subject, + email: "person@example.com", + }); + mocks.readJsonBody.mockResolvedValue({}); + mocks.acquireOperationLease.mockResolvedValue({ + controlKey: "lease-key", + leaseId: "lease-id", + }); + mocks.startRuntimeProvisioning.mockResolvedValue(undefined); + }); + + it.each([ + { + label: "disabled", + runtime: { + ...baseRuntime, + status: "disabled", + instanceId: "i-0123456789abcdef0", + }, + }, + { + label: "purged", + runtime: { + userSub: subject, + status: "purged", + updatedAt: "2026-08-27T12:00:00.000Z", + expiresAt: 1_777_000_000, + }, + }, + ])( + "denies an unexpired session after access is $label", + async ({ runtime }) => { + mocks.getRuntimeForSubject.mockResolvedValue(runtime); + + const response = await POST(request()); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: "Runtime access has been revoked", + }); + expect(mocks.acquireOperationLease).not.toHaveBeenCalled(); + expect(mocks.startRuntimeProvisioning).not.toHaveBeenCalled(); + }, + ); + + it("reports a purge tombstone as disabled without exposing its internal status", async () => { + mocks.getRuntimeForSubject.mockResolvedValue({ + userSub: subject, + status: "purged", + updatedAt: "2026-08-27T12:00:00.000Z", + expiresAt: 1_777_000_000, + }); + + const response = await GET(); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: "disabled" }); + expect(mocks.getProvisioningStatus).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: "active", + runtime: { + ...baseRuntime, + status: "active", + instanceId: "i-0123456789abcdef0", + }, + error: "Environment already exists", + }, + { + label: "provisioning", + runtime: { + ...baseRuntime, + status: "provisioning", + provisioningStartedAt: "2026-08-27T12:00:00.000Z", + }, + error: "Environment creation is already in progress", + }, + ])("does not start over an $label runtime", async ({ runtime, error }) => { + mocks.getRuntimeForSubject.mockResolvedValue(runtime); + mocks.getProvisioningStatus.mockResolvedValue({ + status: "provisioning", + progress: { + stage: "creating_access", + percent: 10, + label: "Creating access", + startedAt: "2026-08-27T12:00:00.000Z", + }, + }); + + const response = await POST(request()); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ error }); + expect(mocks.startRuntimeProvisioning).not.toHaveBeenCalled(); + }); + + it("allows a failed or stale provisioning record to be retried", async () => { + mocks.getRuntimeForSubject.mockResolvedValue({ + ...baseRuntime, + status: "provisioning", + provisioningStartedAt: "2026-08-27T12:00:00.000Z", + }); + mocks.getProvisioningStatus.mockResolvedValue({ status: "failed" }); + + const response = await POST(request()); + + expect(response.status).toBe(202); + expect(mocks.startRuntimeProvisioning).toHaveBeenCalledWith( + subject, + "person@example.com", + ); + expect(mocks.acquireOperationLease).toHaveBeenCalledWith( + subject, + "environment-create", + "runtime", + 120, + ); + expect(mocks.releaseOperationLease).toHaveBeenCalledWith(undefined); + }); + + it("releases the lease when starting Step Functions fails", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const lease = { controlKey: "lease-key", leaseId: "lease-id" }; + mocks.getRuntimeForSubject.mockResolvedValue(undefined); + mocks.acquireOperationLease.mockResolvedValue(lease); + mocks.startRuntimeProvisioning.mockRejectedValue(new Error("AWS failed")); + + const response = await POST(request()); + + expect(response.status).toBe(500); + expect(mocks.releaseOperationLease).toHaveBeenCalledWith(lease); + expect(errorLog).toHaveBeenCalledWith("environment.provision.failed", { + errorType: "Error", + }); + } finally { + errorLog.mockRestore(); + } + }); +}); diff --git a/web/src/app/api/environment/route.ts b/web/src/app/api/environment/route.ts index 77a151f..9d62a59 100644 --- a/web/src/app/api/environment/route.ts +++ b/web/src/app/api/environment/route.ts @@ -1,25 +1,55 @@ import { NextRequest } from "next/server"; import { z } from "zod"; -import { apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; +import { + acquireOperationLease, + enforceRateLimit, + releaseOperationLease, + type OperationLease, +} from "@/lib/admission-control"; +import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; import { requireAuthenticatedIdentity } from "@/lib/authorization"; -import { getProvisioningProgress } from "@/lib/provisioning-status"; +import { AWS_MUTATION_GUARD_TTL_SECONDS } from "@/lib/aws"; +import { getProvisioningStatus } from "@/lib/provisioning-status"; import { startRuntimeProvisioning } from "@/lib/provisioning"; import { getRuntimeForSubject } from "@/lib/registry"; -import { requireSameOriginJson } from "@/lib/request-security"; +import { readJsonBody, requireSameOriginJson } from "@/lib/request-security"; +import { isRuntimeAccessRevoked } from "@/lib/runtime-access"; const requestSchema = z.object({}).strict(); +async function requireProvisionableRuntime(subject: string): Promise { + const runtime = await getRuntimeForSubject(subject); + if (!runtime || runtime.status === "failed") return; + if (isRuntimeAccessRevoked(runtime)) { + throw new ApiError(403, "Runtime access has been revoked"); + } + if (runtime.status === "active") { + throw new ApiError(409, "Environment already exists"); + } + const current = await getProvisioningStatus(runtime); + if (current.status === "provisioning") { + throw new ApiError(409, "Environment creation is already in progress"); + } +} + export async function GET() { try { const { subject } = await requireAuthenticatedIdentity(); + await enforceRateLimit(subject, "environmentRead"); const runtime = await getRuntimeForSubject(subject); - const progress = + const provisioning = runtime?.status === "provisioning" - ? await getProvisioningProgress(runtime) + ? await getProvisioningStatus(runtime) : undefined; return apiJsonResponse({ - status: runtime?.status ?? "not_created", - progress, + status: + provisioning?.status ?? + (runtime?.status === "purged" ? "disabled" : runtime?.status) ?? + "not_created", + progress: + provisioning?.status === "provisioning" + ? provisioning.progress + : undefined, }); } catch (error) { return apiErrorResponse(error, "environment.read.failed"); @@ -27,11 +57,24 @@ export async function GET() { } export async function POST(request: NextRequest) { + let lease: OperationLease | undefined; try { requireSameOriginJson(request); - requestSchema.parse(await request.json()); const { subject, email } = await requireAuthenticatedIdentity(); + requestSchema.parse(await readJsonBody(request)); + await enforceRateLimit(subject, "environmentCreate"); + await requireProvisionableRuntime(subject); + lease = await acquireOperationLease( + subject, + "environment-create", + "runtime", + AWS_MUTATION_GUARD_TTL_SECONDS, + ); await startRuntimeProvisioning(subject, email); + // Keep the short lease until its DynamoDB expiry so another web request + // cannot slip into the gap before Step Functions reserves the registry. + // The state machine's conditional registry write remains the durable lock. + lease = undefined; return apiJsonResponse( { status: "provisioning", @@ -44,5 +87,7 @@ export async function POST(request: NextRequest) { ); } catch (error) { return apiErrorResponse(error, "environment.provision.failed"); + } finally { + await releaseOperationLease(lease); } } diff --git a/web/src/app/api/health/route.ts b/web/src/app/api/health/route.ts index 89a3a27..3ab10e1 100644 --- a/web/src/app/api/health/route.ts +++ b/web/src/app/api/health/route.ts @@ -1,5 +1,14 @@ import { apiJsonResponse } from "@/lib/api-error"; +import { validateApplicationEnvironment } from "@/lib/env"; export function GET() { - return apiJsonResponse({ status: "ok" }); + try { + validateApplicationEnvironment(); + return apiJsonResponse({ status: "ok" }); + } catch (error) { + console.error("health.configuration.invalid", { + errorType: error instanceof Error ? error.name : "UnknownError", + }); + return apiJsonResponse({ status: "unavailable" }, { status: 503 }); + } } diff --git a/web/src/app/api/oauth/loopback/route.test.ts b/web/src/app/api/oauth/loopback/route.test.ts new file mode 100644 index 0000000..29ca363 --- /dev/null +++ b/web/src/app/api/oauth/loopback/route.test.ts @@ -0,0 +1,180 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + acquireOperationLease: vi.fn(), + enforceRateLimit: vi.fn(), + readJsonBody: vi.fn(), + releaseOperationLease: vi.fn(), + requireAuthorizedRuntime: vi.fn(), + requireCurrentRuntimeAssignment: vi.fn(), + requireSameOriginJson: vi.fn(), + runDocumentCommand: vi.fn(), + send: vi.fn(), +})); + +vi.mock("@/lib/admission-control", () => ({ + acquireOperationLease: mocks.acquireOperationLease, + enforceRateLimit: mocks.enforceRateLimit, + releaseOperationLease: mocks.releaseOperationLease, +})); +vi.mock("@/lib/authorization", () => ({ + requireAuthorizedRuntime: mocks.requireAuthorizedRuntime, + requireCurrentRuntimeAssignment: mocks.requireCurrentRuntimeAssignment, +})); +vi.mock("@/lib/aws", () => ({ + AWS_REMOTE_COMMAND_GUARD_TTL_SECONDS: 600, + getS3Client: () => ({ send: mocks.send }), +})); +vi.mock("@/lib/env", () => ({ + getOAuthRelayDocumentName: () => "agentformation-oauth-relay", + getUploadBucketName: () => "agentformation-uploads", +})); +vi.mock("@/lib/request-security", () => ({ + readJsonBody: mocks.readJsonBody, + requireSameOriginJson: mocks.requireSameOriginJson, +})); +vi.mock("@/lib/ssm-command", async () => { + const { ApiError } = + await vi.importActual("@/lib/api-error"); + return { + RuntimeCommandStillRunningError: class extends ApiError { + constructor() { + super(504, "Runtime command state could not be confirmed"); + } + }, + runDocumentCommand: mocks.runDocumentCommand, + }; +}); + +import { POST } from "./route"; +import { RuntimeCommandStillRunningError } from "@/lib/ssm-command"; + +const subject = "00000000-0000-4000-8000-000000000000"; +const instanceId = "i-0123456789abcdef0"; +const callbackUrl = + "http://127.0.0.1:46189/callback/request_ID-1234?code=secret&state=state"; + +function request() { + return new NextRequest("https://agentformation.example/api/oauth/loopback", { + method: "POST", + body: JSON.stringify({ callbackUrl }), + headers: { "content-type": "application/json" }, + }); +} + +describe("OAuth callback relay", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireAuthorizedRuntime.mockResolvedValue({ + subject, + runtime: { instanceId }, + }); + mocks.requireCurrentRuntimeAssignment.mockResolvedValue(undefined); + mocks.readJsonBody.mockResolvedValue({ callbackUrl }); + mocks.acquireOperationLease.mockResolvedValue({ + controlKey: "lease-key", + leaseId: "lease-id", + }); + mocks.releaseOperationLease.mockResolvedValue(undefined); + mocks.send.mockResolvedValue({}); + mocks.runDocumentCommand.mockResolvedValue(""); + }); + + it("holds a cross-instance lease through the bounded remote command", async () => { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(mocks.acquireOperationLease).toHaveBeenCalledWith( + subject, + "oauth-relay", + instanceId, + 600, + ); + expect(mocks.requireCurrentRuntimeAssignment).toHaveBeenCalledWith( + subject, + instanceId, + ); + expect(mocks.runDocumentCommand).toHaveBeenCalledWith( + instanceId, + "agentformation-oauth-relay", + { + UploadBucket: ["agentformation-uploads"], + UserSubject: [subject], + RelayId: [expect.stringMatching(/^[0-9a-f-]{36}$/)], + }, + "Relay AgentFormation OAuth loopback callback", + ); + expect(mocks.releaseOperationLease).toHaveBeenCalledWith({ + controlKey: "lease-key", + leaseId: "lease-id", + }); + expect( + mocks.send.mock.calls.map(([command]) => command.constructor.name), + ).toEqual(["PutObjectCommand", "DeleteObjectCommand"]); + }); + + it("retains the callback and lease while command completion is uncertain", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + mocks.runDocumentCommand.mockRejectedValue( + new RuntimeCommandStillRunningError(), + ); + + const response = await POST(request()); + + expect(response.status).toBe(504); + expect( + mocks.send.mock.calls.map(([command]) => command.constructor.name), + ).toEqual(["PutObjectCommand"]); + expect(mocks.releaseOperationLease).not.toHaveBeenCalled(); + } finally { + errorLog.mockRestore(); + } + }); + + it("keeps a confirmed success when staging cleanup fails", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.send.mockImplementation(async (command: object) => { + if (command.constructor.name === "DeleteObjectCommand") { + throw Object.assign(new Error("private provider detail"), { + name: "ServiceUnavailableException", + }); + } + return {}; + }); + try { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(errorLog).toHaveBeenCalledWith( + "OAuth callback staging cleanup failed", + { errorName: "ServiceUnavailableException" }, + ); + expect(JSON.stringify(errorLog.mock.calls)).not.toContain(callbackUrl); + } finally { + errorLog.mockRestore(); + } + }); + + it("keeps a confirmed success when lease release fails", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.releaseOperationLease.mockRejectedValue( + Object.assign(new Error("private provider detail"), { + name: "ServiceUnavailableException", + }), + ); + try { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(errorLog).toHaveBeenCalledWith( + "OAuth callback lease release failed", + { errorName: "ServiceUnavailableException" }, + ); + expect(JSON.stringify(errorLog.mock.calls)).not.toContain(callbackUrl); + } finally { + errorLog.mockRestore(); + } + }); +}); diff --git a/web/src/app/api/oauth/loopback/route.ts b/web/src/app/api/oauth/loopback/route.ts index 1ef3e48..db85f2e 100644 --- a/web/src/app/api/oauth/loopback/route.ts +++ b/web/src/app/api/oauth/loopback/route.ts @@ -2,33 +2,69 @@ import { randomUUID } from "node:crypto"; import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; import { NextRequest } from "next/server"; import { z } from "zod"; +import { + acquireOperationLease, + enforceRateLimit, + releaseOperationLease, + type OperationLease, +} from "@/lib/admission-control"; import { apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; -import { requireAuthorizedRuntime } from "@/lib/authorization"; -import { getS3Client } from "@/lib/aws"; -import { getUploadBucketName } from "@/lib/env"; +import { + requireAuthorizedRuntime, + requireCurrentRuntimeAssignment, +} from "@/lib/authorization"; +import { AWS_REMOTE_COMMAND_GUARD_TTL_SECONDS, getS3Client } from "@/lib/aws"; +import { getOAuthRelayDocumentName, getUploadBucketName } from "@/lib/env"; import { validateOAuthCallbackUrl } from "@/lib/oauth-callback"; +import { createOAuthRelayPayload } from "@/lib/oauth-relay"; +import { readJsonBody, requireSameOriginJson } from "@/lib/request-security"; import { - buildOAuthRelayCommands, - serializeOAuthCallbackForCurl, -} from "@/lib/oauth-relay"; -import { requireSameOriginJson } from "@/lib/request-security"; -import { runShellCommand } from "@/lib/ssm-command"; + RuntimeCommandStillRunningError, + runDocumentCommand, +} from "@/lib/ssm-command"; export const runtime = "nodejs"; const requestSchema = z.object({ callbackUrl: z.unknown() }).strict(); +const CLEANUP_ATTEMPTS = 3; + +async function deleteOAuthStagingObject(bucket: string, key: string) { + let lastError: unknown; + for (let attempt = 1; attempt <= CLEANUP_ATTEMPTS; attempt += 1) { + try { + await getS3Client().send( + new DeleteObjectCommand({ Bucket: bucket, Key: key }), + ); + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} export async function POST(request: NextRequest) { let stagedObject: { bucket: string; key: string } | undefined; + let cleanupIsSafe = true; + let leaseReleaseIsSafe = true; + let admissionLease: OperationLease | undefined; try { requireSameOriginJson(request); const { subject, runtime: assignedRuntime } = await requireAuthorizedRuntime(); - const body = requestSchema.parse(await request.json()); + const body = requestSchema.parse(await readJsonBody(request)); const callback = validateOAuthCallbackUrl(body.callbackUrl); + await enforceRateLimit(subject, "oauthRelay"); + admissionLease = await acquireOperationLease( + subject, + "oauth-relay", + assignedRuntime.instanceId, + AWS_REMOTE_COMMAND_GUARD_TTL_SECONDS, + ); const bucket = getUploadBucketName(); - const key = `uploads/${subject}/${randomUUID()}/oauth-callback.curl`; + const relayId = randomUUID(); + const key = `uploads/${subject}/${relayId}/oauth-callback.url`; const s3 = getS3Client(); stagedObject = { bucket, key }; @@ -36,34 +72,48 @@ export async function POST(request: NextRequest) { new PutObjectCommand({ Bucket: bucket, Key: key, - Body: serializeOAuthCallbackForCurl(callback.callbackUrl), + Body: createOAuthRelayPayload(callback.callbackUrl), ContentType: "application/octet-stream", CacheControl: "no-store", }), ); - await runShellCommand( + await requireCurrentRuntimeAssignment(subject, assignedRuntime.instanceId); + await runDocumentCommand( assignedRuntime.instanceId, - buildOAuthRelayCommands(bucket, key), + getOAuthRelayDocumentName(), + { + UploadBucket: [bucket], + UserSubject: [subject], + RelayId: [relayId], + }, "Relay AgentFormation OAuth loopback callback", ); return apiJsonResponse({ ok: true }); } catch (error) { + if (error instanceof RuntimeCommandStillRunningError) { + cleanupIsSafe = false; + leaseReleaseIsSafe = false; + } return apiErrorResponse(error, "oauth.loopback.failed"); } finally { - if (stagedObject) { + if (stagedObject && cleanupIsSafe) { try { - await getS3Client().send( - new DeleteObjectCommand({ - Bucket: stagedObject.bucket, - Key: stagedObject.key, - }), - ); + await deleteOAuthStagingObject(stagedObject.bucket, stagedObject.key); } catch (error) { console.error("OAuth callback staging cleanup failed", { errorName: error instanceof Error ? error.name : "UnknownError", }); } } + if (leaseReleaseIsSafe) { + try { + await releaseOperationLease(admissionLease); + } catch (error) { + console.error("OAuth callback lease release failed", { + errorName: error instanceof Error ? error.name : "UnknownError", + }); + } + } } } diff --git a/web/src/app/api/session/resume/route.ts b/web/src/app/api/session/resume/route.ts index aedd53b..e777a84 100644 --- a/web/src/app/api/session/resume/route.ts +++ b/web/src/app/api/session/resume/route.ts @@ -1,40 +1,68 @@ import { ResumeSessionCommand } from "@aws-sdk/client-ssm"; import { NextRequest } from "next/server"; import { z } from "zod"; +import { + acquireOperationLease, + enforceRateLimit, + releaseOperationLease, + type OperationLease, +} from "@/lib/admission-control"; import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; -import { requireAuthorizedRuntime } from "@/lib/authorization"; -import { getSsmClient } from "@/lib/aws"; -import { requireSameOriginJson } from "@/lib/request-security"; +import { + requireAuthorizedRuntime, + requireCurrentRuntimeAssignment, +} from "@/lib/authorization"; +import { AWS_MUTATION_GUARD_TTL_SECONDS, getSsmClient } from "@/lib/aws"; +import { readJsonBody, requireSameOriginJson } from "@/lib/request-security"; import { verifyTerminateToken } from "@/lib/session-proof"; +import { validateSsmSessionResponse } from "@/lib/ssm-session-response"; -const requestSchema = z.object({ - sessionId: z.string().min(1).max(256), - terminateToken: z.string().min(1).max(256), -}); +const requestSchema = z + .object({ + sessionId: z.string().min(1).max(96), + terminateToken: z.string().min(1).max(512), + }) + .strict(); export async function POST(request: NextRequest) { + let lease: OperationLease | undefined; try { requireSameOriginJson(request); - const { subject } = await requireAuthorizedRuntime(); - const body = requestSchema.parse(await request.json()); - if (!verifyTerminateToken(subject, body.sessionId, body.terminateToken)) { + const { subject, runtime } = await requireAuthorizedRuntime(); + const body = requestSchema.parse(await readJsonBody(request)); + if ( + !verifyTerminateToken( + subject, + body.sessionId, + runtime.instanceId, + body.terminateToken, + ) + ) { throw new ApiError(403, "Forbidden"); } + await enforceRateLimit(subject, "sessionResume"); + lease = await acquireOperationLease( + subject, + "session-resume", + body.sessionId, + AWS_MUTATION_GUARD_TTL_SECONDS, + ); + await requireCurrentRuntimeAssignment(subject, runtime.instanceId); const response = await getSsmClient().send( new ResumeSessionCommand({ SessionId: body.sessionId }), ); - if (!response.SessionId || !response.StreamUrl || !response.TokenValue) { - throw new Error("Incomplete SSM resume response"); - } + const session = validateSsmSessionResponse(response, body.sessionId); return apiJsonResponse({ - sessionId: response.SessionId, - streamUrl: response.StreamUrl, - tokenValue: response.TokenValue, + sessionId: session.sessionId, + streamUrl: session.streamUrl, + tokenValue: session.tokenValue, terminateToken: body.terminateToken, }); } catch (error) { return apiErrorResponse(error, "session.resume.failed"); + } finally { + await releaseOperationLease(lease); } } diff --git a/web/src/app/api/session/start/route.test.ts b/web/src/app/api/session/start/route.test.ts new file mode 100644 index 0000000..e449f0b --- /dev/null +++ b/web/src/app/api/session/start/route.test.ts @@ -0,0 +1,180 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + acquireOperationLease: vi.fn(), + createTerminateToken: vi.fn(), + enforceRateLimit: vi.fn(), + readJsonBody: vi.fn(), + releaseOperationLease: vi.fn(), + requireAuthorizedRuntime: vi.fn(), + requireCurrentRuntimeAssignment: vi.fn(), + requireSameOriginJson: vi.fn(), + send: vi.fn(), + validateSsmSessionResponse: vi.fn(), +})); + +vi.mock("@/lib/admission-control", () => ({ + acquireOperationLease: mocks.acquireOperationLease, + enforceRateLimit: mocks.enforceRateLimit, + releaseOperationLease: mocks.releaseOperationLease, +})); +vi.mock("@/lib/authorization", () => ({ + requireAuthorizedRuntime: mocks.requireAuthorizedRuntime, + requireCurrentRuntimeAssignment: mocks.requireCurrentRuntimeAssignment, +})); +vi.mock("@/lib/aws", () => ({ + AWS_MUTATION_GUARD_TTL_SECONDS: 120, + getSsmClient: () => ({ send: mocks.send }), +})); +vi.mock("@/lib/env", () => ({ + getSessionDocumentName: () => "agentformation-terminal", +})); +vi.mock("@/lib/request-security", () => ({ + readJsonBody: mocks.readJsonBody, + requireSameOriginJson: mocks.requireSameOriginJson, +})); +vi.mock("@/lib/session-proof", () => ({ + createTerminateToken: mocks.createTerminateToken, +})); +vi.mock("@/lib/ssm-session-response", () => ({ + validateSsmSessionResponse: mocks.validateSsmSessionResponse, +})); + +import { POST } from "./route"; + +const subject = "00000000-0000-4000-8000-000000000000"; +const instanceId = "i-0123456789abcdef0"; + +function request(signal?: AbortSignal) { + const nextRequest = new NextRequest( + "https://agentformation.example/api/session/start", + { + method: "POST", + body: JSON.stringify({ tmuxSession: "code" }), + headers: { "content-type": "application/json" }, + }, + ); + if (signal) { + Object.defineProperty(nextRequest, "signal", { value: signal }); + } + return nextRequest; +} + +describe("session start", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireAuthorizedRuntime.mockResolvedValue({ + subject, + runtime: { instanceId }, + }); + mocks.readJsonBody.mockResolvedValue({ tmuxSession: "code" }); + mocks.acquireOperationLease.mockResolvedValue({ + controlKey: "lease-key", + leaseId: "lease-id", + }); + mocks.requireCurrentRuntimeAssignment.mockResolvedValue(undefined); + mocks.send.mockResolvedValue({ SessionId: "session-123" }); + mocks.validateSsmSessionResponse.mockReturnValue({ + sessionId: "session-123", + streamUrl: + "wss://ssmmessages.us-east-1.amazonaws.com/v1/data-channel/session-123", + tokenValue: "token-value", + }); + mocks.createTerminateToken.mockReturnValue("termination-proof"); + mocks.releaseOperationLease.mockResolvedValue(undefined); + }); + + it("hands a valid session to the browser without terminating it", async () => { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(mocks.send).toHaveBeenCalledTimes(1); + expect(mocks.send.mock.calls[0][0].constructor.name).toBe( + "StartSessionCommand", + ); + expect(mocks.releaseOperationLease).toHaveBeenCalledTimes(1); + }); + + it("terminates a session when the request aborts before handoff", async () => { + const controller = new AbortController(); + mocks.releaseOperationLease.mockImplementationOnce(async () => { + controller.abort(); + }); + + const response = await POST(request(controller.signal)); + + expect(response.status).toBe(408); + expect(mocks.send).toHaveBeenCalledTimes(2); + expect(mocks.send.mock.calls[1][0].constructor.name).toBe( + "TerminateSessionCommand", + ); + }); + + it("does not terminate a session after successful handoff", async () => { + const controller = new AbortController(); + + const response = await POST(request(controller.signal)); + controller.abort(); + await Promise.resolve(); + + expect(response.status).toBe(200); + expect(mocks.send).toHaveBeenCalledTimes(1); + }); + + it("terminates an AWS session when response validation fails", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + mocks.send + .mockResolvedValueOnce({ SessionId: "session-123" }) + .mockResolvedValueOnce({ SessionId: "session-123" }); + mocks.validateSsmSessionResponse.mockImplementation(() => { + throw new Error("Unexpected SSM stream URL"); + }); + + const response = await POST(request()); + + expect(response.status).toBe(500); + expect(mocks.send).toHaveBeenCalledTimes(2); + expect(mocks.send.mock.calls[1][0].constructor.name).toBe( + "TerminateSessionCommand", + ); + expect(mocks.send.mock.calls[1][0].input).toEqual({ + SessionId: "session-123", + }); + expect(mocks.acquireOperationLease).toHaveBeenCalledWith( + subject, + "session-start", + "runtime", + 120, + ); + } finally { + errorLog.mockRestore(); + } + }); + + it("logs when compensating termination cannot be confirmed", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + mocks.send + .mockResolvedValueOnce({ SessionId: "session-123" }) + .mockRejectedValueOnce( + Object.assign(new Error("cleanup failed"), { + name: "InternalServerError", + }), + ); + mocks.validateSsmSessionResponse.mockImplementation(() => { + throw new Error("Unexpected SSM stream URL"); + }); + + const response = await POST(request()); + + expect(response.status).toBe(500); + expect(errorLog).toHaveBeenCalledWith("session.start.cleanup.failed", { + errorType: "InternalServerError", + }); + } finally { + errorLog.mockRestore(); + } + }); +}); diff --git a/web/src/app/api/session/start/route.ts b/web/src/app/api/session/start/route.ts index 319bd88..a82282d 100644 --- a/web/src/app/api/session/start/route.ts +++ b/web/src/app/api/session/start/route.ts @@ -1,31 +1,75 @@ -import { StartSessionCommand } from "@aws-sdk/client-ssm"; +import { + StartSessionCommand, + TerminateSessionCommand, +} from "@aws-sdk/client-ssm"; import { NextRequest } from "next/server"; import { z } from "zod"; -import { apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; -import { requireAuthorizedRuntime } from "@/lib/authorization"; -import { getSsmClient } from "@/lib/aws"; +import { + acquireOperationLease, + enforceRateLimit, + releaseOperationLease, + type OperationLease, +} from "@/lib/admission-control"; +import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; +import { + requireAuthorizedRuntime, + requireCurrentRuntimeAssignment, +} from "@/lib/authorization"; +import { AWS_MUTATION_GUARD_TTL_SECONDS, getSsmClient } from "@/lib/aws"; import { getSessionDocumentName } from "@/lib/env"; -import { requireSameOriginJson } from "@/lib/request-security"; +import { readJsonBody, requireSameOriginJson } from "@/lib/request-security"; import { createTerminateToken } from "@/lib/session-proof"; +import { validateSsmSessionResponse } from "@/lib/ssm-session-response"; -const requestSchema = z.object({ - tmuxSession: z - .string() - .regex(/^[A-Za-z0-9_-]{1,32}$/) - .default("code"), - termOptions: z - .object({ - cols: z.number().int().min(20).max(240), - rows: z.number().int().min(5).max(200), - }) - .optional(), -}); +const requestSchema = z + .object({ + tmuxSession: z + .string() + .regex(/^[A-Za-z0-9_-]{1,32}$/) + .default("code"), + termOptions: z + .object({ + cols: z.number().int().min(20).max(240), + rows: z.number().int().min(5).max(200), + }) + .optional(), + }) + .strict(); export async function POST(request: NextRequest) { + let startedSessionId: string | undefined; + let sessionReturned = false; + let cleanupPromise: Promise | undefined; + let admissionLease: OperationLease | undefined; + const cleanupStartedSession = () => { + if (!startedSessionId) return Promise.resolve(); + cleanupPromise ??= getSsmClient() + .send(new TerminateSessionCommand({ SessionId: startedSessionId })) + .then(() => undefined) + .catch((error: unknown) => { + console.error("session.start.cleanup.failed", { + errorType: error instanceof Error ? error.name : "UnknownError", + }); + }); + return cleanupPromise; + }; + const handleRequestAbort = () => { + if (!sessionReturned) void cleanupStartedSession(); + }; + request.signal.addEventListener("abort", handleRequestAbort, { once: true }); + try { requireSameOriginJson(request); const { subject, runtime } = await requireAuthorizedRuntime(); - const body = requestSchema.parse(await request.json()); + const body = requestSchema.parse(await readJsonBody(request)); + await enforceRateLimit(subject, "sessionStart"); + admissionLease = await acquireOperationLease( + subject, + "session-start", + "runtime", + AWS_MUTATION_GUARD_TTL_SECONDS, + ); + await requireCurrentRuntimeAssignment(subject, runtime.instanceId); const response = await getSsmClient().send( new StartSessionCommand({ Target: runtime.instanceId, @@ -35,17 +79,44 @@ export async function POST(request: NextRequest) { }), ); - if (!response.SessionId || !response.StreamUrl || !response.TokenValue) { - throw new Error("Incomplete SSM session response"); + // Capture the raw ID before validating the rest of AWS's response so a + // malformed stream URL or token can still be compensated. + if (typeof response.SessionId === "string" && response.SessionId) { + startedSessionId = response.SessionId; + } + + const session = validateSsmSessionResponse(response); + if (request.signal.aborted) { + await cleanupStartedSession(); + throw new ApiError(408, "Session request was cancelled"); } - return apiJsonResponse({ - sessionId: response.SessionId, - streamUrl: response.StreamUrl, - tokenValue: response.TokenValue, - terminateToken: createTerminateToken(subject, response.SessionId), + const result = apiJsonResponse({ + sessionId: session.sessionId, + streamUrl: session.streamUrl, + tokenValue: session.tokenValue, + terminateToken: createTerminateToken( + subject, + session.sessionId, + runtime.instanceId, + ), }); + await releaseOperationLease(admissionLease); + admissionLease = undefined; + if (request.signal.aborted) { + await cleanupStartedSession(); + throw new ApiError(408, "Session request was cancelled"); + } + request.signal.removeEventListener("abort", handleRequestAbort); + sessionReturned = true; + return result; } catch (error) { + if (startedSessionId && !sessionReturned) { + await cleanupStartedSession(); + } return apiErrorResponse(error, "session.start.failed"); + } finally { + request.signal.removeEventListener("abort", handleRequestAbort); + if (admissionLease) await releaseOperationLease(admissionLease); } } diff --git a/web/src/app/api/session/terminate/route.test.ts b/web/src/app/api/session/terminate/route.test.ts new file mode 100644 index 0000000..75d580b --- /dev/null +++ b/web/src/app/api/session/terminate/route.test.ts @@ -0,0 +1,116 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + beginIdempotentAction: vi.fn(), + cancelIdempotentAction: vi.fn(), + completeIdempotentAction: vi.fn(), + enforceRateLimit: vi.fn(), + readJsonBody: vi.fn(), + requireAuthorizedRuntime: vi.fn(), + requireCurrentRuntimeAssignment: vi.fn(), + requireSameOriginJson: vi.fn(), + send: vi.fn(), + verifyTerminateToken: vi.fn(), +})); + +vi.mock("@/lib/admission-control", () => ({ + beginIdempotentAction: mocks.beginIdempotentAction, + cancelIdempotentAction: mocks.cancelIdempotentAction, + completeIdempotentAction: mocks.completeIdempotentAction, + enforceRateLimit: mocks.enforceRateLimit, +})); +vi.mock("@/lib/authorization", () => ({ + requireAuthorizedRuntime: mocks.requireAuthorizedRuntime, + requireCurrentRuntimeAssignment: mocks.requireCurrentRuntimeAssignment, +})); +vi.mock("@/lib/aws", () => ({ + AWS_MUTATION_GUARD_TTL_SECONDS: 120, + getSsmClient: () => ({ send: mocks.send }), +})); +vi.mock("@/lib/request-security", () => ({ + readJsonBody: mocks.readJsonBody, + requireSameOriginJson: mocks.requireSameOriginJson, +})); +vi.mock("@/lib/session-proof", () => ({ + SESSION_CONTROL_TOKEN_TTL_SECONDS: 46_800, + verifyTerminateToken: mocks.verifyTerminateToken, +})); + +import { POST } from "./route"; + +const subject = "00000000-0000-4000-8000-000000000000"; +const instanceId = "i-0123456789abcdef0"; +const body = { sessionId: "session-123", terminateToken: "proof" }; + +function request() { + return new NextRequest( + "https://agentformation.example/api/session/terminate", + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); +} + +describe("session termination", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireAuthorizedRuntime.mockResolvedValue({ + subject, + runtime: { instanceId }, + }); + mocks.readJsonBody.mockResolvedValue(body); + mocks.verifyTerminateToken.mockReturnValue(true); + mocks.requireCurrentRuntimeAssignment.mockResolvedValue(undefined); + mocks.beginIdempotentAction.mockResolvedValue({ + acquired: true, + completed: false, + lease: { controlKey: "once-key", leaseId: "lease-id" }, + }); + mocks.send.mockResolvedValue({ SessionId: body.sessionId }); + mocks.completeIdempotentAction.mockResolvedValue(undefined); + }); + + it("uses a guard longer than the AWS request budget", async () => { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(mocks.beginIdempotentAction).toHaveBeenCalledWith( + subject, + "session-terminate", + body.sessionId, + 120, + ); + expect(mocks.send).toHaveBeenCalledTimes(1); + expect(mocks.completeIdempotentAction).toHaveBeenCalledTimes(1); + }); + + it("does not repeat an action already recorded as complete", async () => { + mocks.beginIdempotentAction.mockResolvedValue({ + acquired: false, + completed: true, + }); + + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(mocks.send).not.toHaveBeenCalled(); + expect(mocks.cancelIdempotentAction).not.toHaveBeenCalled(); + }); + + it("fails closed and releases the marker when AWS does not confirm termination", async () => { + mocks.send.mockRejectedValue( + Object.assign(new Error("unavailable"), { + name: "InternalServerError", + }), + ); + + const response = await POST(request()); + + expect(response.status).toBe(500); + expect(mocks.completeIdempotentAction).not.toHaveBeenCalled(); + expect(mocks.cancelIdempotentAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/app/api/session/terminate/route.ts b/web/src/app/api/session/terminate/route.ts index bc54b5e..187d846 100644 --- a/web/src/app/api/session/terminate/route.ts +++ b/web/src/app/api/session/terminate/route.ts @@ -1,31 +1,72 @@ import { TerminateSessionCommand } from "@aws-sdk/client-ssm"; import { NextRequest } from "next/server"; import { z } from "zod"; +import { + beginIdempotentAction, + cancelIdempotentAction, + completeIdempotentAction, + enforceRateLimit, + type IdempotentAction, +} from "@/lib/admission-control"; import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; -import { requireAuthorizedRuntime } from "@/lib/authorization"; -import { getSsmClient } from "@/lib/aws"; -import { requireSameOriginJson } from "@/lib/request-security"; -import { verifyTerminateToken } from "@/lib/session-proof"; +import { + requireAuthorizedRuntime, + requireCurrentRuntimeAssignment, +} from "@/lib/authorization"; +import { AWS_MUTATION_GUARD_TTL_SECONDS, getSsmClient } from "@/lib/aws"; +import { readJsonBody, requireSameOriginJson } from "@/lib/request-security"; +import { + SESSION_CONTROL_TOKEN_TTL_SECONDS, + verifyTerminateToken, +} from "@/lib/session-proof"; -const requestSchema = z.object({ - sessionId: z.string().min(1).max(256), - terminateToken: z.string().min(1).max(256), -}); +const requestSchema = z + .object({ + sessionId: z.string().min(1).max(96), + terminateToken: z.string().min(1).max(512), + }) + .strict(); export async function POST(request: NextRequest) { + let action: IdempotentAction | undefined; + let terminationFinished = false; try { requireSameOriginJson(request); - const { subject } = await requireAuthorizedRuntime(); - const body = requestSchema.parse(await request.json()); - if (!verifyTerminateToken(subject, body.sessionId, body.terminateToken)) { + const { subject, runtime } = await requireAuthorizedRuntime(); + const body = requestSchema.parse(await readJsonBody(request)); + if ( + !verifyTerminateToken( + subject, + body.sessionId, + runtime.instanceId, + body.terminateToken, + ) + ) { throw new ApiError(403, "Forbidden"); } + await enforceRateLimit(subject, "sessionTerminate"); + await requireCurrentRuntimeAssignment(subject, runtime.instanceId); + action = await beginIdempotentAction( + subject, + "session-terminate", + body.sessionId, + AWS_MUTATION_GUARD_TTL_SECONDS, + ); + if (!action.acquired) { + if (action.completed) return apiJsonResponse({ ok: true }); + throw new ApiError(429, "Session termination is already being handled"); + } await getSsmClient().send( new TerminateSessionCommand({ SessionId: body.sessionId }), ); + terminationFinished = true; + await completeIdempotentAction(action, SESSION_CONTROL_TOKEN_TTL_SECONDS); return apiJsonResponse({ ok: true }); } catch (error) { + if (action?.acquired && !terminationFinished) { + await cancelIdempotentAction(action); + } return apiErrorResponse(error, "session.terminate.failed"); } } diff --git a/web/src/app/api/session/upload/route.test.ts b/web/src/app/api/session/upload/route.test.ts new file mode 100644 index 0000000..4a2add6 --- /dev/null +++ b/web/src/app/api/session/upload/route.test.ts @@ -0,0 +1,319 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + abandonUploadClaim: vi.fn(), + acquireOperationLease: vi.fn(), + claimUploadCompletion: vi.fn(), + completeUploadClaim: vi.fn(), + createPresignedPost: vi.fn(), + enforceRateLimit: vi.fn(), + readJsonBody: vi.fn(), + releaseOperationLease: vi.fn(), + registerUploadReservation: vi.fn(), + requireAuthorizedRuntime: vi.fn(), + requireCurrentRuntimeAssignment: vi.fn(), + requireSameOriginJson: vi.fn(), + runDocumentCommand: vi.fn(), + send: vi.fn(), +})); + +vi.mock("@aws-sdk/s3-presigned-post", () => ({ + createPresignedPost: mocks.createPresignedPost, +})); +vi.mock("@/lib/admission-control", () => ({ + acquireOperationLease: mocks.acquireOperationLease, + enforceRateLimit: mocks.enforceRateLimit, + releaseOperationLease: mocks.releaseOperationLease, +})); +vi.mock("@/lib/authorization", () => ({ + requireAuthorizedRuntime: mocks.requireAuthorizedRuntime, + requireCurrentRuntimeAssignment: mocks.requireCurrentRuntimeAssignment, +})); +vi.mock("@/lib/aws", () => ({ + AWS_REMOTE_COMMAND_GUARD_TTL_SECONDS: 600, + getS3Client: () => ({ send: mocks.send }), +})); +vi.mock("@/lib/env", () => ({ + getUploadBucketName: () => "agentformation-uploads", + getUploadDeliveryDocumentName: () => "agentformation-upload-delivery", +})); +vi.mock("@/lib/request-security", () => ({ + readJsonBody: mocks.readJsonBody, + requireSameOriginJson: mocks.requireSameOriginJson, +})); +vi.mock("@/lib/ssm-command", async () => { + const { ApiError } = + await vi.importActual("@/lib/api-error"); + return { + RuntimeCommandStillRunningError: class extends ApiError { + constructor() { + super(504, "Runtime command state could not be confirmed"); + } + }, + runDocumentCommand: mocks.runDocumentCommand, + }; +}); +vi.mock("@/lib/upload-admission", () => ({ + abandonUploadClaim: mocks.abandonUploadClaim, + claimUploadCompletion: mocks.claimUploadCompletion, + completeUploadClaim: mocks.completeUploadClaim, + registerUploadReservation: mocks.registerUploadReservation, +})); + +import { PATCH, POST } from "./route"; +import { RuntimeCommandStillRunningError } from "@/lib/ssm-command"; + +const subject = "00000000-0000-4000-8000-000000000000"; +const instanceId = "i-0123456789abcdef0"; +const completionBody = { + key: `uploads/${subject}/22222222-2222-4222-8222-222222222222/notes.txt`, + filename: "notes.txt", + mimeType: "text/plain", + fileSize: 12, + tmuxSession: "code", +}; + +function request(method: "PATCH" | "POST" = "POST") { + return new NextRequest("https://agentformation.example/api/session/upload", { + method, + body: JSON.stringify( + method === "POST" + ? { filename: "notes.txt", mimeType: "text/plain", fileSize: 12 } + : completionBody, + ), + headers: { "content-type": "application/json" }, + }); +} + +describe("upload route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.requireAuthorizedRuntime.mockResolvedValue({ + subject, + runtime: { instanceId }, + }); + mocks.requireCurrentRuntimeAssignment.mockResolvedValue(undefined); + mocks.readJsonBody.mockResolvedValue({ + filename: "notes.txt", + mimeType: "text/plain", + fileSize: 12, + }); + mocks.createPresignedPost.mockResolvedValue({ + url: "https://agentformation-uploads.s3.amazonaws.com", + fields: { key: "signed-key" }, + }); + mocks.registerUploadReservation.mockResolvedValue(undefined); + mocks.acquireOperationLease.mockResolvedValue({ + controlKey: "lease-key", + leaseId: "lease-id", + }); + mocks.claimUploadCompletion.mockResolvedValue({ + controlKey: "upload-key", + claimId: "claim-id", + }); + mocks.completeUploadClaim.mockResolvedValue(undefined); + mocks.abandonUploadClaim.mockResolvedValue(undefined); + mocks.releaseOperationLease.mockResolvedValue(undefined); + mocks.runDocumentCommand.mockResolvedValue(""); + mocks.send.mockResolvedValue({}); + }); + + it("enforces the declared size at the S3 upload boundary", async () => { + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(mocks.enforceRateLimit).toHaveBeenCalledWith( + subject, + "uploadBytes", + 12, + ); + expect(mocks.createPresignedPost).toHaveBeenCalledWith( + { send: mocks.send }, + expect.objectContaining({ + Conditions: expect.arrayContaining([["content-length-range", 12, 12]]), + }), + ); + }); + + it.each(["__init__.py", "-notes.txt", "文件.txt"])( + "returns a filename accepted by the fixed delivery document for %s", + async (filename) => { + mocks.readJsonBody.mockResolvedValue({ + filename, + mimeType: "text/plain", + fileSize: 12, + }); + + const response = await POST(request()); + const body = (await response.json()) as { filename: string }; + + expect(body.filename).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/); + expect(mocks.registerUploadReservation).toHaveBeenCalledWith( + expect.objectContaining({ filename: body.filename }), + ); + }, + ); + + it("seals and delivers only the exact reserved object", async () => { + mocks.readJsonBody.mockResolvedValue(completionBody); + mocks.send + .mockResolvedValueOnce({ + ContentLength: 12, + ContentType: "text/plain", + ETag: '"etag"', + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + ContentLength: 12, + ContentType: "text/plain", + }) + .mockResolvedValueOnce({ Errors: [] }); + + const response = await PATCH(request("PATCH")); + + expect(response.status).toBe(200); + expect(mocks.acquireOperationLease).toHaveBeenCalledWith( + subject, + "upload-complete", + instanceId, + 600, + ); + expect(mocks.claimUploadCompletion).toHaveBeenCalledWith({ + subject, + uploadId: "22222222-2222-4222-8222-222222222222", + objectKey: completionBody.key, + filename: "notes.txt", + mimeType: "text/plain", + fileSize: 12, + instanceId, + }); + expect( + mocks.send.mock.calls.map(([command]) => command.constructor.name), + ).toEqual([ + "HeadObjectCommand", + "CopyObjectCommand", + "HeadObjectCommand", + "DeleteObjectsCommand", + ]); + const copyCommand = mocks.send.mock.calls.find( + ([command]) => command.constructor.name === "CopyObjectCommand", + )?.[0] as { + input?: { + Bucket?: string; + Key?: string; + CopySource?: string; + CopySourceIfMatch?: string; + }; + }; + expect(copyCommand.input).toMatchObject({ + Bucket: "agentformation-uploads", + Key: `uploads/${subject}/22222222-2222-4222-8222-222222222222/sealed/notes.txt`, + CopySource: `agentformation-uploads/uploads/${subject}/22222222-2222-4222-8222-222222222222/notes.txt`, + CopySourceIfMatch: '"etag"', + }); + expect(mocks.requireCurrentRuntimeAssignment).toHaveBeenCalledTimes(2); + expect(mocks.runDocumentCommand).toHaveBeenCalledWith( + instanceId, + "agentformation-upload-delivery", + { + UploadBucket: ["agentformation-uploads"], + UserSubject: [subject], + UploadId: ["22222222-2222-4222-8222-222222222222"], + Filename: ["notes.txt"], + TmuxSession: ["code"], + FileSize: ["12"], + }, + "Copy AgentFormation upload to assigned runtime", + ); + expect(mocks.completeUploadClaim).toHaveBeenCalledWith({ + controlKey: "upload-key", + claimId: "claim-id", + }); + expect(mocks.abandonUploadClaim).not.toHaveBeenCalled(); + }); + + it("rejects a completion key outside the signed-in subject prefix", async () => { + mocks.readJsonBody.mockResolvedValue({ + ...completionBody, + key: "uploads/11111111-1111-4111-8111-111111111111/22222222-2222-4222-8222-222222222222/notes.txt", + }); + + const response = await PATCH(request("PATCH")); + + expect(response.status).toBe(403); + expect(mocks.claimUploadCompletion).not.toHaveBeenCalled(); + expect(mocks.send).not.toHaveBeenCalled(); + expect(mocks.runDocumentCommand).not.toHaveBeenCalled(); + }); + + it("retains sealed data when delivery succeeded but claim completion fails", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + mocks.readJsonBody.mockResolvedValue(completionBody); + mocks.send + .mockResolvedValueOnce({ + ContentLength: 12, + ContentType: "text/plain", + ETag: '"etag"', + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + ContentLength: 12, + ContentType: "text/plain", + }); + mocks.completeUploadClaim.mockRejectedValue(new Error("write failed")); + + const response = await PATCH(request("PATCH")); + + expect(response.status).toBe(500); + expect(mocks.runDocumentCommand).toHaveBeenCalledTimes(1); + expect(mocks.abandonUploadClaim).not.toHaveBeenCalled(); + expect( + mocks.send.mock.calls.map(([command]) => command.constructor.name), + ).toEqual([ + "HeadObjectCommand", + "CopyObjectCommand", + "HeadObjectCommand", + ]); + } finally { + errorLog.mockRestore(); + } + }); + + it("retains staged data when command completion cannot be confirmed", async () => { + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + mocks.readJsonBody.mockResolvedValue(completionBody); + mocks.send + .mockResolvedValueOnce({ + ContentLength: 12, + ContentType: "text/plain", + ETag: '"etag"', + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ + ContentLength: 12, + ContentType: "text/plain", + }); + mocks.runDocumentCommand.mockRejectedValue( + new RuntimeCommandStillRunningError(), + ); + + const response = await PATCH(request("PATCH")); + + expect(response.status).toBe(504); + expect( + mocks.send.mock.calls.map(([command]) => command.constructor.name), + ).toEqual([ + "HeadObjectCommand", + "CopyObjectCommand", + "HeadObjectCommand", + ]); + expect(mocks.abandonUploadClaim).not.toHaveBeenCalled(); + expect(mocks.releaseOperationLease).toHaveBeenCalledTimes(1); + } finally { + errorLog.mockRestore(); + } + }); +}); diff --git a/web/src/app/api/session/upload/route.ts b/web/src/app/api/session/upload/route.ts index bb2b157..cd19084 100644 --- a/web/src/app/api/session/upload/route.ts +++ b/web/src/app/api/session/upload/route.ts @@ -1,37 +1,60 @@ import { randomUUID } from "node:crypto"; import { - DeleteObjectCommand, + CopyObjectCommand, + DeleteObjectsCommand, HeadObjectCommand, - PutObjectCommand, } from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; import { NextRequest } from "next/server"; import { z } from "zod"; +import { + acquireOperationLease, + enforceRateLimit, + releaseOperationLease, + type OperationLease, +} from "@/lib/admission-control"; import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; -import { requireAuthorizedRuntime } from "@/lib/authorization"; -import { getS3Client } from "@/lib/aws"; -import { getUploadBucketName } from "@/lib/env"; -import { requireSameOriginJson } from "@/lib/request-security"; -import { runShellCommand } from "@/lib/ssm-command"; -import { shellQuote } from "@/lib/shell"; +import { + requireAuthorizedRuntime, + requireCurrentRuntimeAssignment, +} from "@/lib/authorization"; +import { AWS_REMOTE_COMMAND_GUARD_TTL_SECONDS, getS3Client } from "@/lib/aws"; +import { getUploadBucketName, getUploadDeliveryDocumentName } from "@/lib/env"; +import { readJsonBody, requireSameOriginJson } from "@/lib/request-security"; +import { + RuntimeCommandStillRunningError, + runDocumentCommand, +} from "@/lib/ssm-command"; +import { + abandonUploadClaim, + claimUploadCompletion, + completeUploadClaim, + registerUploadReservation, + type UploadCompletionClaim, +} from "@/lib/upload-admission"; export const runtime = "nodejs"; const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; const SIGNED_URL_SECONDS = 5 * 60; +const CLEANUP_ATTEMPTS = 3; -const createSchema = z.object({ - filename: z.string().min(1).max(255), - mimeType: z.string().min(1).max(120), - fileSize: z.number().int().positive().max(MAX_UPLOAD_BYTES), -}); -const completeSchema = z.object({ - key: z.string().min(1).max(1_024), - filename: z.string().min(1).max(255), - mimeType: z.string().min(1).max(120), - fileSize: z.number().int().positive().max(MAX_UPLOAD_BYTES), - tmuxSession: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), -}); +const createSchema = z + .object({ + filename: z.string().min(1).max(255), + mimeType: z.string().min(1).max(120), + fileSize: z.number().int().positive().max(MAX_UPLOAD_BYTES), + }) + .strict(); +const completeSchema = z + .object({ + key: z.string().min(1).max(1_024), + filename: z.string().min(1).max(255), + mimeType: z.string().min(1).max(120), + fileSize: z.number().int().positive().max(MAX_UPLOAD_BYTES), + tmuxSession: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), + }) + .strict(); function safeFilename(value: string): string { const basename = value.split(/[\\/]/).at(-1)?.trim() ?? ""; @@ -39,7 +62,7 @@ function safeFilename(value: string): string { basename .normalize("NFKD") .replace(/[^A-Za-z0-9._-]+/g, "_") - .replace(/^\.+/, "") + .replace(/^[^A-Za-z0-9]+/, "") .slice(0, 120) || "upload" ); } @@ -48,6 +71,13 @@ function uploadPrefix(subject: string): string { return `uploads/${subject}/`; } +function copySource(bucket: string, key: string): string { + return `${bucket}/${key + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/")}`; +} + function safeMimeType(value: string): string { if (/[\r\n]/.test(value)) { throw new ApiError(400, "Invalid file type"); @@ -55,35 +85,80 @@ function safeMimeType(value: string): string { return value.trim() || "application/octet-stream"; } +async function deleteStagedObjects( + bucket: string, + keys: readonly string[], +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= CLEANUP_ATTEMPTS; attempt += 1) { + try { + const response = await getS3Client().send( + new DeleteObjectsCommand({ + Bucket: bucket, + Delete: { + Quiet: true, + Objects: keys.map((Key) => ({ Key })), + }, + }), + ); + if ((response.Errors?.length ?? 0) > 0) { + throw new Error("S3 rejected staged-object cleanup"); + } + return; + } catch (error) { + lastError = error; + } + } + throw lastError; +} + export async function POST(request: NextRequest) { try { requireSameOriginJson(request); - const { subject } = await requireAuthorizedRuntime(); - const body = createSchema.parse(await request.json()); + const { subject, runtime } = await requireAuthorizedRuntime(); + const body = createSchema.parse(await readJsonBody(request)); + await requireCurrentRuntimeAssignment(subject, runtime.instanceId); + await enforceRateLimit(subject, "uploadCreate"); + await enforceRateLimit(subject, "uploadBytes", body.fileSize); const bucket = getUploadBucketName(); const s3 = getS3Client(); const filename = safeFilename(body.filename); const mimeType = safeMimeType(body.mimeType); - const key = `${uploadPrefix(subject)}${randomUUID()}/${filename}`; - const requiredHeaders = { "Content-Type": mimeType }; - const uploadUrl = await getSignedUrl( - s3, - new PutObjectCommand({ - Bucket: bucket, - Key: key, - ContentType: mimeType, - ContentLength: body.fileSize, - }), - { expiresIn: SIGNED_URL_SECONDS }, - ); + const uploadId = randomUUID(); + const key = `${uploadPrefix(subject)}${uploadId}/${filename}`; + const upload = await createPresignedPost(s3, { + Bucket: bucket, + Key: key, + Expires: SIGNED_URL_SECONDS, + Fields: { + key, + "Content-Type": mimeType, + success_action_status: "204", + }, + Conditions: [ + ["content-length-range", body.fileSize, body.fileSize], + ["eq", "$key", key], + ["eq", "$Content-Type", mimeType], + ["eq", "$success_action_status", "204"], + ], + }); + await registerUploadReservation({ + subject, + uploadId, + objectKey: key, + filename, + mimeType, + fileSize: body.fileSize, + instanceId: runtime.instanceId, + }); return apiJsonResponse({ key, filename, mimeType, fileSize: body.fileSize, - uploadUrl, - method: "PUT", - requiredHeaders, + uploadUrl: upload.url, + method: "POST", + formFields: upload.fields, }); } catch (error) { return apiErrorResponse(error, "session.upload.create.failed"); @@ -91,11 +166,17 @@ export async function POST(request: NextRequest) { } export async function PATCH(request: NextRequest) { + let cleanup: { bucket: string; keys: string[] } | undefined; + let cleanupIsSafe = true; + let completionClaim: UploadCompletionClaim | undefined; + let completionRecorded = false; + let admissionLease: OperationLease | undefined; + try { requireSameOriginJson(request); const { subject, runtime: assignedRuntime } = await requireAuthorizedRuntime(); - const body = completeSchema.parse(await request.json()); + const body = completeSchema.parse(await readJsonBody(request)); const prefix = uploadPrefix(subject); if (!body.key.startsWith(prefix)) { throw new ApiError(403, "Forbidden"); @@ -114,31 +195,81 @@ export async function PATCH(request: NextRequest) { } const bucket = getUploadBucketName(); + const sealedKey = `${prefix}${uploadId}/sealed/${filename}`; + await requireCurrentRuntimeAssignment(subject, assignedRuntime.instanceId); + await enforceRateLimit(subject, "uploadComplete"); + admissionLease = await acquireOperationLease( + subject, + "upload-complete", + assignedRuntime.instanceId, + AWS_REMOTE_COMMAND_GUARD_TTL_SECONDS, + ); + completionClaim = await claimUploadCompletion({ + subject, + uploadId, + objectKey: body.key, + filename, + mimeType, + fileSize: body.fileSize, + instanceId: assignedRuntime.instanceId, + }); + cleanup = { bucket, keys: [body.key, sealedKey] }; const s3 = getS3Client(); const object = await s3.send( new HeadObjectCommand({ Bucket: bucket, Key: body.key }), ); if ( object.ContentLength !== body.fileSize || - object.ContentLength > MAX_UPLOAD_BYTES + object.ContentLength > MAX_UPLOAD_BYTES || + object.ContentType !== mimeType || + !object.ETag ) { - throw new ApiError(400, "Uploaded file size does not match"); + throw new ApiError(400, "Uploaded file does not match"); } - const destinationDirectory = `/workspace/.uploads/${uploadId}`; - const destination = `${destinationDirectory}/${filename}`; - await runShellCommand( + await s3.send( + new CopyObjectCommand({ + Bucket: bucket, + Key: sealedKey, + CopySource: copySource(bucket, body.key), + CopySourceIfMatch: object.ETag, + MetadataDirective: "COPY", + }), + ); + const sealedObject = await s3.send( + new HeadObjectCommand({ Bucket: bucket, Key: sealedKey }), + ); + if ( + sealedObject.ContentLength !== body.fileSize || + sealedObject.ContentType !== mimeType + ) { + throw new ApiError(502, "Uploaded file could not be sealed"); + } + + const destination = `/workspace/.uploads/${uploadId}/${filename}`; + await requireCurrentRuntimeAssignment(subject, assignedRuntime.instanceId); + await runDocumentCommand( assignedRuntime.instanceId, - [ - `tmux has-session -t ${shellQuote(body.tmuxSession)}`, - `install -d -m 700 -o agentformation -g agentformation ${shellQuote(destinationDirectory)}`, - `aws s3 cp ${shellQuote(`s3://${bucket}/${body.key}`)} ${shellQuote(destination)}`, - `chown agentformation:agentformation ${shellQuote(destination)}`, - `chmod 600 ${shellQuote(destination)}`, - ], + getUploadDeliveryDocumentName(), + { + UploadBucket: [bucket], + UserSubject: [subject], + UploadId: [uploadId], + Filename: [filename], + TmuxSession: [body.tmuxSession], + FileSize: [String(body.fileSize)], + }, "Copy AgentFormation upload to assigned runtime", ); - await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: body.key })); + try { + await completeUploadClaim(completionClaim); + } catch (error) { + // Delivery succeeded, so retain the exact sealed input and claim for a + // safe retry instead of deleting the only recoverable copy. + cleanupIsSafe = false; + throw error; + } + completionRecorded = true; return apiJsonResponse({ path: destination, filename, @@ -146,6 +277,23 @@ export async function PATCH(request: NextRequest) { fileSize: body.fileSize, }); } catch (error) { + if (error instanceof RuntimeCommandStillRunningError) { + cleanupIsSafe = false; + } return apiErrorResponse(error, "session.upload.complete.failed"); + } finally { + if (cleanup && cleanupIsSafe) { + try { + await deleteStagedObjects(cleanup.bucket, cleanup.keys); + } catch (error) { + console.error("session.upload.cleanup.failed", { + errorType: error instanceof Error ? error.name : "UnknownError", + }); + } + } + if (completionClaim && !completionRecorded && cleanupIsSafe) { + await abandonUploadClaim(completionClaim); + } + await releaseOperationLease(admissionLease); } } diff --git a/web/src/app/layout.test.ts b/web/src/app/layout.test.ts new file mode 100644 index 0000000..ecc1d74 --- /dev/null +++ b/web/src/app/layout.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { viewport } from "./layout"; + +describe("mobile viewport", () => { + it("keeps the terminal at device scale like the main release", () => { + expect(viewport).toMatchObject({ + maximumScale: 1, + userScalable: false, + }); + }); +}); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index c93b481..72605bf 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,13 +1,14 @@ import { redirect } from "next/navigation"; -import { LogOut, ShieldCheck, TerminalSquare } from "lucide-react"; +import { ShieldCheck, TerminalSquare } from "lucide-react"; import { EnvironmentSetup } from "@/components/environment-setup"; import { MobileTerminal } from "@/components/mobile-terminal"; import { OAuthCallbackAction } from "@/components/oauth-callback-action"; +import { SignOutAction } from "@/components/sign-out-action"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; import { cachedAuth, signIn, signOut } from "@/lib/auth"; import { getRuntimeForSubject } from "@/lib/registry"; -import { isActiveRuntime } from "@/lib/runtime-access"; +import { isActiveRuntime, isRuntimeAccessRevoked } from "@/lib/runtime-access"; function SignInPage() { return ( @@ -51,11 +52,11 @@ export default async function Home() { if (!session?.user?.id || !session.user.email) return ; const runtime = await getRuntimeForSubject(session.user.id); - if (runtime?.status === "disabled") + if (isRuntimeAccessRevoked(runtime)) redirect("/auth-error?error=AccessDenied"); return ( -
+

@@ -70,23 +71,12 @@ export default async function Home() {

{isActiveRuntime(runtime) ? : null} -
{ "use server"; await signOut({ redirectTo: "/" }); }} - > - -
+ />
{isActiveRuntime(runtime) ? ( diff --git a/web/src/components/environment-setup.tsx b/web/src/components/environment-setup.tsx index f264cfa..bc0e87f 100644 --- a/web/src/components/environment-setup.tsx +++ b/web/src/components/environment-setup.tsx @@ -24,6 +24,12 @@ interface EnvironmentSetupProps { initialStartedAt?: string; } +const STATUS_REQUEST_TIMEOUT_MS = 10_000; +const MAX_CONSECUTIVE_STATUS_FAILURES = 5; +const MIN_STATUS_POLL_DELAY_MS = 3_000; +const MAX_STATUS_POLL_DELAY_MS = 15_000; +const MISSING_ENVIRONMENT_GRACE_MS = 60_000; + function formatElapsed(totalSeconds: number): string { if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); @@ -43,6 +49,7 @@ export function EnvironmentSetup({ ); const [error, setError] = useState(); const [pollWarning, setPollWarning] = useState(); + const [pollingStopped, setPollingStopped] = useState(false); const [submitting, setSubmitting] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -64,9 +71,26 @@ export function EnvironmentSetup({ let cancelled = false; let nextPoll: number | undefined; + let consecutiveFailures = 0; + const pollingStartedAt = Date.now(); + + function scheduleNext(delayMs: number) { + if (!cancelled) { + nextPoll = window.setTimeout(refreshProgress, delayMs); + } + } + async function refreshProgress() { + const controller = new AbortController(); + const timeout = window.setTimeout( + () => controller.abort(), + STATUS_REQUEST_TIMEOUT_MS, + ); try { - const response = await fetch("/api/environment", { cache: "no-store" }); + const response = await fetch("/api/environment", { + cache: "no-store", + signal: controller.signal, + }); const parsed = environmentResponseSchema.safeParse( await response.json().catch(() => null), ); @@ -74,7 +98,9 @@ export function EnvironmentSetup({ throw new Error("Status check failed"); } if (cancelled) return; + consecutiveFailures = 0; setPollWarning(undefined); + setPollingStopped(false); if (parsed.data.status === "active") { window.location.replace("/"); @@ -84,19 +110,43 @@ export function EnvironmentSetup({ setStatus("failed"); return; } + if (parsed.data.status === "disabled") { + window.location.replace("/auth-error?error=AccessDenied"); + return; + } + if ( + parsed.data.status === "not_created" && + Date.now() - pollingStartedAt >= MISSING_ENVIRONMENT_GRACE_MS + ) { + setStatus("failed"); + return; + } if (parsed.data.status === "provisioning" && parsed.data.progress) { setProgress(parsed.data.progress); } + + scheduleNext(MIN_STATUS_POLL_DELAY_MS); } catch { if (!cancelled) { - setPollWarning( - "The latest status check was missed. Retrying automatically…", + consecutiveFailures += 1; + if (consecutiveFailures >= MAX_CONSECUTIVE_STATUS_FAILURES) { + setPollingStopped(true); + setPollWarning( + "We could not confirm the latest AWS status. Check again when your connection is stable.", + ); + return; + } + + setPollWarning("The latest status check was missed. Retrying…"); + scheduleNext( + Math.min( + MAX_STATUS_POLL_DELAY_MS, + MIN_STATUS_POLL_DELAY_MS * 2 ** (consecutiveFailures - 1), + ), ); } - } - - if (!cancelled) { - nextPoll = window.setTimeout(refreshProgress, 3_000); + } finally { + window.clearTimeout(timeout); } } @@ -110,6 +160,7 @@ export function EnvironmentSetup({ async function createEnvironment() { setSubmitting(true); setError(undefined); + setPollWarning(undefined); try { const response = await fetch("/api/environment", { method: "POST", @@ -133,6 +184,7 @@ export function EnvironmentSetup({ progressForStage("confirming_access", new Date().toISOString()), ); setElapsedSeconds(0); + setPollingStopped(false); setStatus("provisioning"); } catch (requestError) { setError( @@ -267,16 +319,20 @@ export function EnvironmentSetup({ type="button" size="lg" className="w-full gap-2" - disabled={isProvisioning || submitting} - onClick={createEnvironment} + disabled={(isProvisioning && !pollingStopped) || submitting} + onClick={ + pollingStopped ? () => window.location.reload() : createEnvironment + } > - {isProvisioning || submitting ? ( + {(isProvisioning && !pollingStopped) || submitting ? ( ) : ( )} {isProvisioning - ? (currentStep?.label ?? "Environment is being created") + ? pollingStopped + ? "Check status" + : (currentStep?.label ?? "Environment is being created") : status === "failed" ? "Try again" : "Create environment"} diff --git a/web/src/components/mobile-terminal.tsx b/web/src/components/mobile-terminal.tsx index ca7a0b5..2310774 100644 --- a/web/src/components/mobile-terminal.tsx +++ b/web/src/components/mobile-terminal.tsx @@ -8,8 +8,10 @@ import { TerminalPane } from "@/components/terminal/terminal-pane"; import { TerminalTab, MobileTerminalProps, + MAX_TERMINAL_TABS, RECENT_CLOSED_TABS_LIMIT, - createTerminalTab, + TERMINAL_STORAGE_PREFIX, + createFreshTerminalTab, loadStoredTerminalState, useHydrated, } from "@/components/terminal/terminal-shared"; @@ -21,7 +23,7 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { initialState.closedTabs, ); const [activeTabId, setActiveTabId] = useState(initialState.activeTabId); - const [nextTabIndex, setNextTabIndex] = useState(initialState.nextTabIndex); + const nextTabLabelIndexRef = useRef(initialState.nextTabLabelIndex); const [mountedTabIds, setMountedTabIds] = useState( initialState.mountedTabIds, ); @@ -53,25 +55,31 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { () => tabs.find((tab) => tab.id === renamingTabId) ?? null, [renamingTabId, tabs], ); + const modalOpen = + showNewTabChooser || renamingTab !== null || pendingCloseTab !== null; useEffect(() => { if (!activeTab) return; - window.localStorage.setItem( - storageKey, - JSON.stringify({ - activeTabId: activeTab.id, - closedTabs, - nextTabIndex, - tabs, - }), - ); - }, [activeTab, closedTabs, nextTabIndex, storageKey, tabs]); + try { + window.localStorage.setItem( + storageKey, + JSON.stringify({ + activeTabId: activeTab.id, + closedTabs, + tabs, + }), + ); + } catch { + // The terminal remains usable when browser storage is unavailable or full. + } + }, [activeTab, closedTabs, storageKey, tabs]); const createFreshTab = useCallback(() => { - const nextTab = createTerminalTab(nextTabIndex); - setTabs([...tabs, nextTab]); - setNextTabIndex(nextTabIndex + 1); + if (tabs.length >= MAX_TERMINAL_TABS) return; + const nextTab = createFreshTerminalTab(nextTabLabelIndexRef.current); + nextTabLabelIndexRef.current += 1; + setTabs((currentTabs) => [...currentTabs, nextTab]); setMountedTabIds((currentIds) => [...currentIds, nextTab.id]); newTabDraftRef.current = { tabId: nextTab.id, @@ -81,24 +89,27 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { setShowNewTabChooser(false); setRenamingTabId(nextTab.id); setRenameValue(nextTab.label); - }, [activeTab.id, nextTabIndex, tabs]); + }, [activeTab.id, tabs.length]); const addTab = useCallback(() => { + if (tabs.length >= MAX_TERMINAL_TABS) return; if (closedTabs.length > 0) { setShowNewTabChooser(true); return; } createFreshTab(); - }, [closedTabs.length, createFreshTab]); + }, [closedTabs.length, createFreshTab, tabs.length]); const reopenClosedTab = useCallback( (tabId: string) => { const tabToReopen = closedTabs.find((tab) => tab.id === tabId); - if (!tabToReopen) return; + if (!tabToReopen || tabs.length >= MAX_TERMINAL_TABS) return; - setClosedTabs(closedTabs.filter((tab) => tab.id !== tabId)); - setTabs([...tabs, tabToReopen]); + setClosedTabs((currentClosedTabs) => + currentClosedTabs.filter((tab) => tab.id !== tabId), + ); + setTabs((currentTabs) => [...currentTabs, tabToReopen]); setMountedTabIds((currentIds) => currentIds.includes(tabId) ? currentIds : [...currentIds, tabId], ); @@ -227,91 +238,105 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { return (
-
-
+
+
+
+ {tabs.map((tab) => { + const isActive = tab.id === activeTab.id; + const isMounted = mountedTabIdSet.has(tab.id); + + return ( +
+ +
+ ); + })} +
+ +
+ +
{tabs.map((tab) => { + if (!mountedTabIdSet.has(tab.id)) return null; + const isActive = tab.id === activeTab.id; - const isMounted = mountedTabIdSet.has(tab.id); return (
- -
); })}
- -
- -
- {tabs.map((tab) => { - if (!mountedTabIdSet.has(tab.id)) return null; - - const isActive = tab.id === activeTab.id; - - return ( -
- -
- ); - })}
{showNewTabChooser && ( @@ -330,12 +355,17 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { Open Terminal Tab

- Create a fresh tmux session, or reopen one of your 20 most - recently closed tabs. + Create a fresh tmux session, or reopen a recently closed tab. Up + to {MAX_TERMINAL_TABS} tabs can be open at once.

- @@ -462,6 +492,7 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { variant="outline" size="sm" onClick={() => setPendingCloseTabId(null)} + autoFocus > Cancel @@ -483,7 +514,7 @@ function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { export function MobileTerminal({ storageScope }: MobileTerminalProps) { const hydrated = useHydrated(); - const storageKey = `mobile-terminal-tabs:${storageScope}`; + const storageKey = `${TERMINAL_STORAGE_PREFIX}${storageScope}`; if (!hydrated) return
; return ; } diff --git a/web/src/components/oauth-callback-action.tsx b/web/src/components/oauth-callback-action.tsx index 87dcfe2..acb6053 100644 --- a/web/src/components/oauth-callback-action.tsx +++ b/web/src/components/oauth-callback-action.tsx @@ -1,11 +1,14 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Link2 } from "lucide-react"; +import { z } from "zod"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; type OAuthStatus = { kind: "idle" | "success" | "error"; message: string }; +const successResponseSchema = z.object({ ok: z.literal(true) }).strict(); export function OAuthCallbackAction() { const [open, setOpen] = useState(false); @@ -16,6 +19,32 @@ export function OAuthCallbackAction() { }); const [submitting, setSubmitting] = useState(false); const textareaRef = useRef(null); + const triggerRef = useRef(null); + const dialogRef = useRef(null); + const requestIdRef = useRef(0); + const requestAbortRef = useRef(null); + + const close = useCallback(() => { + requestIdRef.current += 1; + requestAbortRef.current?.abort(); + requestAbortRef.current = null; + setSubmitting(false); + setOpen(false); + setCallbackUrl(""); + setStatus({ kind: "idle", message: "" }); + requestAnimationFrame(() => triggerRef.current?.focus()); + }, []); + + useEffect(() => { + if (!open) return; + const appShell = document.getElementById("agentformation-app-shell"); + if (!appShell) return; + const wasInert = appShell.inert; + appShell.inert = true; + return () => { + appShell.inert = wasInert; + }; + }, [open]); useEffect(() => { if (!open) return; @@ -25,8 +54,25 @@ export function OAuthCallbackAction() { }); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setOpen(false); - setStatus({ kind: "idle", message: "" }); + close(); + return; + } + if (event.key === "Tab" && dialogRef.current) { + const controls = Array.from( + dialogRef.current.querySelectorAll( + "textarea:not([disabled]),button:not([disabled])", + ), + ); + const first = controls[0]; + const last = controls.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } } }; @@ -35,7 +81,7 @@ export function OAuthCallbackAction() { cancelAnimationFrame(focusId); document.removeEventListener("keydown", handleKeyDown); }; - }, [open]); + }, [close, open]); const submitOAuthCallback = useCallback(async () => { const trimmedCallbackUrl = callbackUrl.trim(); @@ -49,6 +95,11 @@ export function OAuthCallbackAction() { setSubmitting(true); setStatus({ kind: "idle", message: "" }); + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + requestAbortRef.current?.abort(); + const requestController = new AbortController(); + requestAbortRef.current = requestController; try { const response = await fetch("/api/oauth/loopback", { @@ -56,12 +107,18 @@ export function OAuthCallbackAction() { headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ callbackUrl: trimmedCallbackUrl }), + signal: requestController.signal, }); - const body = await response - .json() - .catch(() => ({ error: "Unknown error" })); + const body = await response.json().catch(() => null); + if (requestId !== requestIdRef.current) return; if (!response.ok) { - throw new Error(body.error ?? `HTTP ${response.status}`); + const errorBody = z.object({ error: z.string() }).safeParse(body); + throw new Error( + errorBody.success ? errorBody.data.error : `HTTP ${response.status}`, + ); + } + if (!successResponseSchema.safeParse(body).success) { + throw new Error("The server returned an invalid delivery response"); } setCallbackUrl(""); @@ -71,6 +128,8 @@ export function OAuthCallbackAction() { "Callback delivered. Return to the terminal to finish sign-in.", }); } catch (error) { + if (requestId !== requestIdRef.current) return; + if (error instanceof DOMException && error.name === "AbortError") return; setStatus({ kind: "error", message: @@ -79,22 +138,24 @@ export function OAuthCallbackAction() { : "Failed to deliver OAuth callback.", }); } finally { - setSubmitting(false); + if (requestId === requestIdRef.current) { + requestAbortRef.current = null; + setSubmitting(false); + } } }, [callbackUrl]); - const close = useCallback(() => { - setOpen(false); - setStatus({ kind: "idle", message: "" }); - }, []); - return ( <> - {open && ( -
-
-
- -

- Seeing 127.0.0.1 refused to connect is - expected: the login listener is inside your private runtime, not - on this device. -

-
    -
  1. - Copy the complete URL from the failed page's address bar. -
  2. -
  3. Return to this AgentFormation tab and paste it below.
  4. -
  5. Send it while the remote tool is still waiting.
  6. -
-
- -