Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
67 changes: 56 additions & 11 deletions src/Commands/Connect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions tests/ApplicationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types=1);

namespace Bref\Cli\Test;

use Bref\Cli\Application;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

class ApplicationTest extends TestCase
{
public function test_a_forbidden_error_shows_the_message_of_the_api(): void
{
$e = Application::prettifyException($this->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<string, mixed> $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'));
}
}
38 changes: 38 additions & 0 deletions tests/Commands/ConnectTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types=1);

namespace Bref\Cli\Test\Commands;

use Aws\Command;
use Aws\Exception\AwsException;
use Bref\Cli\Commands\Connect;
use PHPUnit\Framework\TestCase;

class ConnectTest extends TestCase
{
public function test_a_denial_by_a_service_control_policy_explains_aws_projects(): void
{
$awsError = $this->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,
]);
}
}
Loading