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
14 changes: 13 additions & 1 deletion docs/guide/provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ YOLO groups every resource by **ownership scope** β€” the blast radius if it cha
| Command | Scope | Blast radius | Provisions |
|---|---|---|---|
| `yolo sync:account <env>` | **Account** | the whole AWS account | GitHub OIDC provider |
| `yolo sync:environment <env>` | **Environment** | every app in the environment | VPC, subnets, internet gateway & routes, RDS security group, SNS alarm topic, the shared ECS execution IAM role, the ALB and its `:80`/`:443` listeners |
| `yolo sync:environment <env>` | **Environment** | every app in the environment | VPC, subnets, internet gateway & routes, RDS security group, SNS alarm topic, the shared ECS execution IAM role, the ALB and its `:80`/`:443` listeners, the [WAF](#web-application-firewall) fronting the ALB |
| `yolo sync:app <env>` | **App** | one app | S3 buckets, app IAM (deployer role/policy, the per-app ECS task role + any [`task-role-policies`](/reference/manifest#task-role-policies)), ECS cluster/service/task definition, target group + listener rule, CloudFront distribution, hosted zone & ACM certificate, SQS queues, CloudWatch dashboard β€” plus, for web apps, the shared [Valkey cache](#cache-and-sessions) (default-on; opt out via `cache.store`). Sessions ride the same Valkey cluster by default, so they need no provisioning of their own |

The bare `yolo sync` runs all three **in dependency order** β€” account, then environment, then app:
Expand All @@ -30,6 +30,18 @@ The shared **Valkey cache** is env-scoped but bootstrapped from `sync:app` by ex
Several apps can share one environment's VPC and load balancer. Because `sync:app` only attaches and never mutates, deploying app B can't break app A's networking. When you're iterating on one app, `sync:app` is faster than a full `sync` β€” the account and environment tiers rarely change.
:::

## Web application firewall

Every environment with a load balancer gets a managed [AWS WAF](https://docs.aws.amazon.com/waf/latest/developerguide/what-is-aws-waf.html) web ACL on its ALB β€” automatically, with no manifest key. It's compulsory infrastructure, like the ALB itself: one web ACL protects every app sharing the load balancer.

YOLO owns the **policy** β€” a baseline of AWS-managed protections, a per-IP rate limit, and a high-risk-country block β€” and reconciles it on every sync. You own the **operational lists**:

- The **allow** and **block IP sets** are seeded empty for you to fill (known-good IPs to allow; abusive sources to block). Their contents are **create-only** β€” an entry you add in the console survives every subsequent `sync`.
- The **country block** is seeded with a sensible default and is likewise yours to re-scope; it's **seed-only**, so your edits stick.
- Any **rule you add by hand** is preserved too β€” YOLO only ever rewrites the rules it owns.

Tune the rest in the AWS console; YOLO won't undo it.

## Plan, confirm, apply

`sync` never surprises you. It runs as a three-step flow:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ Arguments and options as [`sync`](#sync-options). Scope: **account**.

## `yolo sync:environment`

Sync the environment-shared (environment-tier) resources β€” VPC, subnets, internet gateway and routes, the load balancer security group, the ALB and its `:80` listener, the SNS alarm topic, and the shared ECS execution IAM role.
Sync the environment-shared (environment-tier) resources β€” VPC, subnets, internet gateway and routes, the load balancer security group, the ALB and its `:80` listener, the SNS alarm topic, the shared ECS execution IAM role, and the [WAF web ACL](/guide/provisioning#web-application-firewall) (with its allow/block IP sets) fronting the ALB.

```bash
yolo sync:environment <environment> [--check] [--force] [--no-progress] [--tenant=<id>]
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ ivs:

MediaConvert role ARN for video transcoding workloads (used with IVS).

::: tip No `waf` key
The [web application firewall](/guide/provisioning#web-application-firewall) is a **compulsory** environment resource β€” every environment with a load balancer gets one automatically, so there's nothing to configure here. Day-to-day tuning happens in its allow/block IP sets, not the manifest.
:::

### `task-role-policies`

Extra IAM policy ARNs to attach to this app's ECS **task role** β€” the runtime identity its containers (web, queue and scheduler) assume. YOLO gives every app its own task role, so these grants reach only this app and never another. This is how you let your container call an AWS service YOLO doesn't wire for you (an extra S3 bucket, DynamoDB, Bedrock, …): the role carries the access, so the app authenticates as itself with no credentials to manage.
Expand Down
1 change: 1 addition & 0 deletions src/Audit/Audit.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class Audit
'S3' => 's3',
'Sns' => 'sns',
'Sqs' => 'sqs',
'WafV2' => 'wafv2',
];

/**
Expand Down
25 changes: 25 additions & 0 deletions src/Aws.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Aws\Sqs\SqsClient;
use Aws\Ssm\SsmClient;
use Aws\Sts\StsClient;
use Aws\WAFV2\WAFV2Client;
use Aws\AwsClientInterface;
use Illuminate\Support\Str;
use Aws\Route53\Route53Client;
Expand Down Expand Up @@ -505,6 +506,25 @@ public static function synchroniseCloudFrontTags(string $arn, array $tags, bool
);
}

/**
* Synchronise tags on a WAFv2 resource (web ACL, IP set), addressed by its
* ARN. WAFv2 nests the live tags under TagInfoForResource.TagList.
*
* @return array<string, string>
*/
public static function synchroniseWafV2Tags(string $arn, array $tags, bool $apply): array
{
return static::reconcileTags(
$tags,
fn () => static::wafV2()->listTagsForResource(['ResourceARN' => $arn])['TagInfoForResource']['TagList'] ?? [],
fn (array $missing) => static::wafV2()->tagResource([
'ResourceARN' => $arn,
'Tags' => static::keyValueTags($missing),
]),
$apply,
);
}

/**
* The standard upper-case `[{Key, Value}]` tag-list shape most AWS tagging
* APIs accept on write, built from an associative {key => value} map.
Expand Down Expand Up @@ -682,4 +702,9 @@ public static function sts(): StsClient
{
return Helpers::app('sts');
}

public static function wafV2(): WAFV2Client
{
return Helpers::app('wafV2');
}
}
73 changes: 73 additions & 0 deletions src/Aws/WafV2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Codinglabs\Yolo\Aws;

use Codinglabs\Yolo\Aws;
use Codinglabs\Yolo\Exceptions\ResourceDoesNotExistException;

/**
* Thin wrapper over the WAFv2 control plane. Every YOLO WAF resource is
* `REGIONAL` (the scope that protects an Application Load Balancer β€” `CLOUDFRONT`
* scope lives only in us-east-1 and isn't used here), so the scope is baked in
* rather than threaded through every call.
*
* WAFv2 has no get-by-name: you list the summaries (which already carry the Id,
* ARN and LockToken needed for reads, updates and tagging) and match on Name.
* Both lookups page through NextMarker and throw when the name is absent, so
* callers get the same exists()/arn() shape every other `src/Aws/*` wrapper has.
*/
class WafV2
{
public const SCOPE = 'REGIONAL';

/**
* The WebACL summary {Name, Id, ARN, LockToken} for the given name.
*
* @return array<string, string>
*/
public static function webAcl(string $name): array
{
return static::findByName('listWebACLs', 'WebACLs', $name)
?? throw new ResourceDoesNotExistException("Could not find WAF web ACL $name");
}

/**
* The IPSet summary {Name, Id, ARN, LockToken} for the given name.
*
* @return array<string, string>
*/
public static function ipSet(string $name): array
{
return static::findByName('listIPSets', 'IPSets', $name)
?? throw new ResourceDoesNotExistException("Could not find WAF IP set $name");
}

/**
* Page through a WAFv2 list operation and return the first summary whose
* Name matches, or null. WAFv2 list pages are capped, so NextMarker is
* followed to completion.
*
* @return array<string, string>|null
*/
protected static function findByName(string $operation, string $key, string $name): ?array
{
$marker = null;

do {
$response = Aws::wafV2()->{$operation}(array_filter([
'Scope' => static::SCOPE,
'NextMarker' => $marker,
]));

foreach ($response[$key] ?? [] as $summary) {
if ($summary['Name'] === $name) {
return $summary;
}
}

$marker = $response['NextMarker'] ?? null;
} while ($marker !== null && ($response[$key] ?? []) !== []);

return null;
}
}
7 changes: 7 additions & 0 deletions src/Commands/SyncEnvironmentCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ public function scopes(): array
// load balancer + :80 listener
Steps\Sync\Environment\SyncLoadBalancerStep::class,
Steps\Sync\Environment\SyncHttpListenerStep::class,
// WAF (opt-in via `waf: true`) β€” the IP sets are referenced by the
// web ACL's rules, so they're created first; the ACL is then bound
// to the load balancer. Inert unless the manifest enables it.
Steps\Sync\Environment\SyncWafAllowIpSetStep::class,
Steps\Sync\Environment\SyncWafBlockIpSetStep::class,
Steps\Sync\Environment\SyncWafWebAclStep::class,
Steps\Sync\Environment\SyncWafAssociationStep::class,
],
];
}
Expand Down
2 changes: 2 additions & 0 deletions src/Concerns/RegistersAws.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Aws\Sts\StsClient;
use GuzzleHttp\Client;
use Codinglabs\Yolo\Aws;
use Aws\WAFV2\WAFV2Client;
use Codinglabs\Yolo\Helpers;
use Codinglabs\Yolo\Manifest;
use Aws\Route53\Route53Client;
Expand Down Expand Up @@ -68,6 +69,7 @@ protected function registerAwsServices(): void
Helpers::app()->singleton('sqs', fn (): SqsClient => new SqsClient($arguments));
Helpers::app()->singleton('ssm', fn (): SsmClient => new SsmClient($arguments));
Helpers::app()->singleton('sts', fn (): StsClient => new StsClient($arguments));
Helpers::app()->singleton('wafV2', fn (): WAFV2Client => new WAFV2Client($arguments));
}

protected static function awsCredentials(): callable|array|null
Expand Down
71 changes: 71 additions & 0 deletions src/Resources/CloudWatch/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@
use Illuminate\Support\Str;
use Codinglabs\Yolo\Helpers;
use Codinglabs\Yolo\Manifest;
use Codinglabs\Yolo\Aws\WafV2;
use Codinglabs\Yolo\Aws\CloudFront;
use Codinglabs\Yolo\Aws\CloudWatch;
use Codinglabs\Yolo\Resources\WafV2\WebAcl;
use Codinglabs\Yolo\Resources\Ecs\EcsCluster;
use Codinglabs\Yolo\Resources\Ecs\EcsService;
use Codinglabs\Yolo\Resources\S3\AssetBucket;
Expand Down Expand Up @@ -170,6 +172,10 @@ public function resolveContext(): array
'clusterName' => $web ? (new EcsCluster())->name() : null,
'serviceName' => $web ? (new EcsService())->name() : null,
'albSuffix' => $web ? static::tryResolve(fn (): string => static::loadBalancerDimension((new LoadBalancer())->arn())) : null,
// The WAF is env-shared (one ACL fronts the ALB), so it's looked up live
// rather than derived from this app's manifest β€” the panel shows for any
// app behind the ALB, and is omitted until the ACL exists.
'wafWebAcl' => $web ? static::tryResolve(fn (): string => WafV2::webAcl((new WebAcl())->name())['Name']) : null,
'targetGroupSuffix' => $web ? static::tryResolve(fn (): string => static::targetGroupDimension((new TargetGroup())->arn())) : null,
'distributionId' => $web ? static::tryResolve(fn () => CloudFront::distributionByComment((new AssetDistribution())->name())['Id']) : null,
'queuePrefix' => Helpers::keyedResourceName() . '-',
Expand Down Expand Up @@ -311,6 +317,11 @@ public static function body(array $context): array
$widgets = [...$widgets, ...$section];
}

if ($context['wafWebAcl'] !== null) {
[$section, $y] = static::wafSection($context, $y);
$widgets = [...$widgets, ...$section];
}

[$section, $y] = static::queueSection($context, $y);
$widgets = [...$widgets, ...$section];

Expand Down Expand Up @@ -529,6 +540,66 @@ protected static function webSection(array $context, int $y): array
* @param array<string, mixed> $context
* @return array{0: array<int, array<string, mixed>>, 1: int}
*/
/**
* The WAF panels: overall allow/block/count posture, and a per-rule blocked
* breakdown showing where blocks come from. The disposition panel's Counted
* series picks up anything left in Count (the Core Rule Set's body-size
* carve-out). WebACL metrics are env-shared, dimensioned on ACL + region + rule.
*
* @param array<string, mixed> $context
* @return array{0: array<int, array<string, mixed>>, 1: int}
*/
protected static function wafSection(array $context, int $y): array
{
$region = $context['region'];
$webAcl = $context['wafWebAcl'];

$series = fn (string $metric, string $rule, array $options): array => [
'AWS/WAFV2', $metric, 'WebACL', $webAcl, 'Region', $region, 'Rule', $rule, $options,
];

$widgets = [static::header($y, '# WAF')];
$y++;

$widgets[] = static::metric(0, $y, 12, 6, [
'title' => 'Request disposition',
'region' => $region,
'view' => 'timeSeries',
'stacked' => false,
'period' => 60,
'stat' => 'Sum',
'metrics' => [
$series('AllowedRequests', 'ALL', ['label' => 'Allowed', 'color' => static::GREEN]),
$series('BlockedRequests', 'ALL', ['label' => 'Blocked', 'color' => static::RED]),
$series('CountedRequests', 'ALL', ['label' => 'Counted (would block)', 'color' => static::ORANGE]),
],
]);

// Rule names mirror WebAcl's skeleton β€” every group blocks, so each is
// charted as BlockedRequests showing where blocks originate.
$widgets[] = static::metric(12, $y, 12, 6, [
'title' => 'Blocked by rule',
'region' => $region,
'view' => 'timeSeries',
'stacked' => true,
'period' => 60,
'stat' => 'Sum',
'metrics' => [
$series('BlockedRequests', 'yolo-block-ips', ['label' => 'Block list', 'color' => static::RED]),
$series('BlockedRequests', 'yolo-banned-countries', ['label' => 'Geo block', 'color' => static::BLUE]),
$series('BlockedRequests', 'AWS-AWSManagedRulesAmazonIpReputationList', ['label' => 'IP reputation']),
$series('BlockedRequests', 'AWS-AWSManagedRulesKnownBadInputsRuleSet', ['label' => 'Known bad inputs']),
$series('BlockedRequests', 'AWS-AWSManagedRulesCommonRuleSet', ['label' => 'CRS', 'color' => static::ORANGE]),
$series('BlockedRequests', 'AWS-AWSManagedRulesSQLiRuleSet', ['label' => 'SQLi']),
$series('BlockedRequests', 'AWS-AWSManagedRulesPHPRuleSet', ['label' => 'PHP']),
$series('BlockedRequests', 'yolo-rate-limit', ['label' => 'Rate limit', 'color' => static::PURPLE]),
],
]);
$y += 6;

return [$widgets, $y];
}

protected static function queueSection(array $context, int $y): array
{
$region = $context['region'];
Expand Down
24 changes: 24 additions & 0 deletions src/Resources/WafV2/AllowIpSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Codinglabs\Yolo\Resources\WafV2;

/**
* The WAF allow list β€” referenced by the top-priority Allow rule so anything in
* it bypasses the managed groups below (the place to put known-good crawler
* ranges that a managed group might otherwise false-positive). Seeded empty;
* the operator fills it.
*/
class AllowIpSet extends IpSet
{
public function name(): string
{
return $this->keyedName('waf-allow');
}

protected function description(): string
{
return 'YOLO WAF allow list β€” known-good IPs (human-managed, never reconciled)';
}
}
24 changes: 24 additions & 0 deletions src/Resources/WafV2/BlockIpSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Codinglabs\Yolo\Resources\WafV2;

/**
* The WAF block list β€” referenced by the Block rule just under the allow list, so
* a banned IP is dropped before the managed groups even run. This is the lever an
* operator reaches for to shut down an abusive source. Seeded empty; the operator
* fills it.
*/
class BlockIpSet extends IpSet
{
public function name(): string
{
return $this->keyedName('waf-block');
}

protected function description(): string
{
return 'YOLO WAF block list (human-managed, never reconciled)';
}
}
Loading