foobara-aws 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4de7e0012dfa7d11c92054cd25827b8b7f2e0864bf779027fe8c35a754c423e
4
- data.tar.gz: b5009b5e2c62ba0db20ba010ad3580afec5c0524733fd8a8aa09f9a0afa8e475
3
+ metadata.gz: 16a858ce4e2944711259dacbf844fd258a1715018aaa7df98364e45739ec33de
4
+ data.tar.gz: 995b9e3af507e6cb53e3fd581945d27b9251f9559f0151c9151e5fa5bb277191
5
5
  SHA512:
6
- metadata.gz: cb24ae7b484641d23c71c27ff59743ff1a2ac60eb0eea8c672da9c7177d05083cecfc535b47e722c09f995ca70fd9c8b32ea74ca1c5b6e2f2b0767ee9d58eedd
7
- data.tar.gz: 87c12595977a88553fc092c6aa050dd22dcb25d433e5593d5b0594958b0e16cd42e47ae6553fcec8957089a8aeab68e620b251974a8e2da4bd38a65115793a29
6
+ metadata.gz: a328cae1d9959faa1efc89dc399f007005673493704ae20dbe9e02904b85949f7eb76debefeff09830f2a367315efac9bde77981c660690299aced88f5871464
7
+ data.tar.gz: a0cb8f25daa37b5ea097d5bf13e145c0a7df3a27b2d6faa801e426dafb3f8f00def81a55093d7c95818cd2caf32b2d7bbe7c6f3864248993b099f749d1218d52
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-08-04
4
+
5
+ - `Foobara::AWS.plan_from_connector(connector)` — build a plan by inspecting a live connector instead of a
6
+ manifest. No HTTP, no running server, and no snapshot on disk to go stale.
7
+ - It takes a CONNECTOR rather than a set of command classes, because `requires_authentication` is decided at
8
+ `connect` time and lives on the transformed command. The command classes alone cannot say which commands are
9
+ public, which is the one thing a deployment most needs to know.
10
+ - It adapts each command into the shape `plan` already reads, so there is a single derivation and the two routes
11
+ cannot drift — asserted by a spec comparing the plans they produce.
12
+
13
+ ## [0.2.0] - 2026-08-04
14
+
15
+ - `Foobara::AWS::Check` — checks a DEPLOYED API against the plan it was deployed from. It asserts the few things
16
+ that are true of every Foobara deployment and that an application's own tests structurally cannot see, because
17
+ they live at the edge: a public command is reachable without credentials, a gated one is refused without them
18
+ and with an invalid token, and a refusal is JSON rather than an HTML page with a 200 on it.
19
+ - Gated commands are only ever called WITHOUT credentials, so they are refused before executing and nothing is
20
+ written. Public commands are executed with empty inputs; `skip:` is there for when that is not wanted. A 422 is
21
+ treated as success — the question is whether the request reached the command, not whether it liked the inputs.
22
+
3
23
  ## [0.1.0] - 2026-08-04
4
24
 
5
25
  - Initial release. Renamed from foobara-cdk before publishing: half of it is runtime code that loads inside a
data/README.md CHANGED
@@ -15,6 +15,22 @@ service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "bu
15
15
  posts_table.grant_read_write_data(service.function("posts"))
16
16
  ```
17
17
 
18
+ ## Where a plan comes from
19
+
20
+ ```ruby
21
+ Foobara::AWS.plan(manifest) # a manifest, however you obtained it
22
+ Foobara::AWS.plan_from_connector(connector) # a live connector, no server needed
23
+ Foobara::AWS::Plan.load(JSON.parse(json)) # one that has been through JSON
24
+ ```
25
+
26
+ `plan_from_connector` is usually what a build step wants: it reads the objects
27
+ directly, so there is no server to start and no snapshot to go stale. It takes a
28
+ **connector**, not a set of command classes — `requires_authentication` is
29
+ decided at `connect` time and lives on the transformed command, so the classes
30
+ alone cannot say which commands are public.
31
+
32
+ All three produce the same `Plan`, and a spec asserts the first two agree.
33
+
18
34
  ## Two halves, on purpose
19
35
 
20
36
  ```ruby
@@ -151,6 +167,36 @@ Two things it handles that are easy to get wrong:
151
167
  and the load path rewritten. (Use `mounts:` so the build container can see
152
168
  them in the first place.)
153
169
 
170
+ ## Checking a deployment
171
+
172
+ ```ruby
173
+ result = Foobara::AWS::Check.new(url: "https://api.example.com", plan: plan).run
174
+ puts result.report
175
+ exit 1 unless result.ok?
176
+ ```
177
+
178
+ Driven by the plan, so it knows which commands are public without being told. It
179
+ asserts the few things true of every Foobara deployment that an application's own
180
+ tests structurally cannot see, because they live at the edge:
181
+
182
+ - a public command is reachable **without** credentials
183
+ - a gated command is refused without them, and with an invalid token
184
+ - a refusal is JSON, not an HTML page with a 200 on it
185
+
186
+ Each has a specific failure behind it. A public command that 401s usually means
187
+ the authorizer declared an identity source, so API Gateway answered before the
188
+ authorizer ran — silent, because the function was never invoked and logged
189
+ nothing. A refusal arriving as `200 text/html` usually means a SPA history
190
+ fallback is rewriting the API's errors, which turns every client-side error check
191
+ into a lie. Both are real, both shipped, and both passed a full green test suite.
192
+
193
+ Every request carries `{}`, so a command with required inputs answers 422 — which
194
+ counts as success, since the question is whether the request reached the command.
195
+ Gated commands are therefore only ever called **without** credentials: they are
196
+ refused before executing and nothing is written. Public commands are executed
197
+ with empty inputs, which is safe for the usual case of a read; `skip:` is there
198
+ for when it is not.
199
+
154
200
  ## What it does not do
155
201
 
156
202
  - **Create tables, buckets or queues.** Those are the application's, not the
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "error"
8
+ require_relative "plan"
9
+
10
+ module Foobara
11
+ module AWS
12
+ # Checks a DEPLOYED API against the plan it was deployed from.
13
+ #
14
+ # result = Foobara::AWS::Check.new(url: "https://api.example.com", plan: plan).run
15
+ # puts result.report
16
+ # exit 1 unless result.ok?
17
+ #
18
+ # Not a substitute for the application's own tests. It asserts the handful of
19
+ # things that are true of every Foobara deployment and that unit tests
20
+ # structurally cannot see, because they live at the edge:
21
+ #
22
+ # * a public command is reachable WITHOUT credentials
23
+ # * a command requiring authentication is refused without them
24
+ # * a refusal is a refusal, not an HTML page with a 200 on it
25
+ #
26
+ # Each of those has a specific failure behind it. A public command that 401s
27
+ # usually means the authorizer declared an identity source, so API Gateway
28
+ # answered before the authorizer ran — silent, because the function was never
29
+ # invoked and logged nothing. A refusal arriving as `200 text/html` usually
30
+ # means a SPA history fallback is rewriting the API's errors, which turns
31
+ # every client error check into a lie.
32
+ #
33
+ # WHAT IT SENDS. Every request carries `{}` as its body, so a command that
34
+ # requires inputs answers 422. That is treated as SUCCESS: the point is
35
+ # whether the request reached the command at all, not whether it liked the
36
+ # inputs. It also means gated commands are only ever called WITHOUT
37
+ # credentials, so they are refused before executing and nothing is written.
38
+ #
39
+ # Public commands ARE executed, with empty inputs. That is safe for the usual
40
+ # case — a command reachable anonymously is nearly always a read — but it is
41
+ # the caller's judgement, and `skip:` exists for when it is not.
42
+ class Check
43
+ # Anything at all, as long as it is not a refusal and not HTML.
44
+ REFUSALS = [401, 403].freeze
45
+
46
+ Finding = Data.define(:command, :description, :expected, :actual, :ok) do
47
+ def to_s
48
+ "#{ok ? "ok " : "FAIL"} #{command.ljust(28)} #{description} (expected #{expected}, got #{actual})"
49
+ end
50
+ end
51
+
52
+ Result = Data.define(:findings) do
53
+ def ok? = findings.all?(&:ok)
54
+ def failures = findings.reject(&:ok)
55
+
56
+ def report
57
+ summary = "#{findings.count(&:ok)}/#{findings.length} checks passed"
58
+ ([summary] + findings.map(&:to_s)).join("\n")
59
+ end
60
+ end
61
+
62
+ # +http+ is injectable so this can be tested without a network, and so a
63
+ # caller can supply its own client (a proxy, a custom CA, an SSM-fetched
64
+ # token). It receives (url, body_hash, headers) and returns
65
+ # [status, content_type, body_string].
66
+ def initialize(url:, plan:, skip: [], invalid_token: "not.a.token", http: nil)
67
+ @url = url.to_s.chomp("/")
68
+ @plan = plan
69
+ @skip = Array(skip).map(&:to_s)
70
+ @invalid_token = invalid_token
71
+ @http = http || method(:request)
72
+ end
73
+
74
+ def run
75
+ Result.new(findings: @plan.units.flat_map { |unit| check_unit(unit) })
76
+ end
77
+
78
+ private
79
+
80
+ def check_unit(unit)
81
+ unit.commands.reject { |c| @skip.include?(c) }.flat_map do |command|
82
+ if unit.public_commands.include?(command)
83
+ check_public(command)
84
+ else
85
+ check_gated(command)
86
+ end
87
+ end
88
+ end
89
+
90
+ # Reachable without credentials, and answering as an API rather than as a
91
+ # web page.
92
+ def check_public(command)
93
+ status, content_type, = call(command)
94
+
95
+ [
96
+ finding(command, "reachable anonymously", "not #{REFUSALS.join("/")}", status,
97
+ !REFUSALS.include?(status)),
98
+ finding(command, "answers as an API", "json", content_type, json?(content_type))
99
+ ]
100
+ end
101
+
102
+ # Refused without credentials, and refused with a token that does not
103
+ # verify — the second catches an authorizer that accepts anything, which
104
+ # the first cannot distinguish from one that is never invoked.
105
+ def check_gated(command)
106
+ anonymous, anonymous_type, = call(command)
107
+ forged, forged_type, = call(command, token: @invalid_token)
108
+
109
+ [
110
+ finding(command, "refused anonymously", REFUSALS.join("/"), anonymous,
111
+ REFUSALS.include?(anonymous)),
112
+ finding(command, "refusal is not a web page", "json", anonymous_type, json?(anonymous_type)),
113
+ finding(command, "refused with an invalid token", REFUSALS.join("/"), forged,
114
+ REFUSALS.include?(forged)),
115
+ finding(command, "invalid-token refusal is not a web page", "json", forged_type,
116
+ json?(forged_type))
117
+ ]
118
+ end
119
+
120
+ def finding(command, description, expected, actual, passed)
121
+ Finding.new(command: command, description: description, expected: expected,
122
+ actual: actual.to_s, ok: passed)
123
+ end
124
+
125
+ # A 200 serving text/html is the CloudFront-rewrite failure, and it is worth
126
+ # naming rather than reporting as an unexpected status.
127
+ def json?(content_type) = content_type.to_s.include?("json")
128
+
129
+ def call(command, token: nil)
130
+ path = command.split("::").join("/")
131
+ headers = { "Content-Type" => "application/json" }
132
+ headers["Authorization"] = "Bearer #{token}" if token
133
+
134
+ @http.call("#{@url}#{@plan.mount}/#{path}", {}, headers)
135
+ rescue StandardError => e
136
+ # A connection failure is a check failure, not a crash: reporting it
137
+ # alongside the others is more useful than aborting the run, and it
138
+ # applies to an injected client as much as to the default one.
139
+ [0, "error: #{e.class}", e.message]
140
+ end
141
+
142
+ def request(url, body, headers)
143
+ uri = URI(url)
144
+ request = Net::HTTP::Post.new(uri)
145
+ headers.each { |k, v| request[k] = v }
146
+ request.body = JSON.dump(body)
147
+
148
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
149
+ http.request(request)
150
+ end
151
+
152
+ [response.code.to_i, response["content-type"], response.body]
153
+ end
154
+ end
155
+ end
156
+ end
@@ -85,6 +85,28 @@ module Foobara
85
85
  Plan.new(mount: mount, granularity: granularity, units: units.sort_by(&:name))
86
86
  end
87
87
 
88
+ # Build a {Plan} by inspecting a live connector instead of a manifest.
89
+ #
90
+ # The same information by a shorter route: no HTTP, no running server, and
91
+ # no snapshot on disk to go stale. It needs the application loaded, so it
92
+ # belongs in a build step rather than in a CDK app — which is the whole
93
+ # reason a {Plan} can also travel as JSON.
94
+ #
95
+ # A CONNECTOR, not a set of command classes. `requires_authentication` is
96
+ # decided at `connect` time and lives on the transformed command, so the
97
+ # command classes alone cannot say which commands are public — which is the
98
+ # one thing a deployment most needs to know.
99
+ #
100
+ # It adapts each command into the shape {plan} already reads, so there is
101
+ # one implementation and the two routes cannot drift.
102
+ def plan_from_connector(connector, **)
103
+ commands = connector.command_registry.all_transformed_command_classes.to_h do |transformed|
104
+ [transformed.command_class.full_command_name, describe_command(transformed)]
105
+ end
106
+
107
+ plan({ "command" => commands }, **)
108
+ end
109
+
88
110
  # Foobara's own commands — its auth domain and anything else it registers —
89
111
  # are infrastructure, not the application, and deploying them is never what
90
112
  # anyone means. Matched on the command's full name, which is where that
@@ -97,6 +119,35 @@ module Foobara
97
119
 
98
120
  private
99
121
 
122
+ # Manifest-shaped, so {plan} cannot tell the difference. Only the fields
123
+ # planning reads — a manifest carries much more, and none of the rest
124
+ # matters here.
125
+ def describe_command(transformed)
126
+ command_class = transformed.command_class
127
+
128
+ {
129
+ "domain" => domain_name(command_class),
130
+ "organization" => organization_name(command_class),
131
+ "scoped_full_path" => Array(command_class.scoped_full_path),
132
+ "requires_authentication" => transformed.requires_authentication,
133
+ "depends_on" => Array(command_class.depends_on).map(&:to_s),
134
+ # The AWSLambda DSL, when the command extends it.
135
+ "aws_lambda" => (command_class.aws_lambda_manifest if command_class.respond_to?(:aws_lambda_manifest))
136
+ }.compact
137
+ end
138
+
139
+ def domain_name(command_class)
140
+ command_class.domain.scoped_full_name
141
+ rescue StandardError
142
+ ""
143
+ end
144
+
145
+ def organization_name(command_class)
146
+ command_class.organization.scoped_full_name
147
+ rescue StandardError
148
+ ""
149
+ end
150
+
100
151
  def app_commands(manifest, exclude)
101
152
  manifest.fetch("command").reject do |name, _command|
102
153
  exclude.any? { |prefix| name.to_s.start_with?(prefix) }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Foobara
4
4
  module AWS
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/foobara/aws.rb CHANGED
@@ -27,4 +27,5 @@ end
27
27
 
28
28
  require_relative "aws/lambda"
29
29
  require_relative "aws/plan"
30
+ require_relative "aws/check"
30
31
  require_relative "aws/cdk/service"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara-aws
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Omar Qureshi
@@ -26,6 +26,7 @@ files:
26
26
  - lib/foobara/aws.rb
27
27
  - lib/foobara/aws/authorizer.rb
28
28
  - lib/foobara/aws/cdk/service.rb
29
+ - lib/foobara/aws/check.rb
29
30
  - lib/foobara/aws/error.rb
30
31
  - lib/foobara/aws/handler.rb
31
32
  - lib/foobara/aws/lambda.rb