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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +46 -0
- data/lib/foobara/aws/check.rb +156 -0
- data/lib/foobara/aws/plan.rb +51 -0
- data/lib/foobara/aws/version.rb +1 -1
- data/lib/foobara/aws.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 16a858ce4e2944711259dacbf844fd258a1715018aaa7df98364e45739ec33de
|
|
4
|
+
data.tar.gz: 995b9e3af507e6cb53e3fd581945d27b9251f9559f0151c9151e5fa5bb277191
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/foobara/aws/plan.rb
CHANGED
|
@@ -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) }
|
data/lib/foobara/aws/version.rb
CHANGED
data/lib/foobara/aws.rb
CHANGED
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.
|
|
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
|