diff --git a/src/Application.php b/src/Application.php index 333c4fd..dcdabdb 100644 --- a/src/Application.php +++ b/src/Application.php @@ -96,10 +96,14 @@ public static function prettifyException(Throwable $e): Throwable $message = $body['message'] ?? 'Unknown Bref Cloud error'; $statusCode = $e->getResponse()->getStatusCode(); - $message = match ($statusCode) { - 401 => 'Unauthenticated. Please log in with `bref login`.', - 403 => 'Forbidden. You do not have the required permissions. Do you need to login to a different team?', - 429 => 'Too many requests, try again in a minute.', + // Laravel's messages for a failed authorization, which say nothing about the cause + $isGenericForbidden = in_array($body['message'] ?? '', ['', 'This action is unauthorized.'], true); + + $message = match (true) { + $statusCode === 401 => 'Unauthenticated. Please log in with `bref login`.', + // Other 403 responses explain their cause (e.g. Bref Cloud cannot access the AWS account) + $statusCode === 403 && $isGenericForbidden => 'Forbidden. You do not have the required permissions. Do you need to login to a different team?', + $statusCode === 429 => 'Too many requests, try again in a minute.', default => $message, }; diff --git a/src/Commands/Connect.php b/src/Commands/Connect.php index da9d344..3f2e5ff 100644 --- a/src/Commands/Connect.php +++ b/src/Commands/Connect.php @@ -3,6 +3,8 @@ namespace Bref\Cli\Commands; use Aws\CloudFormation\CloudFormationClient; +use Aws\Exception\AwsException; +use Aws\Exception\CredentialsException; use Aws\Sts\StsClient; use Bref\Cli\BrefCloudClient; use Bref\Cli\Cli\IO; @@ -24,7 +26,7 @@ protected function configure(): void $this ->setName('connect') ->setDescription('Connect an AWS account to Bref Cloud using the AWS credentials configured on your machine') - ->addOption('profile', null, InputOption::VALUE_REQUIRED, 'The AWS profile to use', 'default'); + ->addOption('profile', null, InputOption::VALUE_REQUIRED, 'The AWS profile to use (defaults to the AWS_PROFILE environment variable, then to "default")'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -35,10 +37,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'Retrieving information...', ]); - /** @var string $awsProfile */ + /** @var string|null $awsProfile */ $awsProfile = $input->getOption('profile'); - - putenv('AWS_PROFILE=' . $awsProfile); + if ($awsProfile !== null) { + putenv('AWS_PROFILE=' . $awsProfile); + } else { + // Keep the profile selected with `export AWS_PROFILE=...`, like the AWS CLI + $awsProfile = getenv('AWS_PROFILE') ?: 'default'; + } $accountId = $this->getCurrentAwsAccountId($awsProfile); // TODO verbose only @@ -116,11 +122,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $stackParameters['RoleName'] = $details['role_name']; } $cloudFormation = new CloudFormation($cloudFormationClient); - $cloudFormation->deploy( - $details['stack_name'], - $details['template_url'], - $stackParameters, - ); + try { + $cloudFormation->deploy( + $details['stack_name'], + $details['template_url'], + $stackParameters, + ); + } catch (AwsException $e) { + throw self::explainDeployError($e, $details['stack_name'], $details['region']); + } if (!$isConnected && $accountName) { IO::spin('adding to Bref Cloud'); @@ -140,14 +150,49 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + /** + * AWS accounts created with "Sign up for AWS (new)" (AWS projects) have AWS-managed service control + * policies: they deny CloudFormation outside of the project's region, and Bref Cloud could not + * access the account anyway. The raw AWS error does not say any of that. + */ + public static function explainDeployError(AwsException $e, string $stackName, string $region): Exception + { + $awsMessage = $e->getAwsErrorMessage() ?: $e->getMessage(); + if (! str_contains($awsMessage, 'explicit deny in a service control policy')) { + return $e; + } + + return new Exception( + "AWS denied the deployment of the '$stackName' CloudFormation stack in $region: $awsMessage\n\n" + . 'If this AWS account was created with "Sign up for AWS (new)", it is an AWS project: AWS blocks Bref Cloud from connecting to AWS projects. ' + . 'Create an AWS account with "Sign up for AWS (advanced)" instead: https://bref.sh/docs/setup#aws-projects', + previous: $e, + ); + } + private function getCurrentAwsAccountId(string $profile): string { $sts = new StsClient([ 'region' => 'us-east-1', ]); - return $sts->getCallerIdentity()->toArray()['Account'] ?? - throw new RuntimeException('Could not determine the AWS account ID'); + try { + $identity = $sts->getCallerIdentity()->toArray(); + } catch (CredentialsException $e) { + // The AWS SDK tries each credential provider in turn and only reports the error of the last one + // (the EC2 instance metadata service), which hides the actual cause, e.g. an expired `aws login` session + if (str_contains($e->getMessage(), 'instance profile metadata service')) { + throw new Exception( + "No valid AWS credentials found for the AWS profile '$profile'. " + . "If you log in with `aws login`, run `aws login --profile $profile` again: its sessions expire after 12 hours. " + . 'Use the `--profile` option to select another AWS profile.', + previous: $e, + ); + } + throw $e; + } + + return $identity['Account'] ?? throw new RuntimeException('Could not determine the AWS account ID'); } private function selectTeam(BrefCloudClient $brefCloud): int diff --git a/tests/ApplicationTest.php b/tests/ApplicationTest.php new file mode 100644 index 0000000..4888aa3 --- /dev/null +++ b/tests/ApplicationTest.php @@ -0,0 +1,46 @@ +apiError(403, [ + 'message' => 'Bref Cloud is not authorized to access the AWS account "production": it could not assume the role arn:aws:iam::123456789012:role/BrefCloudAccess.', + ])); + + $this->assertSame( + 'Bref Cloud API error: [403] Bref Cloud is not authorized to access the AWS account "production": it could not assume the role arn:aws:iam::123456789012:role/BrefCloudAccess.', + $e->getMessage(), + ); + } + + public function test_a_failed_authorization_suggests_logging_in_to_another_team(): void + { + $expected = 'Bref Cloud API error: [403] Forbidden. You do not have the required permissions. Do you need to login to a different team?'; + + $this->assertSame($expected, Application::prettifyException($this->apiError(403, ['message' => 'This action is unauthorized.']))->getMessage()); + $this->assertSame($expected, Application::prettifyException($this->apiError(403, ['message' => '']))->getMessage()); + $this->assertSame($expected, Application::prettifyException($this->apiError(403, []))->getMessage()); + } + + /** + * @param array $body + */ + private function apiError(int $statusCode, array $body): ClientException + { + $client = new MockHttpClient(new MockResponse(json_encode($body, JSON_THROW_ON_ERROR), [ + 'http_code' => $statusCode, + 'response_headers' => ['content-type' => 'application/json'], + ])); + + return new ClientException($client->request('POST', 'https://bref.cloud/api/v1/deployments')); + } +} diff --git a/tests/Commands/ConnectTest.php b/tests/Commands/ConnectTest.php new file mode 100644 index 0000000..87f6b4a --- /dev/null +++ b/tests/Commands/ConnectTest.php @@ -0,0 +1,38 @@ +awsError('User: arn:aws:sts::123456789012:assumed-role/AccountFullAccessRole/abc is not authorized to perform: cloudformation:DescribeStacks on resource: arn:aws:cloudformation:us-east-1:123456789012:stack/bref-cloud-connect/* with an explicit deny in a service control policy: arn:aws:organizations::111111111111:policy/o-abc/service_control_policy/p-abc'); + + $e = Connect::explainDeployError($awsError, 'bref-cloud-connect', 'us-east-1'); + + $this->assertStringStartsWith("AWS denied the deployment of the 'bref-cloud-connect' CloudFormation stack in us-east-1: User: arn:aws:sts::123456789012:assumed-role/AccountFullAccessRole/abc is not authorized", $e->getMessage()); + $this->assertStringContainsString('"Sign up for AWS (new)", it is an AWS project', $e->getMessage()); + $this->assertStringContainsString('https://bref.sh/docs/setup#aws-projects', $e->getMessage()); + $this->assertSame($awsError, $e->getPrevious()); + } + + public function test_other_aws_errors_are_left_unchanged(): void + { + $awsError = $this->awsError('Stack with id bref-cloud-connect does not exist'); + + $this->assertSame($awsError, Connect::explainDeployError($awsError, 'bref-cloud-connect', 'us-east-1')); + } + + private function awsError(string $awsMessage): AwsException + { + return new AwsException('Error executing "DescribeStacks"; AWS HTTP error: ' . $awsMessage, new Command('DescribeStacks'), [ + 'code' => 'AccessDenied', + 'message' => $awsMessage, + ]); + } +}