foobara-aws 0.1.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 +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +175 -0
- data/Rakefile +12 -0
- data/lib/foobara/aws/authorizer.rb +183 -0
- data/lib/foobara/aws/cdk/service.rb +203 -0
- data/lib/foobara/aws/error.rb +9 -0
- data/lib/foobara/aws/handler.rb +139 -0
- data/lib/foobara/aws/lambda.rb +67 -0
- data/lib/foobara/aws/packager.rb +276 -0
- data/lib/foobara/aws/plan.rb +223 -0
- data/lib/foobara/aws/version.rb +7 -0
- data/lib/foobara/aws.rb +30 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f4de7e0012dfa7d11c92054cd25827b8b7f2e0864bf779027fe8c35a754c423e
|
|
4
|
+
data.tar.gz: b5009b5e2c62ba0db20ba010ad3580afec5c0524733fd8a8aa09f9a0afa8e475
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cb24ae7b484641d23c71c27ff59743ff1a2ac60eb0eea8c672da9c7177d05083cecfc535b47e722c09f995ca70fd9c8b32ea74ca1c5b6e2f2b0767ee9d58eedd
|
|
7
|
+
data.tar.gz: 87c12595977a88553fc092c6aa050dd22dcb25d433e5593d5b0594958b0e16cd42e47ae6553fcec8957089a8aeab68e620b251974a8e2da4bd38a65115793a29
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-08-04
|
|
4
|
+
|
|
5
|
+
- Initial release. Renamed from foobara-cdk before publishing: half of it is runtime code that loads inside a
|
|
6
|
+
Lambda, where "cdk" in the namespace would be a lie and CDK on the load path would be cold-start cost for nothing.
|
|
7
|
+
- `Foobara::AWS::Handler` — API Gateway v2 to Rack, with identity taken from the authorizer's verified claims.
|
|
8
|
+
Owns `Foobara::AWS.current_caller`, which is the answer to "who is calling?" for a command that is public and
|
|
9
|
+
viewer-aware — something Foobara's `requires_authentication` cannot express.
|
|
10
|
+
- `Foobara::AWS::Authorizer` — an optional-auth REQUEST authorizer. Verifies a token when one is present, lets
|
|
11
|
+
anonymous callers through on the commands the manifest says are public, and refuses everything else.
|
|
12
|
+
- `Foobara::AWS::Packager` — one artifact per unit: sources, a generated handler, and a standalone gem bundle
|
|
13
|
+
built in a Lambda-like container. Units whose gemfiles resolve identically share one bundle build; gems declared
|
|
14
|
+
by `path:` are copied in and their load paths rewritten, since bundler writes those relative to the bundle and
|
|
15
|
+
they would not survive being copied into an artifact.
|
|
16
|
+
- `Service` gained `tracing:` and `log_retention:`. `tracing: :active` is the cold-start measure worth having:
|
|
17
|
+
X-Ray splits a cold invocation into an Initialization subsegment and the handler's own work.
|
|
18
|
+
- Per-command units are closed over `depends_on`, transitively and cycle-safely.
|
|
19
|
+
- `Foobara::AWS.plan(manifest)` — reads a Foobara manifest into a deployment plan: one unit per domain
|
|
20
|
+
(or organization, or command), its route, and which of its commands are callable without authentication.
|
|
21
|
+
Plain data; needs neither aws-cdk-lib nor Foobara itself.
|
|
22
|
+
- `Plan#to_h` / `Plan.load` — the plan survives JSON, so it can be produced at build time where the app is and
|
|
23
|
+
consumed at synth time where the infrastructure is.
|
|
24
|
+
- `Foobara::AWS::CDK::Service` — synthesises the plan into one Lambda per unit behind an HTTP API, with greedy routes
|
|
25
|
+
and an optional REQUEST authorizer. Raises if a unit's artifact is missing rather than deploying a route to
|
|
26
|
+
nothing.
|
|
27
|
+
- `Foobara::AWS::Lambda` — `aws_lambda memory:/vcpu:/timeout:` on a command, carried through the manifest.
|
|
28
|
+
The one deployment fact a manifest cannot otherwise supply. `vcpu:` resolves to memory, since Lambda allocates
|
|
29
|
+
CPU in proportion to it.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Omar Qureshi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# foobara-aws
|
|
2
|
+
|
|
3
|
+
Deploy [Foobara](https://github.com/foobara) commands to AWS Lambda, with the
|
|
4
|
+
topology read out of the manifest instead of restated in infrastructure code.
|
|
5
|
+
|
|
6
|
+
`connect(Posts)` already says what a deployment unit is. The manifest already
|
|
7
|
+
says which commands it holds, where they are served, and which need
|
|
8
|
+
authentication. This reads that and builds the AWS resources.
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
plan = Foobara::AWS.plan(JSON.parse(File.read("build/plan.json")))
|
|
12
|
+
|
|
13
|
+
service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build")
|
|
14
|
+
|
|
15
|
+
posts_table.grant_read_write_data(service.function("posts"))
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Two halves, on purpose
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
Foobara::AWS.plan(manifest) # what to deploy. Plain data. No CDK, no Foobara.
|
|
22
|
+
Foobara::AWS::CDK::Service.new # the AWS resources. No application.
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
They are separable because they usually run in different places. The plan is
|
|
26
|
+
produced where the app is — a running connector serves `/manifest` — and
|
|
27
|
+
consumed where the infrastructure is, which is often a CDK app that should not
|
|
28
|
+
have to load an application in order to deploy it.
|
|
29
|
+
|
|
30
|
+
A plan survives a JSON round trip, so it travels as a build artifact:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
# in the build, against a running connector
|
|
34
|
+
File.write("build/plan.json", JSON.pretty_generate(Foobara::AWS.plan(manifest).to_h))
|
|
35
|
+
|
|
36
|
+
# in the CDK app — no Foobara, no ORM, no application gems
|
|
37
|
+
Foobara::AWS::CDK::Service.new(self, "Api",
|
|
38
|
+
plan: Foobara::AWS::Plan.load(JSON.parse(File.read("build/plan.json"))),
|
|
39
|
+
code_root: "build")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That also gets you a property worth having: synthesis depends on what was
|
|
43
|
+
actually built, so `cdk synth` cannot create a route to a Lambda whose code is
|
|
44
|
+
missing. `Service` raises if a unit's artifact directory is not there.
|
|
45
|
+
|
|
46
|
+
## What the plan reads
|
|
47
|
+
|
|
48
|
+
| plan | manifest field |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| unit identity and grouping | `domain` or `organization` |
|
|
51
|
+
| the route | `mount` + `scoped_full_path` |
|
|
52
|
+
| which commands are public | `requires_authentication` — **derived, never handed in** |
|
|
53
|
+
| how to size the function | `aws_lambda` (see below) |
|
|
54
|
+
|
|
55
|
+
The public list is the one to notice. It is not a list you maintain: a command
|
|
56
|
+
moves in or out of it by how it is connected, and nowhere else.
|
|
57
|
+
|
|
58
|
+
## Granularity
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
Foobara::AWS.plan(manifest) # one Lambda per domain
|
|
62
|
+
Foobara::AWS.plan(manifest, granularity: :organization) # one per organization
|
|
63
|
+
Foobara::AWS.plan(manifest, granularity: :command) # one per command
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Foobara gives two natural grouping levels where most frameworks give one, so
|
|
67
|
+
both are offered, plus the fine-grained case. Per-domain and per-organization
|
|
68
|
+
units get a greedy route (`/run/Posts/{proxy+}`); per-command units get an exact
|
|
69
|
+
one.
|
|
70
|
+
|
|
71
|
+
A greedy route is only safe with a REQUEST authorizer, which sees the path and
|
|
72
|
+
can decide per command. A JWT authorizer attaches per route, so a unit mixing
|
|
73
|
+
public and authenticated commands would have to be split into one route each.
|
|
74
|
+
|
|
75
|
+
## Sizing: `aws_lambda`
|
|
76
|
+
|
|
77
|
+
The one thing a manifest cannot otherwise supply. Foobara describes what a
|
|
78
|
+
command *is*, not how it should be run — but something has to carry it, and the
|
|
79
|
+
person who knows a command fans out across a whole comment tree is the person
|
|
80
|
+
writing that command:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
class DestroyPost < Foobara::Command
|
|
84
|
+
extend Foobara::AWS::Lambda
|
|
85
|
+
aws_lambda vcpu: 1, timeout: 60
|
|
86
|
+
...
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
No change to Foobara is required: a command's manifest is `super.merge(...)`, so
|
|
91
|
+
this adds a key and the connector serves it.
|
|
92
|
+
|
|
93
|
+
**`vcpu:` resolves to memory.** Lambda has no CPU setting — CPU is allocated in
|
|
94
|
+
proportion to memory, and 1,769 MB is where a function gets one full vCPU. So
|
|
95
|
+
asking for compute is more honest than picking a memory number, but it is not a
|
|
96
|
+
second dial. Give both and the larger memory wins.
|
|
97
|
+
|
|
98
|
+
Where a unit holds several commands, the largest value any of them asked for
|
|
99
|
+
wins: a unit runs all of its commands in one function, so it must be sized for
|
|
100
|
+
the hungriest. Undersizing is a runtime failure; oversizing is a rounding error
|
|
101
|
+
on the bill.
|
|
102
|
+
|
|
103
|
+
## The authorizer
|
|
104
|
+
|
|
105
|
+
Pass a built authorizer, or a hash and let `Service` build it:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build",
|
|
109
|
+
authorizer: { code: "build/authorizer", environment: { "ISSUER" => issuer } })
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Building it here is deliberate, because it is the only way to guarantee this:
|
|
113
|
+
|
|
114
|
+
**No identity source, and caching off.** Naming an identity source makes it
|
|
115
|
+
*required* — when the header is absent, API Gateway answers 401 itself and never
|
|
116
|
+
invokes the authorizer. Every anonymous caller is refused before any "is this
|
|
117
|
+
command public?" logic can run, which defeats the only reason to choose a
|
|
118
|
+
REQUEST authorizer over a JWT one. It is an easy mistake to make and a hard one
|
|
119
|
+
to see: the symptom is that public commands 401 for signed-out callers, with no
|
|
120
|
+
log line anywhere, because the function was never called.
|
|
121
|
+
|
|
122
|
+
The plan's public list and mount are passed to the function as
|
|
123
|
+
`FOOBARA_ANONYMOUS` and `FOOBARA_MOUNT`, so the authorizer decides per command
|
|
124
|
+
from `rawPath` without being configured separately.
|
|
125
|
+
|
|
126
|
+
`Service` does not implement the authorizer itself — verification is
|
|
127
|
+
identity-provider-specific, and `code:` is your own artifact.
|
|
128
|
+
|
|
129
|
+
## Packaging
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Foobara::AWS::Packager.new(plan: plan, root: ".", authorizer: {}).build
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
One artifact per unit: the application's sources, a generated `handler.rb`, and
|
|
136
|
+
a standalone gem bundle holding only that unit's dependencies (`units/<name>.gemfile`).
|
|
137
|
+
Units whose gemfiles resolve identically share one bundle build.
|
|
138
|
+
|
|
139
|
+
The generated handler is fully generic — everything unit-specific comes from the
|
|
140
|
+
plan, and everything app-specific from the boot file, where the application sets
|
|
141
|
+
`Foobara::AWS.caller_builder`. Pass `handler_template:` for your own.
|
|
142
|
+
|
|
143
|
+
Two things it handles that are easy to get wrong:
|
|
144
|
+
|
|
145
|
+
- **Standalone, not `bundle exec`.** Bundler's runtime is a large fraction of a
|
|
146
|
+
Ruby cold start and buys a deployed artifact nothing.
|
|
147
|
+
- **Gems declared by `path:`.** Bundler does not copy those into a standalone
|
|
148
|
+
bundle; it writes their location into the load path, relative to the bundle
|
|
149
|
+
directory. Copy the bundle into an artifact and that path resolves elsewhere,
|
|
150
|
+
so the unit boots on the build machine and dies in Lambda. They are copied in
|
|
151
|
+
and the load path rewritten. (Use `mounts:` so the build container can see
|
|
152
|
+
them in the first place.)
|
|
153
|
+
|
|
154
|
+
## What it does not do
|
|
155
|
+
|
|
156
|
+
- **Create tables, buckets or queues.** Those are the application's, not the
|
|
157
|
+
connector's. For DynamoDB from Dynamoid models, see
|
|
158
|
+
[dynamoid-cdk-schema](https://github.com/omarqureshi/dynamoid-cdk-schema),
|
|
159
|
+
which follows the same describe/build split.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
bin/setup
|
|
165
|
+
bundle exec rspec
|
|
166
|
+
bundle exec rubocop
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The specs use a manifest fragment carrying only the fields planning reads — no
|
|
170
|
+
type declarations, no possible errors — which is a check in itself that nothing
|
|
171
|
+
else is needed.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An API Gateway v2 REQUEST authorizer that supports OPTIONAL authentication.
|
|
4
|
+
#
|
|
5
|
+
# RUNTIME, not synth. Require this file and nothing else from the gem — it loads
|
|
6
|
+
# no planning code and no CDK. It runs on EVERY request, including anonymous
|
|
7
|
+
# ones, so its cold start is the one that matters most.
|
|
8
|
+
#
|
|
9
|
+
# require "foobara/aws/authorizer"
|
|
10
|
+
#
|
|
11
|
+
# HANDLER = Foobara::AWS::Authorizer.from_env(issuer: ENV.fetch("ISSUER"),
|
|
12
|
+
# audience: ENV.fetch("AUDIENCE"))
|
|
13
|
+
# def handle(event:, context:) = HANDLER.call(event, context)
|
|
14
|
+
#
|
|
15
|
+
# The public list and the mount come from the environment — FOOBARA_ANONYMOUS
|
|
16
|
+
# and FOOBARA_MOUNT, which Foobara::AWS::CDK::Service sets from the plan. So the
|
|
17
|
+
# set of commands reachable without authentication is derived from
|
|
18
|
+
# `requires_authentication` and never maintained by hand.
|
|
19
|
+
#
|
|
20
|
+
# WHY IT EXISTS. An API Gateway JWT authorizer is all-or-nothing: it rejects a
|
|
21
|
+
# request with no token, and a route without one receives no verified claims at
|
|
22
|
+
# all. There is no "verify if present". That breaks any command which is public
|
|
23
|
+
# but viewer-aware — one that marks your own posts editable, say. Such a command
|
|
24
|
+
# must be readable signed out AND must know who is calling when someone is
|
|
25
|
+
# signed in.
|
|
26
|
+
#
|
|
27
|
+
# This sees rawPath, so it decides per COMMAND rather than per route. Routes stay
|
|
28
|
+
# greedy, which keeps the route count off API Gateway's per-API quota, and the
|
|
29
|
+
# public list is configuration rather than topology.
|
|
30
|
+
#
|
|
31
|
+
# IDENTITY SOURCE. The stack must NOT declare one — Service guarantees this, and
|
|
32
|
+
# it is the reason Service builds the authorizer rather than accepting one.
|
|
33
|
+
# Naming an identity source makes it required: when the header is absent API
|
|
34
|
+
# Gateway answers 401 itself and never invokes this function, so every anonymous
|
|
35
|
+
# caller is refused before the logic below can allow them.
|
|
36
|
+
#
|
|
37
|
+
# It does NOT decide what a caller may do. It answers "is this token real, and is
|
|
38
|
+
# this command reachable without one" — authorization is the command's own,
|
|
39
|
+
# through Foobara's allowed_rule.
|
|
40
|
+
#
|
|
41
|
+
# Verification covers signature (RS256 against the issuer's JWKS), exp, iss and
|
|
42
|
+
# aud. JWKS is fetched once per execution environment — cold-start scope, never
|
|
43
|
+
# request scope.
|
|
44
|
+
|
|
45
|
+
require "json"
|
|
46
|
+
require "net/http"
|
|
47
|
+
require "uri"
|
|
48
|
+
|
|
49
|
+
require_relative "error"
|
|
50
|
+
require_relative "version"
|
|
51
|
+
|
|
52
|
+
module Foobara
|
|
53
|
+
module AWS
|
|
54
|
+
class Unverified < StandardError; end
|
|
55
|
+
|
|
56
|
+
class Authorizer
|
|
57
|
+
# Reads the plan-derived settings Service puts in the environment, so a
|
|
58
|
+
# deployed authorizer needs no configuration of its own beyond who issued
|
|
59
|
+
# the tokens.
|
|
60
|
+
def self.from_env(issuer:, audience:, jwks_url: nil)
|
|
61
|
+
new(
|
|
62
|
+
issuer:, audience:, jwks_url:,
|
|
63
|
+
anonymous: ENV.fetch("FOOBARA_ANONYMOUS", "").split(","),
|
|
64
|
+
mount: ENV.fetch("FOOBARA_MOUNT", "/run")
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def initialize(issuer:, audience:, anonymous: [], mount: "/run", jwks_url: nil)
|
|
69
|
+
@issuer = issuer.to_s.chomp("/")
|
|
70
|
+
@audience = Array(audience)
|
|
71
|
+
# Foobara's own full command names — "Posts::ListPosts" — so the list the
|
|
72
|
+
# packager derives from the manifest travels here untranslated.
|
|
73
|
+
@anonymous = Array(anonymous).map(&:to_s)
|
|
74
|
+
@mount = mount
|
|
75
|
+
@jwks_url = jwks_url || "#{@issuer}/.well-known/jwks.json"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# SIMPLE response format:
|
|
79
|
+
# { "isAuthorized" => bool, "context" => { ...claims } }
|
|
80
|
+
# Anything in `context` reaches the command Lambda at
|
|
81
|
+
# requestContext.authorizer.lambda, which is where the handler reads the
|
|
82
|
+
# viewer from.
|
|
83
|
+
def call(event, _lambda_context = nil)
|
|
84
|
+
token = bearer(event)
|
|
85
|
+
command = command_name(event)
|
|
86
|
+
|
|
87
|
+
if token.nil?
|
|
88
|
+
# No token at all. Allowed only where the command is declared public —
|
|
89
|
+
# and it reaches the command with no claims, so a public viewer-aware
|
|
90
|
+
# command correctly sees an anonymous caller.
|
|
91
|
+
return allow({}) if anonymous?(command)
|
|
92
|
+
|
|
93
|
+
return deny
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
begin
|
|
97
|
+
allow(claims_from(token))
|
|
98
|
+
rescue Unverified
|
|
99
|
+
# A present-but-invalid token: expired, wrong audience, bad signature.
|
|
100
|
+
#
|
|
101
|
+
# On a public command this is treated as anonymous rather than refused —
|
|
102
|
+
# someone whose session expired should still see the public feed, and
|
|
103
|
+
# they gain nothing by it. On a gated command it is a refusal.
|
|
104
|
+
anonymous?(command) ? allow({}) : deny
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def allow(context) = { "isAuthorized" => true, "context" => stringify(context) }
|
|
111
|
+
def deny = { "isAuthorized" => false }
|
|
112
|
+
|
|
113
|
+
# API Gateway forwards only strings in the authorizer context.
|
|
114
|
+
def stringify(claims)
|
|
115
|
+
claims.to_h { |k, v| [k.to_s, v.is_a?(Array) ? v.join(",") : v.to_s] }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def anonymous?(command) = command && @anonymous.include?(command)
|
|
119
|
+
|
|
120
|
+
# "/run/Posts/ListPosts" -> "Posts::ListPosts", which is exactly the key the
|
|
121
|
+
# Foobara manifest files that command under. Deciding here rather than in
|
|
122
|
+
# routing is what lets routes stay greedy.
|
|
123
|
+
def command_name(event)
|
|
124
|
+
path = event["rawPath"] || event.dig("requestContext", "http", "path") || ""
|
|
125
|
+
parts = path.delete_prefix("#{@mount}/").split("/")
|
|
126
|
+
parts.length == 2 ? parts.join("::") : nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def bearer(event)
|
|
130
|
+
headers = event["headers"] || {}
|
|
131
|
+
raw = headers["authorization"] ||
|
|
132
|
+
headers.find { |k, _| k.to_s.downcase == "authorization" }&.last
|
|
133
|
+
return nil if raw.nil? || raw.empty?
|
|
134
|
+
|
|
135
|
+
raw.to_s.sub(/\Abearer\s+/i, "").strip.then { |t| t.empty? ? nil : t }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def claims_from(token)
|
|
139
|
+
require_jwt!
|
|
140
|
+
|
|
141
|
+
payload, = JWT.decode(
|
|
142
|
+
token, nil, true,
|
|
143
|
+
algorithms: ["RS256"],
|
|
144
|
+
jwks: jwks,
|
|
145
|
+
iss: @issuer, verify_iss: true,
|
|
146
|
+
aud: @audience, verify_aud: !@audience.empty?,
|
|
147
|
+
verify_expiration: true
|
|
148
|
+
)
|
|
149
|
+
payload
|
|
150
|
+
rescue Unverified
|
|
151
|
+
raise # an unreachable JWKS is already the right failure
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
# Broad on purpose: the jwt gem raises several unrelated classes for
|
|
154
|
+
# signature, key-lookup and claim failures, and every one of them means the
|
|
155
|
+
# same thing here — this token is not trustworthy.
|
|
156
|
+
raise Unverified, "#{e.class}: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# `jwt` is deliberately not a dependency of this gem: only the authorizer
|
|
160
|
+
# needs it, only the authorizer's own deployment unit installs it, and a
|
|
161
|
+
# bare LoadError — which is not even a StandardError, so it escapes the
|
|
162
|
+
# rescue above — would say none of that.
|
|
163
|
+
def require_jwt!
|
|
164
|
+
require "jwt"
|
|
165
|
+
rescue LoadError
|
|
166
|
+
raise Error,
|
|
167
|
+
"Foobara::AWS::Authorizer needs the `jwt` gem, which foobara-aws does not depend on — " \
|
|
168
|
+
"only the authorizer uses it. Add `gem \"jwt\"` to the authorizer unit's gemfile."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Cold-start scope: fetched once per execution environment and reused across
|
|
172
|
+
# invocations. Nothing request-specific may be cached here.
|
|
173
|
+
def jwks
|
|
174
|
+
@jwks ||= begin
|
|
175
|
+
JSON.parse(Net::HTTP.get(URI(@jwks_url)), symbolize_names: true)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
# An unreachable JWKS must not quietly become "everyone is anonymous".
|
|
178
|
+
raise Unverified, "could not fetch JWKS from #{@jwks_url}: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../error"
|
|
4
|
+
# Service takes a Plan and every caller has to build one, so requiring
|
|
5
|
+
# "foobara/aws/cdk/service" alone must be enough.
|
|
6
|
+
require_relative "../plan"
|
|
7
|
+
|
|
8
|
+
module Foobara
|
|
9
|
+
module AWS
|
|
10
|
+
# Everything here is SYNTH time. Nothing under Foobara::AWS::CDK is
|
|
11
|
+
# ever loaded inside a Lambda — that is why the handler and the
|
|
12
|
+
# authorizer live outside it.
|
|
13
|
+
module CDK
|
|
14
|
+
# Synthesises a {Plan} into Lambda functions behind one HTTP API.
|
|
15
|
+
#
|
|
16
|
+
# AWS CDK is the caller's dependency (the Ruby CDK, from its preview feed),
|
|
17
|
+
# so it is referenced lazily — {Foobara::AWS.plan} works without it, which is
|
|
18
|
+
# what lets a build step produce a plan on a machine that has no CDK at all.
|
|
19
|
+
#
|
|
20
|
+
# service = Foobara::AWS::CDK::Service.new(self, "Api", plan: plan, code_root: "build")
|
|
21
|
+
# table.grant_read_write_data(service.function("posts"))
|
|
22
|
+
#
|
|
23
|
+
# It does NOT build artifacts. `code_root` must already contain one directory
|
|
24
|
+
# per unit, each with a `handler.rb` — so synthesis cannot create a route to a
|
|
25
|
+
# Lambda whose code was never built.
|
|
26
|
+
class Service
|
|
27
|
+
DEFAULT_SIZING = { memory_size: 1024, timeout_seconds: 10 }.freeze
|
|
28
|
+
|
|
29
|
+
attr_reader :api, :functions, :plan
|
|
30
|
+
|
|
31
|
+
# rubocop:disable Metrics/ParameterLists
|
|
32
|
+
def initialize(scope, id, plan:, code_root:, sizing: {}, defaults: DEFAULT_SIZING,
|
|
33
|
+
environment: {}, runtime: nil, architecture: nil, authorizer: nil,
|
|
34
|
+
api_props: {}, tracing: nil, log_retention: nil)
|
|
35
|
+
# Checked first: everything below needs it, and a bare LoadError from
|
|
36
|
+
# `constructs` would say nothing about which dependency is missing.
|
|
37
|
+
cdk
|
|
38
|
+
|
|
39
|
+
@scope = construct(scope, id)
|
|
40
|
+
@plan = plan
|
|
41
|
+
@code_root = code_root
|
|
42
|
+
@sizing = sizing.transform_keys(&:to_s)
|
|
43
|
+
@defaults = DEFAULT_SIZING.merge(defaults)
|
|
44
|
+
@environment = environment
|
|
45
|
+
@runtime = runtime
|
|
46
|
+
@architecture = architecture
|
|
47
|
+
@tracing = tracing
|
|
48
|
+
@log_retention = log_retention
|
|
49
|
+
@functions = {}
|
|
50
|
+
|
|
51
|
+
@api = cdk::APIGatewayv2::HttpAPI.new(@scope, "Api", api_props)
|
|
52
|
+
@authorizer = build_authorizer(authorizer)
|
|
53
|
+
plan.units.each { |unit| add_unit(unit) }
|
|
54
|
+
end
|
|
55
|
+
# rubocop:enable Metrics/ParameterLists
|
|
56
|
+
|
|
57
|
+
# Look a unit's function up by name, for grants:
|
|
58
|
+
# table.grant_read_write_data(service.function("posts"))
|
|
59
|
+
def function(name) = @functions.fetch(name.to_s)
|
|
60
|
+
|
|
61
|
+
def url = @api.url
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def add_unit(unit)
|
|
66
|
+
fn = build_function(unit)
|
|
67
|
+
@functions[unit.name] = fn
|
|
68
|
+
|
|
69
|
+
props = {
|
|
70
|
+
path: unit.route,
|
|
71
|
+
methods: [cdk::APIGatewayv2::HttpMethod::ANY],
|
|
72
|
+
integration: cdk::APIGatewayv2Integrations::HttpLambdaIntegration.new(
|
|
73
|
+
"#{logical(unit.name)}Integration", fn
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
props[:authorizer] = @authorizer if @authorizer
|
|
77
|
+
@api.add_routes(props)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_function(unit)
|
|
81
|
+
# Precedence: an explicit sizing: override, then whatever the unit's
|
|
82
|
+
# commands declared via aws_lambda, then the defaults. The override
|
|
83
|
+
# exists for the case the declaration cannot know about — an
|
|
84
|
+
# environment where everything should be small, say.
|
|
85
|
+
sizing = @defaults.merge(unit.sizing).merge(@sizing.fetch(unit.name, {}))
|
|
86
|
+
asset = File.join(@code_root, unit.name)
|
|
87
|
+
unless Dir.exist?(asset)
|
|
88
|
+
raise Error, "no artifact for unit #{unit.name.inspect} at #{asset} — build it before synthesising"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
cdk::Lambda::Function.new(@scope, logical(unit.name), {
|
|
92
|
+
runtime: @runtime || cdk::Lambda::Runtime.RUBY_4_0,
|
|
93
|
+
# x86_64 by default: building arm64 artifacts on an x86_64 host
|
|
94
|
+
# runs under qemu, which is dramatically slower.
|
|
95
|
+
architecture: @architecture || cdk::Lambda::Architecture.X86_64,
|
|
96
|
+
handler: "handler.handle",
|
|
97
|
+
code: cdk::Lambda::Code.from_asset(asset),
|
|
98
|
+
memory_size: sizing.fetch(:memory_size),
|
|
99
|
+
timeout: cdk::Duration.seconds(sizing.fetch(:timeout_seconds)),
|
|
100
|
+
environment: @environment.merge("FOOBARA_UNIT" => unit.name)
|
|
101
|
+
}.merge(observability))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Accepts an already-built authorizer, or a hash describing one to build.
|
|
105
|
+
#
|
|
106
|
+
# Building it here rather than leaving it to the caller is deliberate: it
|
|
107
|
+
# is the only way to guarantee the two settings below, and getting either
|
|
108
|
+
# wrong silently breaks optional authentication rather than failing.
|
|
109
|
+
def build_authorizer(config)
|
|
110
|
+
return nil if config.nil?
|
|
111
|
+
return config unless config.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
fn = authorizer_function(config)
|
|
114
|
+
|
|
115
|
+
cdk::APIGatewayv2Authorizers::HttpLambdaAuthorizer.new(
|
|
116
|
+
config.fetch(:id, "FoobaraAuth"), fn,
|
|
117
|
+
{
|
|
118
|
+
response_types: [cdk::APIGatewayv2Authorizers::HttpLambdaResponseType::SIMPLE],
|
|
119
|
+
# NO identity source, and this is not an oversight.
|
|
120
|
+
#
|
|
121
|
+
# Naming one makes it REQUIRED: when the header is absent, API
|
|
122
|
+
# Gateway answers 401 itself and never invokes the authorizer. Every
|
|
123
|
+
# anonymous caller is then refused before any "is this command
|
|
124
|
+
# public?" logic can run — which defeats the only reason to choose a
|
|
125
|
+
# REQUEST authorizer over a JWT one.
|
|
126
|
+
#
|
|
127
|
+
# With no identity source the authorizer runs on every request, which
|
|
128
|
+
# is what optional means. Caching must then be off: there is no key
|
|
129
|
+
# to cache under.
|
|
130
|
+
identity_source: [],
|
|
131
|
+
results_cache_ttl: cdk::Duration.seconds(0)
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def authorizer_function(config)
|
|
137
|
+
asset = config.fetch(:code)
|
|
138
|
+
# The public list is passed in, not configured: it comes from the plan,
|
|
139
|
+
# which read it from the manifest.
|
|
140
|
+
env = { "FOOBARA_ANONYMOUS" => plan.anonymous.join(","),
|
|
141
|
+
"FOOBARA_MOUNT" => plan.mount }.merge(config.fetch(:environment, {}))
|
|
142
|
+
|
|
143
|
+
cdk::Lambda::Function.new(@scope, config.fetch(:id, "FoobaraAuth"), {
|
|
144
|
+
runtime: @runtime || cdk::Lambda::Runtime.RUBY_4_0,
|
|
145
|
+
architecture: @architecture || cdk::Lambda::Architecture.X86_64,
|
|
146
|
+
handler: config.fetch(:handler, "handler.handle"),
|
|
147
|
+
code: cdk::Lambda::Code.from_asset(asset),
|
|
148
|
+
memory_size: config.fetch(:memory_size, 512),
|
|
149
|
+
timeout: cdk::Duration.seconds(config.fetch(:timeout_seconds, 10)),
|
|
150
|
+
environment: env
|
|
151
|
+
}.merge(observability))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Tracing and log retention, applied identically to every function
|
|
155
|
+
# including the authorizer — a trace that stops at the authorizer would
|
|
156
|
+
# hide the one invocation that happens on every single request.
|
|
157
|
+
#
|
|
158
|
+
# `tracing: :active` is the cold-start measure worth having: X-Ray breaks
|
|
159
|
+
# a cold invocation into an Initialization subsegment and the handler's
|
|
160
|
+
# own work, so you can see what the boot actually costs rather than
|
|
161
|
+
# inferring it. CloudWatch's REPORT line already carries Init Duration
|
|
162
|
+
# per invocation; X-Ray adds the shape of it, and the view across
|
|
163
|
+
# services once a command calls another.
|
|
164
|
+
#
|
|
165
|
+
# NOTE it is Lambda-side only. An API Gateway HTTP API (v2) does not
|
|
166
|
+
# support X-Ray — only REST APIs do — so the trace begins at the
|
|
167
|
+
# function, not at the edge.
|
|
168
|
+
def observability
|
|
169
|
+
props = {}
|
|
170
|
+
props[:tracing] = tracing_mode(@tracing) if @tracing
|
|
171
|
+
props[:log_retention] = @log_retention if @log_retention
|
|
172
|
+
props
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def tracing_mode(value)
|
|
176
|
+
case value
|
|
177
|
+
when :active then cdk::Lambda::Tracing::ACTIVE
|
|
178
|
+
when :pass_through then cdk::Lambda::Tracing::PASS_THROUGH
|
|
179
|
+
when :disabled then cdk::Lambda::Tracing::DISABLED
|
|
180
|
+
else value # an already-resolved CDK enum
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A child construct, so the functions' logical ids cannot collide with
|
|
185
|
+
# anything at stack level — a "comments" function and a "Comments" table
|
|
186
|
+
# both want the id "Comments" otherwise, and synthesis fails.
|
|
187
|
+
def construct(scope, id)
|
|
188
|
+
require "constructs"
|
|
189
|
+
Constructs::Construct.new(scope, id)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# CloudFormation logical ids must be alphanumeric.
|
|
193
|
+
def logical(name) = name.to_s.split(/[.\-_]/).map(&:capitalize).join
|
|
194
|
+
|
|
195
|
+
def cdk
|
|
196
|
+
return AWSCDK if defined?(AWSCDK)
|
|
197
|
+
|
|
198
|
+
raise Error, "aws-cdk-lib must be loaded to synthesise — add it to your CDK app and `require \"aws-cdk-lib\"`"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|