hecks 3.11.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
- data/lib/hecks/adapters/driven/script_runner.rb +93 -0
- data/lib/hecks/adapters/driven.rb +1 -0
- data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
- data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
- data/lib/hecks/adapters/driving/newcomer.rb +50 -0
- data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -15
- data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
- data/lib/hecks/bluebook/meta_validator.rb +28 -21
- data/lib/hecks/bluebook.rb +1 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
- data/lib/hecks/framework/bluebook/change.behaviors +30 -0
- data/lib/hecks/framework/bluebook/change.bluebook +162 -0
- data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
- data/lib/hecks/framework/change.oidc.json +34 -0
- data/lib/hecks/framework/console_settings.oidc.json +103 -0
- data/lib/hecks/framework/governance.oidc.json +28 -0
- data/lib/hecks/framework/identity.oidc.json +18 -0
- data/lib/hecks/framework/privacy.oidc.json +30 -0
- data/lib/hecks/gate/stages.yml +35 -0
- data/lib/hecks/grammar/translation.oidc.json +48 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
- data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
- data/lib/hecks/hecks/hecks.world +3 -0
- data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
- data/lib/hecks/language/bluebook/policy.behaviors +19 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
- data/lib/hecks/projection_files/expression_tables.rb +7 -2
- data/lib/hecks/projections/deploy/box/compose.rb +6 -4
- data/lib/hecks/projections/deploy/box/edge.rb +62 -0
- data/lib/hecks/projections/deploy/box/logging.rb +99 -0
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
- data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
- data/lib/hecks/projections/deploy/box/settings.rb +6 -2
- data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
- data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
- data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
- data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box.rb +15 -3
- data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
- data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
- data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
- data/lib/hecks/projections/site/edge/checks.rb +2 -0
- data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
- data/lib/hecks/projections/site/edge/function_code.rb +86 -0
- data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
- data/lib/hecks/projections/site/edge.rb +35 -9
- data/lib/hecks/projections/site/site_cdn.rb +21 -7
- data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
- data/lib/hecks/projections/site/table/row_builder.rb +12 -2
- data/lib/hecks/projections/site/table.rb +4 -2
- data/lib/hecks/projector/cli_audience.rb +20 -0
- data/lib/hecks/projector/cli_projector/usage.rb +15 -4
- data/lib/hecks/rust_build/host.rb +11 -4
- data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
- data/lib/hecks/site/bluebook/site.bluebook +16 -1
- data/lib/hecks/tools/ci_gate_decision.rb +35 -0
- data/lib/hecks/tools/ci_gates.rb +20 -1
- data/lib/hecks/tools/lanes/files.rb +8 -0
- data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
- data/lib/hecks/tools/lanes.rb +1 -0
- data/lib/hecks/tools/oidc_manifests.rb +44 -6
- data/lib/hecks/tools/release_only.rb +111 -0
- data/lib/hecks/tools/site_routes/templates.rb +13 -9
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +15 -1
- data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
- data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
- data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/auth.rs +58 -0
- data/rust/host/src/extension.rs +13 -1
- data/rust/host/src/lib.rs +1 -0
- data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
- data/rust/host/src/platform_signin/config.rs +512 -0
- data/rust/host/src/platform_signin/cookie.rs +237 -0
- data/rust/host/src/platform_signin/extension.rs +190 -0
- data/rust/host/src/platform_signin/fetcher.rs +322 -0
- data/rust/host/src/platform_signin/handler.rs +251 -0
- data/rust/host/src/platform_signin/handler_tests.rs +554 -0
- data/rust/host/src/platform_signin/jti.rs +196 -0
- data/rust/host/src/platform_signin/jwks.rs +542 -0
- data/rust/host/src/platform_signin/jws.rs +249 -0
- data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
- data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
- data/rust/host/src/platform_signin/mod.rs +75 -0
- data/rust/host/src/platform_signin/mode_tests.rs +382 -0
- data/rust/host/src/platform_signin/pages.rs +204 -0
- data/rust/host/src/platform_signin/query.rs +233 -0
- data/rust/host/src/platform_signin/session.rs +151 -0
- data/rust/host/src/platform_signin/settings.rs +385 -0
- data/rust/host/src/platform_signin/strict_json.rs +351 -0
- data/rust/host/src/platform_signin/test_support.rs +235 -0
- data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
- data/rust/host/src/platform_signin/verify.rs +604 -0
- data/rust/host/src/platform_signin/wiring_support.rs +319 -0
- data/rust/host/src/web.rs +50 -6
- data/rust/parser/src/parse/value_object.rs +22 -10
- metadata +59 -7
- data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
- data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
- /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
- /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
|
|
2
|
+
# Included by the Makefile beside it; the aliases come from the domain's deployed_to("AwsBox")
|
|
3
|
+
# block (cdn).
|
|
4
|
+
|
|
5
|
+
EDGE_STACK = @@EDGE_STACK@@
|
|
6
|
+
|
|
7
|
+
.PHONY: edge
|
|
8
|
+
|
|
9
|
+
# Creates or updates the CloudFront edge stack in front of the box (run `make stacks` first: the
|
|
10
|
+
# box's AppOriginDomain output is the distribution's origin). The certificate is never created
|
|
11
|
+
# here: issue it in ACM in us-east-1, validate its DNS records, then pass its ARN.
|
|
12
|
+
# make edge CERTIFICATE_ARN=arn:aws:acm:us-east-1:<account>:certificate/<id>
|
|
13
|
+
edge:
|
|
14
|
+
@test -n "$(CERTIFICATE_ARN)" || { echo "usage: make edge CERTIFICATE_ARN=arn:aws:acm:us-east-1:..." >&2; exit 1; }
|
|
15
|
+
aws cloudformation deploy --region @@REGION@@ --template-file edge.yaml --stack-name $(EDGE_STACK) \
|
|
16
|
+
--parameter-overrides CertificateArn=$(CERTIFICATE_ARN) \
|
|
17
|
+
AppOriginDomain=$$(aws cloudformation describe-stacks --region @@REGION@@ --stack-name $(BOX_STACK) --query "Stacks[0].Outputs[?OutputKey=='AppOriginDomain'].OutputValue" --output text)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
AWSTemplateFormatVersion: "2010-09-09"
|
|
2
|
+
Description: >
|
|
3
|
+
The CloudFront edge of @@STACK@@: one distribution in front of the app box. Its only origin is the
|
|
4
|
+
box's public DNS name over plain http on port 80, and every request to it carries the origin secret
|
|
5
|
+
header the box's proxy checks. Nothing is cached: the origin decides what a response means.
|
|
6
|
+
|
|
7
|
+
Parameters:
|
|
8
|
+
AppOriginDomain:
|
|
9
|
+
Type: String
|
|
10
|
+
AllowedPattern: ".+"
|
|
11
|
+
ConstraintDescription: the box stack's AppOriginDomain output
|
|
12
|
+
Description: DNS name of the app box, the distribution's only origin.
|
|
13
|
+
CertificateArn:
|
|
14
|
+
Type: String
|
|
15
|
+
AllowedPattern: "arn:aws:acm:us-east-1:.+"
|
|
16
|
+
ConstraintDescription: an ACM certificate in us-east-1 that covers every alias
|
|
17
|
+
Description: >
|
|
18
|
+
An issued ACM certificate in us-east-1 that covers every name in DomainNames. It is never created
|
|
19
|
+
here: DNS validation is a step an operator performs.
|
|
20
|
+
DomainNames:
|
|
21
|
+
Type: CommaDelimitedList
|
|
22
|
+
Default: "@@ALIASES@@"
|
|
23
|
+
Description: The host names the distribution answers on.
|
|
24
|
+
|
|
25
|
+
Resources:
|
|
26
|
+
Distribution:
|
|
27
|
+
Type: AWS::CloudFront::Distribution
|
|
28
|
+
DeletionPolicy: Retain
|
|
29
|
+
UpdateReplacePolicy: Retain
|
|
30
|
+
Properties:
|
|
31
|
+
DistributionConfig:
|
|
32
|
+
Enabled: true
|
|
33
|
+
HttpVersion: http2
|
|
34
|
+
IPV6Enabled: true
|
|
35
|
+
Aliases: !Ref DomainNames
|
|
36
|
+
ViewerCertificate:
|
|
37
|
+
AcmCertificateArn: !Ref CertificateArn
|
|
38
|
+
SslSupportMethod: sni-only
|
|
39
|
+
MinimumProtocolVersion: TLSv1.2_2021
|
|
40
|
+
Origins:
|
|
41
|
+
- Id: BoxOrigin
|
|
42
|
+
DomainName: !Ref AppOriginDomain
|
|
43
|
+
OriginCustomHeaders:
|
|
44
|
+
# Resolved by CloudFormation from Secrets Manager when the stack deploys, so the value
|
|
45
|
+
# never appears on a command line or in this file.
|
|
46
|
+
- HeaderName: @@ORIGIN_HEADER@@
|
|
47
|
+
HeaderValue: "{{resolve:secretsmanager:@@ORIGIN_SECRET@@:SecretString}}"
|
|
48
|
+
CustomOriginConfig:
|
|
49
|
+
OriginProtocolPolicy: http-only
|
|
50
|
+
OriginSSLProtocols: [TLSv1.2]
|
|
51
|
+
DefaultCacheBehavior:
|
|
52
|
+
TargetOriginId: BoxOrigin
|
|
53
|
+
ViewerProtocolPolicy: redirect-to-https
|
|
54
|
+
AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
|
|
55
|
+
CachedMethods: [GET, HEAD]
|
|
56
|
+
Compress: true
|
|
57
|
+
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # Managed-CachingDisabled
|
|
58
|
+
OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3 # Managed-AllViewer
|
|
59
|
+
|
|
60
|
+
Outputs:
|
|
61
|
+
DistributionId:
|
|
62
|
+
Value: !Ref Distribution
|
|
63
|
+
DistributionDomainName:
|
|
64
|
+
Description: Point each alias's DNS record (CNAME or alias) at this name.
|
|
65
|
+
Value: !GetAtt Distribution.DomainName
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Every container's log, one stream per service. The group is made here and not by Docker
|
|
2
|
+
# (awslogs-create-group is "false"), so the role below needs no right to create groups, and the
|
|
3
|
+
# retention is a declaration. A production group outlives the stack: the evidence a container
|
|
4
|
+
# wrote must not go with a deleted stack.
|
|
5
|
+
BoxLogGroup:
|
|
6
|
+
Type: AWS::Logs::LogGroup
|
|
7
|
+
DeletionPolicy: !If [IsProduction, Retain, Delete]
|
|
8
|
+
UpdateReplacePolicy: !If [IsProduction, Retain, Delete]
|
|
9
|
+
Properties:
|
|
10
|
+
LogGroupName: @@LOG_GROUP@@
|
|
11
|
+
RetentionInDays: @@RETENTION_DAYS@@
|
|
12
|
+
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# When the world names origin_env, the named origin secret is compared with those variables in the
|
|
14
14
|
# task definition and the render refuses on any difference (Caddy checks the CDN's header against
|
|
15
15
|
# the secret; the containers check it against their own copy). Values are never printed.
|
|
16
|
+
@@COMPOSE_LOG_NOTE@@
|
|
16
17
|
set -euo pipefail
|
|
17
18
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
18
19
|
DB_HOST=${1:?rds endpoint}
|
|
@@ -36,7 +37,7 @@ if [ -n "$ORIGIN_ENV" ]; then
|
|
|
36
37
|
fi
|
|
37
38
|
|
|
38
39
|
jq --argjson def "$DEF" --arg host "$DB_HOST" --arg secret "$DB_SECRET" '
|
|
39
|
-
|
|
40
|
+
@@LOG_DEF@@
|
|
40
41
|
def env(c): ((c.environment // []) | map({(.name): .value}) | add // {})
|
|
41
42
|
| (if has("DB_HOST") then .DB_HOST = $host | .DB_SECRET_ARN = $secret else . end);
|
|
42
43
|
. as $in
|
|
@@ -44,16 +45,16 @@ jq --argjson def "$DEF" --arg host "$DB_HOST" --arg secret "$DB_SECRET" '
|
|
|
44
45
|
($in.services | with_entries(.value |= (. as $s
|
|
45
46
|
| ($def | map(select(.name == $s.name)) | first) as $c
|
|
46
47
|
| if $c == null then error("task definition has no container named " + $s.name) else
|
|
47
|
-
{image: $c.image, network_mode: "host", restart: "unless-stopped", logging:
|
|
48
|
+
{image: $c.image, network_mode: "host", restart: "unless-stopped", logging: @@LOG_SERVICE@@,
|
|
48
49
|
environment: ({PORT: ($s.port | tostring)} + env($c))}
|
|
49
50
|
+ (if (($c.secrets // []) | length) > 0 then {env_file: [($s.name + ".secrets.env")]} else {} end)
|
|
50
51
|
end)))
|
|
51
52
|
+ {caddy: ({image: "@@PROXY_IMAGE@@", network_mode: "host", restart: "unless-stopped",
|
|
52
|
-
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "./caddy-extra:/etc/caddy/extra:ro"], logging:
|
|
53
|
+
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "./caddy-extra:/etc/caddy/extra:ro"], logging: @@LOG_CADDY@@}
|
|
53
54
|
+ (if $in.origin then {env_file: ["caddy.secrets.env"]} else {} end))}
|
|
54
55
|
+ (if $in.tunnel then {cloudflared: {image: $in.tunnel.image, network_mode: "host", restart: "unless-stopped",
|
|
55
56
|
command: ["tunnel", "--no-autoupdate", "--url", $in.tunnel.url, "run"],
|
|
56
|
-
env_file: ["cloudflared.secrets.env"], logging:
|
|
57
|
+
env_file: ["cloudflared.secrets.env"], logging: @@LOG_TUNNEL@@}} else {} end))}' \
|
|
57
58
|
"$HERE/services.json" > compose.json
|
|
58
59
|
|
|
59
60
|
jq --argjson def "$DEF" '. as $in
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# Tags default to "latest". Every container gets DB_HOST, DB_NAME, DB_SECRET_ARN and PORT, and runs on the
|
|
7
7
|
# box's own network, so the proxy reaches each one on 127.0.0.1:<port>. Secrets named in services.json
|
|
8
8
|
# are written to secrets.json for fetch-secrets.sh to resolve on the box.
|
|
9
|
+
@@COMPOSE_LOG_NOTE@@
|
|
9
10
|
set -euo pipefail
|
|
10
11
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
11
12
|
DB_HOST=${1:?rds endpoint}
|
|
@@ -18,20 +19,20 @@ ECR="$ACCOUNT.dkr.ecr.$REGION.amazonaws.com"
|
|
|
18
19
|
TAGS=$(printf '%s\n' "$@" | jq -R 'select(length > 0) | split("=") | {(.[0]): .[1]}' | jq -s 'add // {}')
|
|
19
20
|
|
|
20
21
|
jq --arg ecr "$ECR" --argjson tags "$TAGS" --arg host "$DB_HOST" --arg secret "$DB_SECRET" --arg db "@@DB_NAME@@" '
|
|
21
|
-
|
|
22
|
+
@@LOG_DEF@@
|
|
22
23
|
. as $in
|
|
23
24
|
| {services: (
|
|
24
25
|
($in.services | with_entries(.value |= (
|
|
25
26
|
{image: ($ecr + "/" + .repository + ":" + ($tags[.name] // "latest")),
|
|
26
|
-
network_mode: "host", restart: "unless-stopped", logging:
|
|
27
|
+
network_mode: "host", restart: "unless-stopped", logging: @@LOG_SERVICE@@,
|
|
27
28
|
environment: ({PORT: (.port | tostring), DB_HOST: $host, DB_NAME: $db, DB_SECRET_ARN: $secret} + .env)}
|
|
28
29
|
+ (if (.secrets | length) > 0 then {env_file: [(.name + ".secrets.env")]} else {} end))))
|
|
29
30
|
+ {caddy: ({image: "@@PROXY_IMAGE@@", network_mode: "host", restart: "unless-stopped",
|
|
30
|
-
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "./caddy-extra:/etc/caddy/extra:ro"], logging:
|
|
31
|
+
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "./caddy-extra:/etc/caddy/extra:ro"], logging: @@LOG_CADDY@@}
|
|
31
32
|
+ (if $in.origin then {env_file: ["caddy.secrets.env"]} else {} end))}
|
|
32
33
|
+ (if $in.tunnel then {cloudflared: {image: $in.tunnel.image, network_mode: "host", restart: "unless-stopped",
|
|
33
34
|
command: ["tunnel", "--no-autoupdate", "--url", $in.tunnel.url, "run"],
|
|
34
|
-
env_file: ["cloudflared.secrets.env"], logging:
|
|
35
|
+
env_file: ["cloudflared.secrets.env"], logging: @@LOG_TUNNEL@@}} else {} end))}' \
|
|
35
36
|
"$HERE/services.json" > compose.json
|
|
36
37
|
|
|
37
38
|
jq '. as $in
|
|
@@ -5,10 +5,12 @@ require_relative "box/hosting"
|
|
|
5
5
|
require_relative "box/roll_recipe"
|
|
6
6
|
require_relative "box/caddy"
|
|
7
7
|
require_relative "box/access"
|
|
8
|
+
require_relative "box/logging"
|
|
8
9
|
require_relative "box/stack_yaml"
|
|
9
10
|
require_relative "box/compose"
|
|
10
11
|
require_relative "box/shared_database"
|
|
11
12
|
require_relative "box/roll_scripts"
|
|
13
|
+
require_relative "box/edge"
|
|
12
14
|
|
|
13
15
|
module Hecks
|
|
14
16
|
module Projections
|
|
@@ -21,10 +23,12 @@ module Hecks
|
|
|
21
23
|
extend Projector::Target
|
|
22
24
|
extend Caddy
|
|
23
25
|
extend Access
|
|
26
|
+
extend Logging
|
|
24
27
|
extend StackYaml
|
|
25
28
|
extend Compose
|
|
26
29
|
extend SharedDatabase
|
|
27
30
|
extend RollScripts
|
|
31
|
+
extend Edge
|
|
28
32
|
|
|
29
33
|
projects_as :aws_box, needs_world: true, emits: :files
|
|
30
34
|
|
|
@@ -123,7 +127,15 @@ module Hecks
|
|
|
123
127
|
volume_gb: { value: settings.fetch(:volume_gb, 30) },
|
|
124
128
|
database_class: { value: settings.fetch(:database_class, "db.t4g.small") },
|
|
125
129
|
storage_gb: { value: settings.fetch(:storage_gb, 20) }
|
|
126
|
-
}
|
|
130
|
+
}.merge(cdn_arguments(settings[:cdn]))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @param cdn [Hash{Symbol => Object}, nil] the world's `cdn` setting
|
|
134
|
+
# @return [Hash{Symbol => Array}] the `cdn_aliases` argument, or nothing without a `cdn`
|
|
135
|
+
def cdn_arguments(cdn)
|
|
136
|
+
return {} unless cdn.is_a?(Hash) && cdn[:aliases].is_a?(Array)
|
|
137
|
+
|
|
138
|
+
{ cdn_aliases: cdn[:aliases].map { |name| { value: name } } }
|
|
127
139
|
end
|
|
128
140
|
|
|
129
141
|
# @param plan [Settings::Plan] the resolved settings
|
|
@@ -136,8 +148,8 @@ module Hecks
|
|
|
136
148
|
"render-compose.sh" => render_compose_sh(plan, region),
|
|
137
149
|
"fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
|
|
138
150
|
"deploy-box.sh" => deploy_box_sh(plan, region),
|
|
139
|
-
"Makefile" => makefile(plan)
|
|
140
|
-
}.merge(database_files(plan)).merge(migration_files(plan))
|
|
151
|
+
"Makefile" => makefile(plan, region)
|
|
152
|
+
}.merge(database_files(plan)).merge(migration_files(plan)).merge(edge_files(plan, region))
|
|
141
153
|
.then { |files| Hosting.extend_files(files, plan: plan, region: region) }
|
|
142
154
|
end
|
|
143
155
|
|
|
@@ -13,7 +13,8 @@ module Hecks
|
|
|
13
13
|
# (`:origin`,
|
|
14
14
|
# `:path` for an item, `:viewer_protocol`, `:methods`, `:compress`, `:cache_policy`,
|
|
15
15
|
# `:origin_request_policy`), and optionally `:response_headers_policy`, each policy as
|
|
16
|
-
# `[id_or_intrinsic, comment_or_nil]
|
|
16
|
+
# `[id_or_intrinsic, comment_or_nil]`, and `:edge_function`, the logical id of a
|
|
17
|
+
# viewer-request function
|
|
17
18
|
# @param base [String] the indentation of `key`, or of the list item's dash
|
|
18
19
|
# @return [Array<String>] the lines
|
|
19
20
|
def behavior_lines(key, entry, base)
|
|
@@ -91,6 +92,16 @@ module Hecks
|
|
|
91
92
|
lines << "#{pad}Compress: #{entry[:compress]}" if entry.key?(:compress)
|
|
92
93
|
lines << policy_line(pad, "CachePolicyId", entry[:cache_policy])
|
|
93
94
|
lines.concat(optional_policy_lines(entry, pad))
|
|
95
|
+
lines.concat(function_lines(entry, pad))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The viewer-request function a behaviour names (`:edge_function`, the logical id of an
|
|
99
|
+
# `AWS::CloudFront::Function` in the same template), if any.
|
|
100
|
+
def function_lines(entry, pad)
|
|
101
|
+
return [] unless entry[:edge_function]
|
|
102
|
+
|
|
103
|
+
["#{pad}FunctionAssociations:", "#{pad} - EventType: viewer-request",
|
|
104
|
+
"#{pad} FunctionARN: !GetAtt #{entry[:edge_function]}.FunctionARN"]
|
|
94
105
|
end
|
|
95
106
|
|
|
96
107
|
def optional_policy_lines(entry, pad)
|
|
@@ -15,10 +15,12 @@ module Hecks
|
|
|
15
15
|
// Signing in (endpoints/sso.ts) proved the person was admitted then; this asks again, so that
|
|
16
16
|
// disabling or removing someone locks them out on their next request, not when their session runs out.
|
|
17
17
|
//
|
|
18
|
-
// The question is the host's
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// The question is the host's members list, which the host answers (403) only to a person holding
|
|
19
|
+
// one of the admin roles, and the list's own row for the caller must hold such a role and not be
|
|
20
|
+
// disabled. It is asked with a short account token signed by the secret the host checks (the content
|
|
21
|
+
// system's AUTH_SECRET is the host's SESSION_SECRET). The answer is remembered for a minute per
|
|
22
|
+
// email so every admin request does not cost a round trip. Being admitted to the site is not enough:
|
|
23
|
+
// a member with no admin role is refused here, as the site's own admin gate refuses them.
|
|
22
24
|
import { accountToken, resolveAccountCookieName } from "@hecks/client";
|
|
23
25
|
|
|
24
26
|
const DEFAULT_URL = __HOST_DEFAULT__;
|
|
@@ -38,7 +40,7 @@ module Hecks
|
|
|
38
40
|
|
|
39
41
|
export interface MembershipOptions {
|
|
40
42
|
url?: string;
|
|
41
|
-
/** The host route asked: the account route (any admitted person) by default
|
|
43
|
+
/** The host route asked: the account route (any admitted person) by default; `adminCheck` asks the members list, for admin roles only. */
|
|
42
44
|
path?: string;
|
|
43
45
|
/** Decides from the host's answer; the default is any 2xx. */
|
|
44
46
|
accept?: (res: Response, email: string) => Promise<boolean>;
|
|
@@ -83,8 +85,6 @@ module Hecks
|
|
|
83
85
|
};
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
export const membership = membershipCheck();
|
|
87
|
-
|
|
88
88
|
/** The roles the host's own admin gate passes. */
|
|
89
89
|
export const ADMIN_ROLES = __ROLES__;
|
|
90
90
|
|
|
@@ -106,6 +106,9 @@ module Hecks
|
|
|
106
106
|
path: __MEMBERS_PATH__,
|
|
107
107
|
accept: async (res, email) => holdsAdminRole(await res.json().catch(() => null), email),
|
|
108
108
|
});
|
|
109
|
+
|
|
110
|
+
/** The check the sign-in endpoint and the session strategy use: admin roles only, never a plain member. */
|
|
111
|
+
export const membership = adminCheck();
|
|
109
112
|
TS
|
|
110
113
|
end
|
|
111
114
|
end
|
|
@@ -10,7 +10,8 @@ module Hecks
|
|
|
10
10
|
# compression give it. A route needs none of its own when the behaviour that would match its
|
|
11
11
|
# path anyway (the nearest broader route's, else the default one) is the same. The rest keep
|
|
12
12
|
# the order the table declares, which is the order CloudFront matches in, and a route that
|
|
13
|
-
# a broader, different one before it would shadow is refused.
|
|
13
|
+
# a broader, different one before it would shadow is refused. The edge function a route
|
|
14
|
+
# names is part of its behaviour, so a route is folded only into one that names the same.
|
|
14
15
|
class Behaviours
|
|
15
16
|
# The default behaviour's path: the row that holds the settings of everything else.
|
|
16
17
|
DEFAULT_PATH = "/*"
|
|
@@ -58,11 +59,14 @@ module Hecks
|
|
|
58
59
|
policy = @edge.policy(row.cache, row.origin)
|
|
59
60
|
{
|
|
60
61
|
path: pattern, origin: @edge.upstream(row.origin).id, methods: methods([row]),
|
|
61
|
-
viewer_protocol: protocol(row), compress: row.compress
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
viewer_protocol: protocol(row), compress: row.compress
|
|
63
|
+
}.merge(policies(policy)).merge(row.edge ? { edge_function: row.edge } : {})
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def policies(policy)
|
|
67
|
+
{ cache_policy: Checks.resolve(policy.cache),
|
|
68
|
+
origin_request_policy: reference(policy.origin_request),
|
|
69
|
+
response_headers_policy: reference(policy.response_headers) }
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
def reference(text) = text.nil? || text == "none" ? nil : Checks.resolve(text)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "findings"
|
|
4
4
|
require_relative "route_checks"
|
|
5
|
+
require_relative "function_checks"
|
|
5
6
|
|
|
6
7
|
module Hecks
|
|
7
8
|
module Projections
|
|
@@ -47,6 +48,7 @@ module Hecks
|
|
|
47
48
|
check_policies
|
|
48
49
|
check_upstreams
|
|
49
50
|
RouteChecks.new(@edge, @rows, @vocabulary, @problems).call
|
|
51
|
+
FunctionChecks.new(@edge, @rows, @vocabulary, @problems).call
|
|
50
52
|
end
|
|
51
53
|
|
|
52
54
|
private
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "findings"
|
|
4
|
+
require_relative "function_code"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Projections
|
|
8
|
+
module Site
|
|
9
|
+
class Edge
|
|
10
|
+
# The part of the edge checks that reads the `EdgeFunction` rows and the routes that name
|
|
11
|
+
# them: each row is well formed and safe to write into a function, each route names a row
|
|
12
|
+
# that exists, and no row is left that nothing names.
|
|
13
|
+
class FunctionChecks
|
|
14
|
+
include Findings
|
|
15
|
+
|
|
16
|
+
# The longest logical id a function takes; the name it is given in the account adds the
|
|
17
|
+
# stack name, and CloudFront allows 64 characters in all.
|
|
18
|
+
NAME_LIMIT = 40
|
|
19
|
+
|
|
20
|
+
# @param edge [Edge] the edge being read, its rows set
|
|
21
|
+
# @param rows [Array<Table::Row>] the route table's rows
|
|
22
|
+
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
23
|
+
# @param problems [Array<String>] collects each problem found, one line each
|
|
24
|
+
def initialize(edge, rows, vocabulary, problems)
|
|
25
|
+
@edge = edge
|
|
26
|
+
@rows = rows
|
|
27
|
+
@vocabulary = vocabulary
|
|
28
|
+
@problems = problems
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [void]
|
|
32
|
+
def call
|
|
33
|
+
@edge.functions.each { |function| check_function(function) }
|
|
34
|
+
repeated(@edge.functions.map(&:name), "EdgeFunction", &:itself)
|
|
35
|
+
check_derived_names
|
|
36
|
+
check_routes
|
|
37
|
+
check_unused
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def check_function(function)
|
|
43
|
+
label = "EdgeFunction #{function.name}"
|
|
44
|
+
check_name(label, function)
|
|
45
|
+
member_of(label, "kind", function.kind, :edge_function_kind)
|
|
46
|
+
check_host(label, "host", function.host)
|
|
47
|
+
function.alias_list.each { |host| check_alias(label, function, host) }
|
|
48
|
+
check_paths(label, function)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Two logical ids that differ only in case would be given one function name.
|
|
52
|
+
def check_derived_names
|
|
53
|
+
names = @edge.functions.map { |function| function.name.to_s }.uniq
|
|
54
|
+
names.group_by { |name| FunctionCode.kebab(name) }.each do |kebab, together|
|
|
55
|
+
problem("EdgeFunction", "#{together.join(" and ")} would both be named #{kebab}") if together.size > 1
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_name(label, function)
|
|
60
|
+
name = function.name.to_s
|
|
61
|
+
problem(label, "is not a logical id") unless Checks::LOGICAL_ID.match?(name)
|
|
62
|
+
problem(label, "is longer than #{NAME_LIMIT} characters") if name.size > NAME_LIMIT
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_host(label, field, host)
|
|
66
|
+
return if FunctionCode.host?(host)
|
|
67
|
+
|
|
68
|
+
problem(label, "has #{field} #{host.inspect}; #{field == "alias" ? "an" : "a"} #{field} is a lowercase hostname " \
|
|
69
|
+
"such as app.example.test")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check_alias(label, function, host)
|
|
73
|
+
check_host(label, "alias", host)
|
|
74
|
+
problem(label, "has alias #{host}, which is its host; an alias is another host") if host == function.host
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_paths(label, function)
|
|
78
|
+
problem(label, "needs at least one path to watch") if function.path_list.empty?
|
|
79
|
+
function.path_list.each do |path|
|
|
80
|
+
next if FunctionCode.path?(path)
|
|
81
|
+
|
|
82
|
+
problem(label, "has path #{path.inspect}; a path is exact, starts with / and holds letters, digits and . _ ~ - /")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every `edge:` names a row, and a route the CDN never sees has no edge to run it.
|
|
87
|
+
def check_routes
|
|
88
|
+
@rows.select(&:edge).each do |row|
|
|
89
|
+
problem(row.path, "names edge #{row.edge}, which no EdgeFunction declares") unless @edge.function(row.edge)
|
|
90
|
+
problem(row.path, "names edge #{row.edge} and has cdn false; the edge never sees it") unless row.cdn
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def check_unused
|
|
95
|
+
named = @rows.select(&:edge).map(&:edge)
|
|
96
|
+
@edge.functions.reject { |function| named.include?(function.name) }.each do |function|
|
|
97
|
+
problem("EdgeFunction #{function.name}", "is named by no route; remove the row or add edge: to a route")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Site
|
|
8
|
+
class Edge
|
|
9
|
+
# The CloudFront function resource of a `redirect_host` row, and the JavaScript in it.
|
|
10
|
+
#
|
|
11
|
+
# The code is a fixed template (`redirect_host.js.tmpl`) with three values filled in: the
|
|
12
|
+
# canonical host, the alias hosts and the watched paths. A value is never escaped. One
|
|
13
|
+
# outside the strict shape of a lowercase hostname, or of a path made of unreserved
|
|
14
|
+
# characters, is refused, so no value can end a JavaScript string, a YAML block or a
|
|
15
|
+
# CloudFormation expression. The checks refuse such a row when it is read, and `code`
|
|
16
|
+
# refuses again, since it is the last step before the text is written.
|
|
17
|
+
module FunctionCode
|
|
18
|
+
# A lowercase hostname of at least two labels, as the host header is compared after
|
|
19
|
+
# lowercasing.
|
|
20
|
+
HOST = /\A([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}\z/
|
|
21
|
+
|
|
22
|
+
# A path of unreserved characters and slashes; no query, no percent escape, no quote.
|
|
23
|
+
PATH = %r{\A/[A-Za-z0-9._~/-]*\z}
|
|
24
|
+
|
|
25
|
+
# The longest hostname and path a function takes.
|
|
26
|
+
HOST_LIMIT = 253
|
|
27
|
+
PATH_LIMIT = 200
|
|
28
|
+
|
|
29
|
+
TEMPLATE = File.join(__dir__, "redirect_host.js.tmpl")
|
|
30
|
+
PLACEHOLDER = /__(CANONICAL|ALIASES|WATCHED)__/
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# @param text [String] a candidate hostname
|
|
35
|
+
# @return [Boolean] whether it may be written into a function
|
|
36
|
+
def host?(text) = text.is_a?(String) && text.size <= HOST_LIMIT && HOST.match?(text)
|
|
37
|
+
|
|
38
|
+
# @param text [String] a candidate path
|
|
39
|
+
# @return [Boolean] whether it may be written into a function
|
|
40
|
+
def path?(text) = text.is_a?(String) && text.size <= PATH_LIMIT && PATH.match?(text)
|
|
41
|
+
|
|
42
|
+
# @param function [Edge::Function] a `redirect_host` row
|
|
43
|
+
# @return [String] the function's JavaScript, ending in a newline
|
|
44
|
+
# @raise [ArgumentError] when a host or path is outside the strict shapes
|
|
45
|
+
def code(function)
|
|
46
|
+
ensure_safe(function)
|
|
47
|
+
values = { "CANONICAL" => function.host, "ALIASES" => function.alias_list, "WATCHED" => function.path_list }
|
|
48
|
+
File.read(TEMPLATE).gsub(PLACEHOLDER) { JSON.generate(values.fetch(Regexp.last_match(1))) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param name [String] a function's logical id
|
|
52
|
+
# @return [String] the hyphenated name the function carries, such as `host-redirect`
|
|
53
|
+
def kebab(name) = name.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
|
|
54
|
+
|
|
55
|
+
# @param function [Edge::Function] a `redirect_host` row
|
|
56
|
+
# @return [String] the resource, from the left margin, ending in a newline
|
|
57
|
+
def resource(function)
|
|
58
|
+
[
|
|
59
|
+
"#{function.name}:", " Type: AWS::CloudFront::Function", " Properties:",
|
|
60
|
+
" Name: !Sub \"${AWS::StackName}-#{kebab(function.name)}\"", " AutoPublish: true",
|
|
61
|
+
" FunctionConfig:", " Comment: Redirect the watched paths from an alias to the canonical host",
|
|
62
|
+
" Runtime: cloudfront-js-2.0", " FunctionCode: |",
|
|
63
|
+
*code(function).each_line.map { |line| line.strip.empty? ? "" : " #{line.chomp}" }
|
|
64
|
+
].join("\n") << "\n"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def ensure_safe(function)
|
|
68
|
+
raise ArgumentError, "EdgeFunction #{function.name} has no path to watch" if function.path_list.empty?
|
|
69
|
+
|
|
70
|
+
bad = unsafe_values(function)
|
|
71
|
+
return if bad.empty?
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "EdgeFunction #{function.name} has values outside the hostname and path shapes: " \
|
|
74
|
+
"#{bad.map(&:inspect).join(", ")}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unsafe_values(function)
|
|
78
|
+
[function.host, *function.alias_list].reject { |host| host?(host) } +
|
|
79
|
+
function.path_list.reject { |path| path?(path) }
|
|
80
|
+
end
|
|
81
|
+
private_class_method :ensure_safe, :unsafe_values
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function handler(event) {
|
|
2
|
+
var request = event.request;
|
|
3
|
+
var canonical = __CANONICAL__;
|
|
4
|
+
var aliases = __ALIASES__;
|
|
5
|
+
var watched = __WATCHED__;
|
|
6
|
+
var header = request.headers.host;
|
|
7
|
+
var host = header && header.value ? header.value.toLowerCase().replace(/:\d+$/, "") : "";
|
|
8
|
+
var reads = request.method === "GET" || request.method === "HEAD";
|
|
9
|
+
if (!reads || watched.indexOf(request.uri) < 0) { return request; }
|
|
10
|
+
if (host === canonical || aliases.indexOf(host) < 0) { return request; }
|
|
11
|
+
var parts = [];
|
|
12
|
+
Object.keys(request.querystring).forEach(function (key) {
|
|
13
|
+
var entry = request.querystring[key];
|
|
14
|
+
var values = entry.multiValue ? entry.multiValue.map(function (item) { return item.value; }) : [entry.value];
|
|
15
|
+
values.forEach(function (value) {
|
|
16
|
+
var name = encode(key);
|
|
17
|
+
parts.push(value === "" || value === undefined ? name : name + "=" + encode(value));
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
var query = parts.length ? "?" + parts.join("&") : "";
|
|
21
|
+
return {
|
|
22
|
+
statusCode: 302,
|
|
23
|
+
statusDescription: "Found",
|
|
24
|
+
headers: {
|
|
25
|
+
location: { value: "https://" + canonical + request.uri + query },
|
|
26
|
+
"cache-control": { value: "no-store" }
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Percent-encodes what cannot stand in a query string and keeps what is already encoded, so the
|
|
32
|
+
// location header carries no space, control character, "&" or "#" whether the runtime hands over
|
|
33
|
+
// the raw text or the decoded one. It walks the text one UTF-16 unit at a time and pairs the
|
|
34
|
+
// surrogates itself (a lone one becomes U+FFFD), because CloudFront's regex engine refuses a
|
|
35
|
+
// pattern that names a surrogate code point and fails the whole function at compile time.
|
|
36
|
+
var SAFE = /[A-Za-z0-9\-._~!$'()*,;:@\/?+]/;
|
|
37
|
+
var HEX = /^[0-9A-Fa-f]{2}$/;
|
|
38
|
+
var REPLACEMENT = "%EF%BF%BD";
|
|
39
|
+
|
|
40
|
+
function encode(text) {
|
|
41
|
+
var input = String(text);
|
|
42
|
+
var output = "";
|
|
43
|
+
var index = 0;
|
|
44
|
+
while (index < input.length) {
|
|
45
|
+
var unit = input.charAt(index);
|
|
46
|
+
var code = input.charCodeAt(index);
|
|
47
|
+
index += 1;
|
|
48
|
+
if (code >= 55296 && code <= 56319) {
|
|
49
|
+
var next = index < input.length ? input.charCodeAt(index) : 0;
|
|
50
|
+
if (next >= 56320 && next <= 57343) {
|
|
51
|
+
output += encodeURIComponent(unit + input.charAt(index));
|
|
52
|
+
index += 1;
|
|
53
|
+
} else {
|
|
54
|
+
output += REPLACEMENT;
|
|
55
|
+
}
|
|
56
|
+
} else if (code >= 56320 && code <= 57343) {
|
|
57
|
+
output += REPLACEMENT;
|
|
58
|
+
} else if (unit === "%") {
|
|
59
|
+
output += HEX.test(input.substr(index, 2)) ? unit : "%25";
|
|
60
|
+
} else {
|
|
61
|
+
output += SAFE.test(unit) ? unit : encodeURIComponent(unit);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return output;
|
|
65
|
+
}
|