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.
Files changed (118) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  3. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  4. data/lib/hecks/adapters/driven.rb +1 -0
  5. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  6. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  7. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  8. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  9. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  10. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  11. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  12. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  13. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  14. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  15. data/lib/hecks/bluebook.rb +1 -0
  16. data/lib/hecks/deploy/bluebook/deploy.bluebook +17 -3
  17. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  18. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  19. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  20. data/lib/hecks/framework/change.oidc.json +34 -0
  21. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  22. data/lib/hecks/framework/governance.oidc.json +28 -0
  23. data/lib/hecks/framework/identity.oidc.json +18 -0
  24. data/lib/hecks/framework/privacy.oidc.json +30 -0
  25. data/lib/hecks/gate/stages.yml +35 -0
  26. data/lib/hecks/grammar/translation.oidc.json +48 -0
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  28. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  29. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  30. data/lib/hecks/hecks/hecks.world +3 -0
  31. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  32. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  33. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  34. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  35. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  36. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  37. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  38. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  39. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  40. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  41. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  42. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  43. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  44. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  45. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  46. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  47. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  48. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  49. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  50. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  51. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  52. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  53. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  54. data/lib/hecks/projections/deploy/box.rb +15 -3
  55. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  56. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  57. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  58. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  59. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  60. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  61. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  62. data/lib/hecks/projections/site/edge.rb +35 -9
  63. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  64. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  65. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  66. data/lib/hecks/projections/site/table.rb +4 -2
  67. data/lib/hecks/projector/cli_audience.rb +20 -0
  68. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  69. data/lib/hecks/rust_build/host.rb +11 -4
  70. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  71. data/lib/hecks/site/bluebook/site.bluebook +16 -1
  72. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  73. data/lib/hecks/tools/ci_gates.rb +20 -1
  74. data/lib/hecks/tools/lanes/files.rb +8 -0
  75. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  76. data/lib/hecks/tools/lanes.rb +1 -0
  77. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  78. data/lib/hecks/tools/release_only.rb +111 -0
  79. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks/vocabulary.rb +15 -1
  82. data/prebuilt/2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal +0 -0
  83. data/prebuilt/58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal +0 -0
  84. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json} +1 -1
  85. data/rust/host/HECKS_RELEASE +1 -1
  86. data/rust/host/src/auth.rs +58 -0
  87. data/rust/host/src/extension.rs +13 -1
  88. data/rust/host/src/lib.rs +1 -0
  89. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  90. data/rust/host/src/platform_signin/config.rs +512 -0
  91. data/rust/host/src/platform_signin/cookie.rs +237 -0
  92. data/rust/host/src/platform_signin/extension.rs +190 -0
  93. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  94. data/rust/host/src/platform_signin/handler.rs +251 -0
  95. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  96. data/rust/host/src/platform_signin/jti.rs +196 -0
  97. data/rust/host/src/platform_signin/jwks.rs +542 -0
  98. data/rust/host/src/platform_signin/jws.rs +249 -0
  99. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  100. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  101. data/rust/host/src/platform_signin/mod.rs +75 -0
  102. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  103. data/rust/host/src/platform_signin/pages.rs +204 -0
  104. data/rust/host/src/platform_signin/query.rs +233 -0
  105. data/rust/host/src/platform_signin/session.rs +151 -0
  106. data/rust/host/src/platform_signin/settings.rs +385 -0
  107. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  108. data/rust/host/src/platform_signin/test_support.rs +235 -0
  109. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  110. data/rust/host/src/platform_signin/verify.rs +604 -0
  111. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  112. data/rust/host/src/web.rs +50 -6
  113. data/rust/parser/src/parse/value_object.rs +22 -10
  114. metadata +59 -7
  115. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  116. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  117. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  118. /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
+
@@ -0,0 +1,7 @@
1
+ - PolicyName: write-logs
2
+ PolicyDocument:
3
+ Version: "2012-10-17"
4
+ Statement:
5
+ - Effect: Allow
6
+ Action: [logs:CreateLogStream, logs:PutLogEvents, logs:DescribeLogStreams]
7
+ Resource: !GetAtt BoxLogGroup.Arn
@@ -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
- def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
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: log,
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: log}
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: log}} else {} end))}' \
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
- def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
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: log,
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: log}
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: log}} else {} end))}' \
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 own account route, which answers only for a person who holds a role
19
- // and is not disabled, asked with a short account token signed by the secret the host checks (the
20
- // content system's AUTH_SECRET is the host's SESSION_SECRET). The answer is remembered for a minute
21
- // per email so every admin request does not cost a round trip.
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, the members list for admin roles only. */
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
- cache_policy: Checks.resolve(policy.cache),
63
- origin_request_policy: reference(policy.origin_request),
64
- response_headers_policy: reference(policy.response_headers)
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
+ }